Skip to content

API Reference

Story(data=None, width=None, height=None, font=None, base_font_size=None, template=None, geodata=False, geojson_url=None, geojson_property=None, geo_property=None, geo_value=None, **kwargs)

Story class: Implements a structure for creating narrative views of data.

This class extends the functionality of Altair to include narrative elements
such as titles, contexts, and call-to-action elements. It is designed to facilitate
the creation of more engaging and informative data visualisations.

Parameters:

Name Type Description Default
data DataFrame | str

DataFrame or URL/path containing the chart data. Defaults to None.

None
width int

Graph width in pixels. If omitted or None, uses the preferred width from the template (if used), falling back to 600. Defaults to None.

None
height int

Graph height in pixels. If omitted or None, uses the preferred height from the template (if used), falling back to 400. Defaults to None.

None
font str

Font family to be applied to all text elements. If omitted or None, uses the value from the template (if used), falling back to Times New Roman for the title (if used) and Noto Sans for all the rest. Defaults to None.

None
base_font_size int | float

Base font size in pixels. If omitted or None, uses the value from the template (if used) Defaults to None.

None
template Template | type[Template]

Instance or subclass of Template for styling. If omitted or None, uses DefaultTemplate(). Defaults to None.

None
geodata bool

Indicates whether the chart handles spatial data. Defaults to False.

False
geojson_url str, optional, required if `geodata=True`

File path or URL to the GeoJSON file. Required if geodata=True. Defaults to None.

None
geojson_property str, optional, required if `geodata=True`

Key property name in the GeoJSON used for merging. Required if geodata=True. Defaults to None.

None
geo_property str, optional, required if `geodata=True`

DataFrame column name matching geojson_property. Required if geodata=True. Defaults to None.

None
geo_value str, optional, required if `geodata=True`

DataFrame column name containing the numerical or categorical values to map spatially. Required if geodata=True. Defaults to None.

None
**kwargs

Additional keyword arguments passed directly to the alt.Chart constructor.

{}

Raises:

Type Description
ValueError

If template is neither an instance nor a subclass of Template.

ValueError

If geodata=True and any required spatial arguments (geojson_url, geojson_property, geo_property, geo_value) are missing.

ValueError

If geojson_url does not have a .json or .geojson extension.

ValueError

If geo_property or geo_value are not valid columns in data.

Source code in pynarrative/src/pynarrative/story.py
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
def __init__(
        self,
        data=None,
        width=None,
        height=None,
        font=None,
        base_font_size=None,
        template=None,
        geodata = False,
        geojson_url = None,
        geojson_property = None,
        geo_property = None,
        geo_value = None,
        **kwargs
    ):

    """Story class: Implements a structure for creating narrative views of data.

        This class extends the functionality of Altair to include narrative elements
        such as titles, contexts, and call-to-action elements. It is designed to facilitate
        the creation of more engaging and informative data visualisations.


    Args:
        data (pandas.DataFrame | str, optional): DataFrame or URL/path containing
            the chart data. Defaults to None.
        width (int, optional): Graph width in pixels. If omitted or None, uses the
            preferred width from the template (if used), falling back to 600. Defaults to None.
        height (int, optional): Graph height in pixels. If omitted or None, uses the
            preferred height from the template (if used), falling back to 400. Defaults to None.
        font (str, optional): Font family to be applied to all text elements.
            If omitted or None, uses the value from the template (if used), falling back to
            Times New Roman for the title (if used) and Noto Sans for all the rest.
            Defaults to None.
        base_font_size (int | float, optional): Base font size in pixels.
            If omitted or None, uses the value from the template (if used)
            Defaults to None.
        template (Template | type[Template], optional): Instance or subclass of
            `Template` for styling. If omitted or None, uses `DefaultTemplate()`.
            Defaults to None.
        geodata (bool, optional): Indicates whether the chart handles spatial data.
            Defaults to False.
        geojson_url (str, optional, required if `geodata=True`): File path or URL to the GeoJSON file.
            Required if `geodata=True`. Defaults to None.
        geojson_property (str, optional, required if `geodata=True`): Key property name in the GeoJSON used for
            merging. Required if `geodata=True`. Defaults to None.
        geo_property (str, optional, required if `geodata=True`): DataFrame column name matching `geojson_property`.
            Required if `geodata=True`. Defaults to None.
        geo_value (str, optional, required if `geodata=True`): DataFrame column name containing the numerical or
            categorical values to map spatially. Required if `geodata=True`.
            Defaults to None.
        **kwargs: Additional keyword arguments passed directly to the `alt.Chart`
            constructor.

    Raises:
        ValueError: If `template` is neither an instance nor a subclass of `Template`.
        ValueError: If `geodata=True` and any required spatial arguments
            (`geojson_url`, `geojson_property`, `geo_property`, `geo_value`) are missing.
        ValueError: If `geojson_url` does not have a `.json` or `.geojson` extension.
        ValueError: If `geo_property` or `geo_value` are not valid columns in `data`.
    """
    if template is None:
        template = DefaultTemplate()
    elif isinstance(template, type) and issubclass(template, Template):
        template = template()
    elif not isinstance(template, Template):
        raise ValueError("template must be a Template instance or Template subclass")

    if font is not None:
        template.style.set_font(font)
    if base_font_size is not None:
        template.style.set_base_font_size(base_font_size)

    #Managing geographical data
    if geodata and None in [geojson_url, geojson_property, geo_property, geo_value]:
        raise ValueError("if `geodata=True`, 'geojson_url', 'geojson_property', 'geo_property', 'geo_value' are required arguments")
    if geodata:
        if geojson_url: #Checking extension
            if os.path.splitext(geojson_url)[1] not in [".geojson", ".json"]:
                raise ValueError("'geojson_url' must be a .geojson or .json file")
        if geo_property not in data.columns:
            raise ValueError(f"'{geo_property}' must be a column in your dataframe")
        if geo_value not in data.columns:
                raise ValueError(f"'{geo_value}' must be a column in your dataframe")

    if geodata:
        geo_dataframe = gpd.read_file(geojson_url)
        geo_dataframe = geo_dataframe.merge(data, left_on = geojson_property, right_on = geo_property, how = "left")
        geo_dataframe[geo_value] = geo_dataframe[geo_value].fillna(0)
        data = geo_dataframe


    self.template = template
    self.font = template.font
    self.base_font_size = template.base_font_size
    chart_width = width if width is not None else self.chart_style_value(template, 'preferred_width', 600)
    self.chart_width = chart_width

    chart_height = height if height is not None else self.chart_style_value(template, 'preferred_height', 400)

    # Initialising the Altair Chart object with basic parameters
    self.chart = alt.Chart(data, width=chart_width, height=chart_height, **kwargs)
    self.story_layers = []  # List for storing history layers

    # Dictionaries for the sizes and colours of various text elements
    # These values are multipliers for base_font_size
    self.style = template.style
    self.layout = template.layout
    self.font_sizes = self.style['font_sizes']
    self.colors = self.style['colors']
    self.context_box = self.style['context_box']
    self.chart_style = {**self.style, **self.layout}
    self.config = {}
    self._warned_about_manual_override = False

add_labels_chart(values=None, color=None, orientation='horizontal', angle=0, dx=0, dy=0, font_size=11, font_weight='normal', geodata=False, geodata_label=None)

Adds custom text labels to an existing chart layer or map visualization.

Creates an Altair mark_text layer positioned relative to existing chart encodings or geodata centroids. Supports custom orientations, offsets, and fonts.

Parameters:

Name Type Description Default
values str | list | Series

Column name or explicit list of values to display as labels. If None, automatically defaults to the Y-axis encoding field. Defaults to None.

None
color str

Custom fill color for the label text. If omitted or None, falls back to the chart_label_color defined in the template style settings. Defaults to None.

None
orientation str

Preset label layout orientation. Must be one of 'horizontal', 'vertical_left' (angle=90), or 'vertical_right' (angle=270). For custom rotations, use 'horizontal' and set angle directly. Defaults to "horizontal".

'horizontal'
angle int | float

Label rotation angle in degrees. Must be between 0 and 360. Cannot be combined with preset orientations other than 'horizontal'. Defaults to 0.

0
dx int | float

Horizontal pixel offset for text positioning. Defaults to 0.

0
dy int | float

Vertical pixel offset for text positioning. Defaults to 0.

0
font_size int | float

Font size of the text labels in pixels. Defaults to 11.

11
font_weight str

Font weight for the label text. Must be one of 'normal' or 'bold'. Defaults to "normal".

'normal'
geodata bool

Whether the target chart represents geographic map data. If True, labels are placed on spatial geometry centroids. Defaults to False.

False
geodata_label str

The column name containing text labels when geodata=True. Required if geodata is True. Defaults to None.

None

Returns:

Name Type Description
Story

The current instance (self) to enable method chaining.

Raises:

Type Description
TypeError

If numerical parameters (angle, dx, dy, font_size) receive boolean or non-numeric types.

TypeError

If geodata is not a boolean.

ValueError

If used on multi-series charts without enabling geodata=True.

ValueError

If angle is not between 0 and 360 degrees.

ValueError

If orientation is not one of 'horizontal', 'vertical_left', or 'vertical_right'.

ValueError

If a non-zero angle is combined with 'vertical_left' or 'vertical_right'.

ValueError

If font_weight is not 'normal' or 'bold'.

ValueError

If geodata=True but geodata_label is omitted.

ValueError

If no Y-axis encoding or explicit values can be resolved.

Source code in pynarrative/src/pynarrative/story.py
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
def add_labels_chart(self,
                     values = None,
                     color = None,
                     orientation = "horizontal",
                     angle=0,
                     dx=0,
                     dy=0,
                     font_size=11,
                     font_weight="normal",
                     geodata = False,
                     geodata_label = None
                     ):
    """Adds custom text labels to an existing chart layer or map visualization.

    Creates an Altair `mark_text` layer positioned relative to existing chart encodings
    or geodata centroids. Supports custom orientations, offsets, and fonts.

    Args:
        values (str | list | pandas.Series, optional): Column name or explicit list of values
            to display as labels. If None, automatically defaults to the Y-axis encoding field.
            Defaults to None.
        color (str, optional): Custom fill color for the label text. If omitted or None,
            falls back to the `chart_label_color` defined in the template style settings.
            Defaults to None.
        orientation (str, optional): Preset label layout orientation. Must be one of
            `'horizontal'`, `'vertical_left'` (angle=90), or `'vertical_right'` (angle=270).
            For custom rotations, use `'horizontal'` and set `angle` directly.
            Defaults to "horizontal".
        angle (int | float, optional): Label rotation angle in degrees. Must be between 0 and 360.
            Cannot be combined with preset orientations other than `'horizontal'`.
            Defaults to 0.
        dx (int | float, optional): Horizontal pixel offset for text positioning.
            Defaults to 0.
        dy (int | float, optional): Vertical pixel offset for text positioning.
            Defaults to 0.
        font_size (int | float, optional): Font size of the text labels in pixels.
            Defaults to 11.
        font_weight (str, optional): Font weight for the label text. Must be one of
            `'normal'` or `'bold'`. Defaults to "normal".
        geodata (bool, optional): Whether the target chart represents geographic map data.
            If True, labels are placed on spatial geometry centroids. Defaults to False.
        geodata_label (str, optional): The column name containing text labels when `geodata=True`.
            Required if `geodata` is True. Defaults to None.

    Returns:
        Story: The current instance (`self`) to enable method chaining.

    Raises:
        TypeError: If numerical parameters (`angle`, `dx`, `dy`, `font_size`) receive boolean or non-numeric types.
        TypeError: If `geodata` is not a boolean.
        ValueError: If used on multi-series charts without enabling `geodata=True`.
        ValueError: If `angle` is not between 0 and 360 degrees.
        ValueError: If `orientation` is not one of `'horizontal'`, `'vertical_left'`, or `'vertical_right'`.
        ValueError: If a non-zero `angle` is combined with `'vertical_left'` or `'vertical_right'`.
        ValueError: If `font_weight` is not `'normal'` or `'bold'`.
        ValueError: If `geodata=True` but `geodata_label` is omitted.
        ValueError: If no Y-axis encoding or explicit `values` can be resolved.
    """

    if not isinstance(geodata, bool):
        raise TypeError(f"'geodata' must be a boolean, got {type(geodata).__name__}")

    if isinstance(angle, bool) or not isinstance(angle, (int, float)):
        raise TypeError(f"'angle' must be a numeric integer or float, got {type(angle).__name__}")

    if not (0 <= angle <= 360):
        raise ValueError(f"Value of 'angle' must be between 0 and 360, got {angle}")

    if isinstance(dx, bool) or not isinstance(dx, (int, float)):
        raise TypeError(f"'dx' must be a numeric integer or float, got {type(dx).__name__}")

    if isinstance(dy, bool) or not isinstance(dy, (int, float)):
        raise TypeError(f"'dy' must be a numeric integer or float, got {type(dy).__name__}")

    if isinstance(font_size, bool) or not isinstance(font_size, (int, float)) or font_size <= 0:
        raise TypeError(f"'font_size' must be a positive number, got {type(font_size).__name__}")

    if font_weight not in ["normal", "bold"]:
        raise ValueError(f"Invalid 'font_weight' value: '{font_weight}'. Must be 'normal' or 'bold'")

    has_series, _ = self._get_encoding_field_and_type("color")
    if has_series is not None and not geodata:
        raise ValueError(
            "add_labels_chart cannot be used on charts with multiple series. "
            "For geographical data, set 'geodata = True'."
        )


    preset_orientations = ["horizontal", "vertical_left", "vertical_right"]
    if orientation not in preset_orientations:
        raise ValueError(
            f"Invalid 'orientation' value: '{orientation}'. Must be one of {preset_orientations}. "
            "For custom layouts, use 'horizontal' with explicit angle, dx, and dy settings."
        )

    if orientation != "horizontal" and angle != 0:
        raise ValueError(
            f"Cannot specify a custom angle ({angle}) when using orientation '{orientation}'. "
            "Use orientation 'horizontal' when specifying custom angles."
        )

    if orientation == "vertical_left":
        angle = 90
    if orientation == "vertical_right":
        angle = 270


    mark_color = color if color else self.colors.get("chart_label_color", "#000000")

    if geodata:
        if geodata_label is None:
            raise ValueError("When 'geodata=True', you must specify 'geodata_label'.")
        elif ":" not in geodata_label:
            raise ValueError("You must specify the geodata_label value encoding (ex: 'value:Q' for quantities or 'word:N' for nominal values).")
        else:
            label = geodata_label.split(":")[0]
            label_encoding = geodata_label.split(":")[1]

            values = self.data.copy()
            centroids = values.geometry.to_crs(epsg=3035).centroid.to_crs(epsg=4326)
            values["lon"] = centroids.x
            values["lat"] = centroids.y
            self.label_layer = alt.Chart(values[values[label].notna()]).mark_text(
                align = "center",
                dx = dx,
                dy = dy,
                lineBreak="\n",
                angle = angle,
                color = mark_color,
                fontSize = font_size,
                fontWeight = font_weight
            ).encode(
                longitude = "lon:Q",
                latitude = "lat:Q",
                text=f"{label}:{label_encoding}",
            )

            return self


    y_enc = self.encoding.get("y") if isinstance(self.encoding, dict) else getattr(self.encoding, "y", None)

    if y_enc is not None:
        shorthand = getattr(y_enc, "shorthand", str(y_enc))
        field_name = shorthand.split(":")[0] if ":" in shorthand else shorthand
        labels = f"{field_name}:Q"
    else:
        labels = None

    if values is not None and len(values) > 0:
        if not isinstance(values, (str, list, pd.Series)):
            raise TypeError("'values' must be a column name (str), a list, or a pandas Series.")

        if isinstance(values, str):
            if ":" in values:
                labels = values
            else:
                if values in self.data.columns and pd.api.types.is_numeric_dtype(self.data[values]):
                    labels = f"{values}:Q"
                else:
                    labels = f"{values}:N"

        elif isinstance(values, (list, pd.Series)):
            first_elem = values[0] if isinstance(values, list) else values.iloc[0]
            data_type = "Q" if isinstance(first_elem, (int, float, np.number)) else "N"

            chart_data = self.data.copy()
            chart_data["_custom_label"] = values
            self.data = chart_data
            labels = f"_custom_label:{data_type}"

    if not labels:
        raise ValueError(
            "Could not resolve labels: ensure self.encoding['y'] is set "
            "or provide a valid 'values' parameter."
        )

    text_encoding = alt.Text(labels)

    self.label_layer = alt.Chart(self.data).encode(
        x=self.encoding['x'],
        y=self.encoding['y'],
        text=text_encoding
    ).mark_text(
        align = "center",
        dx = dx,
        dy = dy,
        lineBreak="\n",
        angle = angle,
        color = mark_color,
        fontSize = font_size,
        fontWeight = font_weight
        )

    return self

add_title(title, subtitle=None, background_color=None, color=None, align='center', img_url=None, img_width=80, x_img_offset=-300, y_img_offset=0)

Adds a main title layer and an optional subtitle to the story.

Parameters:

Name Type Description Default
title str

Main title text to display.

required
subtitle str

Subtitle text to display below the title. Defaults to None.

None
background_color str

Background color for the title block. If omitted or None, falls back to the title_background_color defined in the template. Defaults to None.

None
color str

Text color for the title and subtitle. If omitted or None, falls back to the title defined in the template. Defaults to None.

None
align str

Horizontal text alignment. Must be one of 'left', 'center', or 'right'. Defaults to "center".

'center'
img_url str

URL or path to an optional image/logo to display alongside the title. Defaults to None.

None
img_width int | float

Width of the image in pixels. Defaults to 80.

80
x_img_offset int | float

Horizontal offset for the image position relative to the title block in pixels. Defaults to -300.

-300
y_img_offset int | float

Vertical offset for the image position relative to the title block in pixels. Defaults to 0.

0

Returns:

Name Type Description
Story

The current instance (self) to enable method chaining.

Raises:

Type Description
TypeError

If title is not a string.

ValueError

If align is not one of 'left', 'center', or 'right'.

ValueError

If img_width is less than or equal to 0 when img_url is provided.

Source code in pynarrative/src/pynarrative/story.py
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
def add_title(
        self,
        title,
        subtitle=None,
        background_color = None,
        color = None,
        align = "center",

        img_url = None,
        img_width = 80,
        x_img_offset = -300,
        y_img_offset = 0

        ):
    """Adds a main title layer and an optional subtitle to the story.

    Args:
        title (str): Main title text to display.
        subtitle (str, optional): Subtitle text to display below the title.
            Defaults to None.
        background_color (str, optional): Background color for the title block.
            If omitted or None, falls back to the `title_background_color` defined
            in the template. Defaults to None.
        color (str, optional): Text color for the title and subtitle.
            If omitted or None, falls back to the `title` defined
            in the template. Defaults to None.
        align (str, optional): Horizontal text alignment. Must be one of
            `'left'`, `'center'`, or `'right'`. Defaults to "center".
        img_url (str, optional): URL or path to an optional image/logo to display
            alongside the title. Defaults to None.
        img_width (int | float, optional): Width of the image in pixels.
            Defaults to 80.
        x_img_offset (int | float, optional): Horizontal offset for the image position
            relative to the title block in pixels. Defaults to -300.
        y_img_offset (int | float, optional): Vertical offset for the image position
            relative to the title block in pixels. Defaults to 0.

    Returns:
        Story: The current instance (`self`) to enable method chaining.

    Raises:
        TypeError: If `title` is not a string.
        ValueError: If `align` is not one of `'left'`, `'center'`, or `'right'`.
        ValueError: If `img_width` is less than or equal to 0 when `img_url` is provided.
    """

    #GESTIONE ERRORI
    if align not in ["left", "center", "right"]:
        raise ValueError(f"Invalid 'align' value: '{align}'. Must be one of: 'left', 'center', 'right'")

    if img_url is not None and img_width <= 0:
        raise ValueError("'img_width' must be a positive number")

    background_color = background_color if background_color else self.colors["title_background_color"]
    color = color if color else self.colors["title"]

    self.story_layers.append({
        'type': 'title', 
        'title': title, 
        'subtitle': subtitle,
        "align": align,
        "color": color,
        "background_color": background_color,
        "img_url": img_url,
        "img_width": img_width,
        "x_img_offset": x_img_offset,
        "y_img_offset": y_img_offset
    })
    return self

add_context(text, text_align='left', position='left', align='middle', width_multiplier=None, height_multiplier=None, font_weight='normal', font_style='normal', title='CONTEXT', title_dy=0, img_url=None, img_width=60, x_img_offset=-40, y_img_offset=0)

Adds a context box layer to the story.

Parameters:

Name Type Description Default
text str

The body text to be displayed within the context box.

required
text_align str

Horizontal alignment of the text inside the box. Must be one of 'left', 'center', or 'right'. Defaults to "left".

'left'
position str

Position of the context box relative to the chart. Must be one of 'left', 'right', 'top', or 'bottom'. Defaults to "left".

'left'
align str

Vertical alignment relative to the chart when position is 'left' or 'right'. Must be one of 'top', 'middle', or 'bottom'. Defaults to "middle".

'middle'
width_multiplier (int, float)

Box width multiplier (suggested values: from 0.1 to 4). If omitted or None, uses the value from the template (if used), falling back to the standard value (1.7)

None
height_multiplier (int, float)

Box height multiplier (suggested values: from 0.1 to 0.75). If omitted or None, uses the value from the template (if used), falling back to the standard value (0.33)

None
font_weight str

Font weight for the context text. Must be one of 'normal' or 'bold'. Defaults to "normal".

'normal'
font_style str

Font style for the context text. Must be one of 'normal' or 'italic'. Defaults to "normal".

'normal'
title str

Title header displayed above the context text. Defaults to "CONTEXT".

'CONTEXT'
title_dy int | float

Vertical offset in pixels for the title relative to its default position. Defaults to 0.

0
img_url str

File path or URL to an image/icon displayed alongside the context block. Defaults to None.

None
img_width int | float

Width of the context image in pixels. Defaults to 60.

60
x_img_offset int | float

Horizontal offset in pixels for the context image. Defaults to -40.

-40
y_img_offset int | float

Vertical offset in pixels for the context image. Defaults to 0.

0

Returns:

Name Type Description
Story

The current instance (self) to enable method chaining.

Raises:

Type Description
TypeError

If text or title is not a string.

TypeError

If width_multiplier is not int or float.

TypeError

If height_multiplier is not int or float.

ValueError

If text_align is not one of 'left', 'center', or 'right'.

ValueError

If position is not one of 'left', 'right', 'top', or 'bottom'.

ValueError

If align is specified with position set to 'top' or 'bottom'.

ValueError

If align is not one of 'top', 'middle', or 'bottom'.

ValueError

If font_weight is not one of 'normal' or 'bold'.

ValueError

If font_style is not one of 'normal' or 'italic'.

ValueError

If img_width is less than or equal to 0 when img_url is provided.

Source code in pynarrative/src/pynarrative/story.py
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
def add_context(
        self,
        text,
        text_align = "left",
        position='left',
        align = "middle",
        width_multiplier = None,
        height_multiplier = None,
        font_weight = "normal",
        font_style = "normal",
        title = "CONTEXT",
        title_dy = 0,
        img_url = None,
        img_width = 60,
        x_img_offset = -40,
        y_img_offset = 0
    ):
    """Adds a context box layer to the story.

    Args:
        text (str): The body text to be displayed within the context box.
        text_align (str, optional): Horizontal alignment of the text inside the box.
            Must be one of `'left'`, `'center'`, or `'right'`. Defaults to "left".
        position (str, optional): Position of the context box relative to the chart.
            Must be one of `'left'`, `'right'`, `'top'`, or `'bottom'`. Defaults to "left".
        align (str, optional): Vertical alignment relative to the chart when `position` is
            `'left'` or `'right'`. Must be one of `'top'`, `'middle'`, or `'bottom'`.
            Defaults to "middle".
        width_multiplier (int, float, optional): Box width multiplier (suggested values: from 0.1 to 4).
            If omitted or None, uses the value from the template (if used),
            falling back to the standard value (1.7)
        height_multiplier (int, float, optional): Box height multiplier (suggested values: from 0.1 to 0.75).
            If omitted or None, uses the value from the template (if used),
            falling back to the standard value (0.33)
        font_weight (str, optional): Font weight for the context text.
            Must be one of `'normal'` or `'bold'`. Defaults to "normal".
        font_style (str, optional): Font style for the context text.
            Must be one of `'normal'` or `'italic'`. Defaults to "normal".
        title (str, optional): Title header displayed above the context text.
            Defaults to "CONTEXT".
        title_dy (int | float, optional): Vertical offset in pixels for the title relative
            to its default position. Defaults to 0.
        img_url (str, optional): File path or URL to an image/icon displayed alongside
            the context block. Defaults to None.
        img_width (int | float, optional): Width of the context image in pixels.
            Defaults to 60.
        x_img_offset (int | float, optional): Horizontal offset in pixels for the context image.
            Defaults to -40.
        y_img_offset (int | float, optional): Vertical offset in pixels for the context image.
            Defaults to 0.

    Returns:
        Story: The current instance (`self`) to enable method chaining.

    Raises:
        TypeError: If `text` or `title` is not a string.
        TypeError: If `width_multiplier` is not int or float.
        TypeError: If `height_multiplier` is not int or float.
        ValueError: If `text_align` is not one of `'left'`, `'center'`, or `'right'`.
        ValueError: If `position` is not one of `'left'`, `'right'`, `'top'`, or `'bottom'`.
        ValueError: If `align` is specified with `position` set to `'top'` or `'bottom'`.
        ValueError: If `align` is not one of `'top'`, `'middle'`, or `'bottom'`.
        ValueError: If `font_weight` is not one of `'normal'` or `'bold'`.
        ValueError: If `font_style` is not one of `'normal'` or `'italic'`.
        ValueError: If `img_width` is less than or equal to 0 when `img_url` is provided.
    """

    #GESTIONE ERRORI

    if not isinstance(text, str):
        raise TypeError(f"'text' must be a string, got {type(text).__name__}")
    if title is not None and not isinstance(title, str):
        raise TypeError(f"'title' must be a string, got {type(title).__name__}")
    if text_align not in ["left", "center", "right"]:
        raise ValueError(f"Invalid 'text_align' value: '{text_align}'. Must be one of: 'left', 'center', 'right'")
    if position not in ['bottom', 'top', 'left', 'right']:
        raise ValueError(f"Invalid 'position' value: '{position}'. Must be one of: 'bottom', 'top', 'left', 'right'")
    if position in ["top", "bottom"] and align != "middle":
        raise ValueError("Vertical alignment ('align') cannot be customized when 'position' is 'top' or 'bottom'")
    if align not in ["top", "middle", "bottom"]:
        raise ValueError(f"Invalid 'align' value: '{align}'. Must be one of: 'top', 'middle', 'bottom'")
    if not isinstance(width_multiplier, (int, float)) and width_multiplier is not None:
        raise TypeError(f"'width_multiplier' must be int or float, got {type(width_multiplier).__name__}")
    if not isinstance(height_multiplier, (int, float)) and height_multiplier is not None:
        raise TypeError(f"'height_multiplier' must be int or float, got {type(height_multiplier).__name__}")
    if font_weight not in ["normal", "bold"]:
        raise ValueError(f"Invalid 'font_weight' value: '{font_weight}'. Must be one of: 'normal', 'bold'")
    if font_style not in ["normal", "italic"]:
        raise ValueError(f"Invalid 'font_style' value: '{font_style}'. Must be one of: 'normal', 'italic'")
    if img_url is not None and img_width <= 0:
        raise ValueError("'img_width' must be a positive number")

    font_size_px = self.em_to_px(self.font_sizes['context']) 

    self._calculate_width_multipliers(text, position, block_type_ = "context", width_multiplier = width_multiplier, height_multiplier = height_multiplier)

    box_width, box_height = self._context_box_size(position)
    wrapped_text = self._wrap_text_for_width(
        text,
        box_width=box_width,
        font_size_px=font_size_px,
    )

    self.story_layers.append({
        'type': 'context', 
        'text': wrapped_text,
        "text_align" : text_align,
        'position': position,
        'box_width': box_width,
        'box_height': box_height,
        "align" : align,
        "font_weight" : font_weight,
        "font_style" : font_style,
        "title" : title,
        "title_dy" : title_dy,
        "img_url": img_url,
        "img_width": img_width,
        "x_img_offset": x_img_offset,
        "y_img_offset": y_img_offset
    })

    return self

add_next_steps(steps, text_align='left', position='bottom', width_multiplier=None, height_multiplier=1, title='NEXT STEPS', title_dy=0, title_dx=0, mode='horizontal', img_url=None, img_width=45, x_img_offset=-50, y_img_offset=-22.5)

Adds next steps as a row or column of styled boxes with a shared title.

Parameters:

Name Type Description Default
steps list[str] | str

Sequence of step descriptions to display. A single string will be automatically converted into a one-element list.

required
text_align str

Horizontal alignment of the text inside each step box. Must be one of 'left', 'center', or 'right'. Defaults to "left".

'left'
position str

Position of the entire element relative to the chart. Must be one of 'bottom', 'top', 'left', or 'right'. Defaults to "bottom".

'bottom'
width_multiplier (int, float)

Box width multiplier (suggested values: from 0.1 to 4). If omitted or None, uses the value from the template (if used), falling back to the standard value (1.7)

None
height_multiplier (int, float)

Box height multiplier (suggested values: from 0.1 to 4). If omitted or None, uses the value from the template (if used), falling back to the standard value

1
title str

Main title header displayed above the next steps. Defaults to "NEXT STEPS".

'NEXT STEPS'
title_dy int | float

Vertical offset in pixels for the title. If omitted or 0, falls back to the nextstep_title_offset defined in the template. Defaults to 0.

0
title_dx int | float

Horizontal offset in pixels for the title. Defaults to 0.

0
mode str

Layout orientation for displaying the step boxes. Must be either 'horizontal' or 'vertical'. Defaults to "horizontal".

'horizontal'
img_url str | list[str]

URL/path or list of URLs/paths for icons associated with each step. If a list is provided, its length must match steps. Defaults to None.

None
img_width int | float

Width and height of the step icons in pixels. Defaults to 45.

45
x_img_offset int | float

Horizontal offset in pixels for icon positioning. Defaults to -50.

-50
y_img_offset int | float

Vertical offset in pixels for icon positioning. Defaults to -22.5.

-22.5

Returns:

Name Type Description
Story

The current instance (self) to enable method chaining.

Raises:

Type Description
TypeError

If steps is not a string or a list of strings.

TypeError

If img_url is provided but is neither a string nor a list.

TypeError

If width_multiplier is not int or float.

TypeError

If height_multiplier is not int or float.

ValueError

If steps is empty or contains more than 5 elements.

ValueError

If position is not one of 'bottom', 'top', 'left', or 'right'.

ValueError

If text_align is not one of 'left', 'center', or 'right'.

ValueError

If mode is not 'horizontal' or 'vertical'.

ValueError

If img_url as a list does not match the length of steps.

ValueError

If img_width is less than or equal to 0 when img_url is provided.

Source code in pynarrative/src/pynarrative/story.py
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
def add_next_steps(
    self,
    steps,
    text_align = "left",
    position='bottom',
    width_multiplier = None,
    height_multiplier = 1,
    title="NEXT STEPS",
    title_dy = 0,
    title_dx = 0,
    mode = "horizontal",
    img_url = None,
    img_width = 45,
    x_img_offset = -50,
    y_img_offset = -22.5
):

    """Adds next steps as a row or column of styled boxes with a shared title.

    Args:
        steps (list[str] | str): Sequence of step descriptions to display.
            A single string will be automatically converted into a one-element list.
        text_align (str, optional): Horizontal alignment of the text inside each step box.
            Must be one of `'left'`, `'center'`, or `'right'`. Defaults to "left".
        position (str, optional): Position of the entire element relative to the chart.
            Must be one of `'bottom'`, `'top'`, `'left'`, or `'right'`. Defaults to "bottom".
        width_multiplier (int, float, optional): Box width multiplier (suggested values: from 0.1 to 4).
            If omitted or None, uses the value from the template (if used),
            falling back to the standard value (1.7)
        height_multiplier (int, float, optional): Box height multiplier (suggested values: from 0.1 to 4).
            If omitted or None, uses the value from the template (if used),
            falling back to the standard value
        title (str, optional): Main title header displayed above the next steps.
            Defaults to "NEXT STEPS".
        title_dy (int | float, optional): Vertical offset in pixels for the title.
            If omitted or 0, falls back to the `nextstep_title_offset` defined in the template.
            Defaults to 0.
        title_dx (int | float, optional): Horizontal offset in pixels for the title.
            Defaults to 0.
        mode (str, optional): Layout orientation for displaying the step boxes.
            Must be either `'horizontal'` or `'vertical'`. Defaults to "horizontal".
        img_url (str | list[str], optional): URL/path or list of URLs/paths for icons
            associated with each step. If a list is provided, its length must match `steps`.
            Defaults to None.
        img_width (int | float, optional): Width and height of the step icons in pixels.
            Defaults to 45.
        x_img_offset (int | float, optional): Horizontal offset in pixels for icon positioning.
            Defaults to -50.
        y_img_offset (int | float, optional): Vertical offset in pixels for icon positioning.
            Defaults to -22.5.

    Returns:
        Story: The current instance (`self`) to enable method chaining.

    Raises:
        TypeError: If `steps` is not a string or a list of strings.
        TypeError: If `img_url` is provided but is neither a string nor a list.
        TypeError: If `width_multiplier` is not int or float.
        TypeError: If `height_multiplier` is not int or float.
        ValueError: If `steps` is empty or contains more than 5 elements.
        ValueError: If `position` is not one of `'bottom'`, `'top'`, `'left'`, or `'right'`.
        ValueError: If `text_align` is not one of `'left'`, `'center'`, or `'right'`.
        ValueError: If `mode` is not `'horizontal'` or `'vertical'`.
        ValueError: If `img_url` as a list does not match the length of `steps`.
        ValueError: If `img_width` is less than or equal to 0 when `img_url` is provided.
    """
    if steps is None:
        raise ValueError("The parameter 'steps' is required")
    if isinstance(steps, str):
        steps = [steps]
    if not isinstance(steps, list) or not all(isinstance(s, str) for s in steps):
        raise TypeError("'steps' must be a string or a list of strings")
    if len(steps) < 1:
        raise ValueError("Must provide at least one step")
    if len(steps) > 5:
        raise ValueError("Maximum number of steps is 5")
    if position not in ["bottom", "top", "left", "right"]:
        raise ValueError(f"Invalid 'position' value: '{position}'. Must be one of: 'bottom', 'top', 'left', 'right'")
    if not isinstance(width_multiplier, (int, float)) and width_multiplier is not None:
        raise TypeError(f"'width_multiplier' must be int or float, got {type(width_multiplier).__name__}")
    if not isinstance(height_multiplier, (int, float)) and height_multiplier is not None:
        raise TypeError(f"'height_multiplier' must be int or float, got {type(height_multiplier).__name__}")
    if text_align not in ["left", "center", "right"]:
        raise ValueError(f"Invalid 'text_align' value: '{text_align}'. Must be one of: 'left', 'center', 'right'")
    if mode not in ["horizontal", "vertical"]:
        raise ValueError(f"Invalid 'mode' value: '{mode}'. Must be one of: 'horizontal', 'vertical'")

    if img_url is not None:
        if not isinstance(img_url, (list, str)):
            raise TypeError("'img_url' must be a list or a string")
        elif len(img_url) != len(steps):
                raise ValueError(f"Length of 'img_url' list ({len(img_url)}) must match length of 'steps' ({len(steps)})")

    if img_width <= 0:
        raise ValueError("'img_width' must be a positive number")

    self._calculate_width_multipliers(steps, position, block_type_ = f"nextsteps_{mode}", width_multiplier = width_multiplier, height_multiplier = None)

    font_family = self.font
    font_size = self.em_to_px(self.font_sizes["nextstep"])
    title_color = self.colors['nextstep_title']

    gap = self.chart_style['nextstep_gap']
    text_top_padding_px = self.chart_style.get('nextstep_text_top_padding_px', max(4, font_size * 0.35))
    text_bottom_padding_px = self.chart_style.get('nextstep_text_bottom_padding_px', text_top_padding_px)
    text_side_padding_px = self.chart_style.get('nextstep_text_side_padding_px', max(6, font_size * 0.4))
    line_gap_px = self.chart_style.get('nextstep_line_gap_px', max(1.0, font_size * 0.15))
    line_height_px = font_size + line_gap_px
    layout_width = self._layout_width()
    min_box = self.chart_style['nextstep_min_box_width']
    max_box = self.chart_style['nextstep_max_box_width']

    # NUOVO
    if mode == "horizontal":
        box_width, _ = self._context_box_size(position) #Il calcolo della larghezza utilizza lo stesso metodo del box di contesto per questioni di uniformità grafica
        content_width = (box_width * len(steps)) + (gap * (len(steps) - 1))
        chart_width = max(layout_width, content_width)
    else: #vertical
        box_width, _ = self._context_box_size(position) #Il calcolo della larghezza utilizza lo stesso metodo del box di contesto per questioni di uniformità grafica
        chart_width = box_width

    box_color = self.colors['nextstep_box']
    box_border = self.colors['nextstep_border']
    text_color = self.colors['nextstep_text']


    wrapped_steps = []
    box_heights = []
    for step in steps:
        wrapped = self._wrap_text_for_width( #uniformazione alla gestione dei box di contesto
            str(step),
            box_width = box_width,
            font_size_px = font_size,
        )
        wrapped_steps.append(wrapped)
        box_heights.append(self._text_block_height(wrapped, font_size, block_type = "nextsteps"))  #uniformazione alla gestione dei box di contesto


    uniform_box_height = (max(box_heights) * height_multiplier) if box_heights else self.chart_style['nextstep_box_height']
    chart_body_height = uniform_box_height


    rows = []

    text_align = text_align if text_align is not None else self.chart_style['nextstep_text_align']
    if mode == "horizontal":
        for i, step_text in enumerate(wrapped_steps):
            x_left = i * (box_width + gap)
            if text_align == "left":
                x_mid = x_left +  (box_width / 10)
            elif text_align == "center":
                x_mid = x_left +  (box_width / 2)
            else: #right
                x_mid = x_left + box_width - 10
            rows.append({
                'x': x_left,
                'y': 0,
                'x2': x_left + box_width,
                'y2': uniform_box_height,
                'text': step_text,
                'x_mid': x_mid,
                'y_text': uniform_box_height - text_top_padding_px,
        })
    else: #vertical
        total_body_height = sum(box_heights) + (gap * (len(steps) - 1))
        chart_body_height = total_body_height

        current_top = total_body_height
        for i, step_text in enumerate(wrapped_steps):
            h_i = box_heights[i]
            y_bottom = current_top - h_i
            if text_align == "left":
                x_mid = text_side_padding_px
            elif text_align == "center":
                x_mid = box_width / 2
            else: #right
                x_mid = box_width - text_side_padding_px
            rows.append({
                'x': 0,
                'y': y_bottom,
                'x2': box_width,
                'y2': current_top,
                'text': step_text,
                'x_mid': x_mid,
                'y_text': current_top - text_top_padding_px,
            })
            current_top = y_bottom - gap

    df_rect = pd.DataFrame(rows)

    rect = alt.Chart(df_rect).mark_rect(
        color=box_color,
        opacity=self.chart_style['nextstep_opacity'],
        cornerRadius=self.chart_style['nextstep_corner_radius'],
        stroke=box_border,
        strokeWidth=self.chart_style['nextstep_border_width']
    ).encode(
        x=alt.X('x:Q', axis=None),
        y=alt.Y('y:Q', axis=None),
        x2='x2:Q',
        y2='y2:Q'
    ).properties(
        width = chart_width,
        height = chart_body_height + self.chart_style['nextstep_chart_height_padding']
    )

    text = alt.Chart(df_rect).mark_text(
        color = text_color,
        font = font_family,
        fontSize = font_size,
        align = text_align,
        baseline = "top",
        lineBreak = self.chart_style['text_line_break'],
        lineHeight = font_size * self.chart_style['context_line_height_ratio'], #uniformazione alla gestione dei box di contesto
    ).encode(
        text = "text:N",
        x = alt.X('x_mid:Q', axis=None),
        y = alt.Y('y_text:Q', axis=None),
    )

    if img_url:
        df_rect["img_url"] = img_url
        img_layer = alt.Chart(df_rect).mark_image(
            width = img_width,
            height = img_width,
            baseline = "bottom",
            xOffset = x_img_offset,
            yOffset = y_img_offset
        ).encode(
            x = alt.X('x:Q', axis=None),
            y = alt.Y('y:Q', axis=None),
            url = "img_url:N",
        )
        chart = alt.layer(rect, text, img_layer)
    else:
        chart = alt.layer(rect, text)

    title_dy = title_dy if title_dy else self.chart_style["nextstep_title_offset"]
    if title:
        dx = title_dx
        if img_url:
            dx = x_img_offset + title_dx
        chart = chart.properties(
            title=alt.TitleParams(
                text = [title],
                fontSize = self.em_to_px(self.font_sizes["nextstep"]) + 5,
                font = font_family,
                color = title_color,
                fontWeight = "bold",
                align = "center",
                baseline = "top",
                dx = dx,
                offset = title_dy,
            )
        )

    self.story_layers.append({
        'type': 'special_cta',
        'chart': chart,
        'position': position
    })

    return self

add_source(text, position='top', vertical=False, align='center')

Adds a source (or credits) attribution layer to the story.

Parameters:

Name Type Description Default
text str

The source text or attribution to display.

required
position str

Position of the source relative to the chart. Must be one of 'top', 'bottom', 'left', or 'right'. Defaults to "top".

'top'
vertical bool

Whether to rotate the text 90 degrees. Defaults to False.

False
align str

Horizontal text alignment when position is 'top' or 'bottom'. Must be one of 'left', 'center', or 'right'. Defaults to "center".

'center'

Returns:

Name Type Description
Story

The current instance (self) to enable method chaining.

Raises:

Type Description
TypeError

If text is not a string.

TypeError

If vertical is not a boolean.

ValueError

If position is not one of 'top', 'bottom', 'left', or 'right'.

ValueError

If align is specified (other than default 'center') when position is 'left' or 'right'.

ValueError

If align is not one of 'left', 'center', or 'right'.

Source code in pynarrative/src/pynarrative/story.py
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
def add_source(self, text, position='top', vertical=False, align = "center"):
    """Adds a source (or credits) attribution layer to the story.

    Args:
        text (str): The source text or attribution to display.
        position (str, optional): Position of the source relative to the chart.
            Must be one of `'top'`, `'bottom'`, `'left'`, or `'right'`.
            Defaults to "top".
        vertical (bool, optional): Whether to rotate the text 90 degrees.
            Defaults to False.
        align (str, optional): Horizontal text alignment when `position` is `'top'` or
            `'bottom'`. Must be one of `'left'`, `'center'`, or `'right'`.
            Defaults to "center".

    Returns:
        Story: The current instance (`self`) to enable method chaining.

    Raises:
        TypeError: If `text` is not a string.
        TypeError: If `vertical` is not a boolean.
        ValueError: If `position` is not one of `'top'`, `'bottom'`, `'left'`, or `'right'`.
        ValueError: If `align` is specified (other than default `'center'`) when `position`
            is `'left'` or `'right'`.
        ValueError: If `align` is not one of `'left'`, `'center'`, or `'right'`.
    """

    #GESTIONE ERRORI
    if not isinstance(text, str):
        raise TypeError(f"'text' must be a string, got {type(text).__name__}")
    if not isinstance(vertical, bool):
        raise TypeError(f"'vertical' must be a boolean, got {type(vertical).__name__}")
    if position not in ['bottom', 'top', 'left', 'right']:
        raise ValueError(f"Invalid 'position' value: '{position}'. Must be one of: 'top', 'bottom', 'left', 'right'")
    if position in ["left", "right"] and align != "center":
        raise ValueError("Horizontal alignment ('align') cannot be customized when 'position' is 'left' or 'right'")
    if align not in ["left", "center", "right"]:
        raise ValueError(f"Invalid 'align' value: '{align}'. Must be one of: 'left', 'center', 'right'")

    self.story_layers.append({
        'type': 'source', 
        'text': text, 
        'position': position, 
        'vertical': vertical,
        "align": align
    })

    return self

add_annotation(x=None, y=None, text=None, title=None, dx=0, dy=0, background_color=None, text_color=None, show_subject=False, line_point_color=None, img_url=None, img_width=60, x_img_offset=0, y_img_offset=0)

Adds an annotation layer anchored to quantitative (x, y) coordinates.

Parameters:

Name Type Description Default
x int | float

Subject X-coordinate in chart data space.

None
y int | float

Subject Y-coordinate in chart data space.

None
text str

Main annotation body text. Required if img_url is None. Defaults to None.

None
title str

Title header prepended above the text. Defaults to None.

None
dx int | float

Horizontal pixel offset from the subject point to the annotation box corner. Defaults to 0.

0
dy int | float

Vertical pixel offset from the subject point to the annotation box corner. Defaults to 0.

0
background_color str

Background color for the annotation box. If omitted or None, falls back to the annotation_fill defined in the template or the context box fill setting. Defaults to None.

None
text_color str

Text color for the annotation label. If omitted or None, falls back to the annotation_text defined in the template or context color settings. Defaults to None.

None
show_subject bool

If True, draws a point marker at (x, y) and a connecting line to the annotation box, that slightly shifts. Defaults to False.

False
line_point_color str

Custom color for the connecting line and subject point marker. If omitted or None, falls back to text_color. Defaults to None.

None
img_url str

URL or file path for an image/icon inside the annotation. Required if text is None. Defaults to None.

None
img_width int | float

Width and height of the annotation image in pixels. Defaults to 60.

60
x_img_offset int | float

Horizontal pixel offset for the image position. Defaults to 0.

0
y_img_offset int | float

Vertical pixel offset for the image position. Defaults to 0.

0
**kwargs

Unused keyword arguments.

required

Returns:

Name Type Description
Story

The current instance (self) to enable method chaining.

Raises:

Type Description
TypeError

If show_subject is not a boolean.

ValueError

If both x and y are not provided, or are non-numeric.

ValueError

If both text and img_url are missing.

ValueError

If either the X or Y chart axes are not quantitative ('Q').

ValueError

If axis numeric ranges cannot be resolved.

ValueError

If img_width is less than or equal to 0 when img_url is provided.

Source code in pynarrative/src/pynarrative/story.py
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
def add_annotation(
    self,
    x=None,
    y=None,
    text=None,
    title=None,
    dx=0,
    dy=0,
    background_color = None,
    text_color = None,
    show_subject = False,
    line_point_color = None,
    img_url = None,
    img_width = 60,
    x_img_offset = 0,
    y_img_offset = 0,
):
    """Adds an annotation layer anchored to quantitative (x, y) coordinates.

    Args:
        x (int | float): Subject X-coordinate in chart data space.
        y (int | float): Subject Y-coordinate in chart data space.
        text (str, optional): Main annotation body text. Required if `img_url` is None.
            Defaults to None.
        title (str, optional): Title header prepended above the text. Defaults to None.
        dx (int | float, optional): Horizontal pixel offset from the subject point
            to the annotation box corner. Defaults to 0.
        dy (int | float, optional): Vertical pixel offset from the subject point
            to the annotation box corner. Defaults to 0.
        background_color (str, optional): Background color for the annotation box.
            If omitted or None, falls back to the `annotation_fill` defined in the
            template or the context box fill setting. Defaults to None.
        text_color (str, optional): Text color for the annotation label.
            If omitted or None, falls back to the `annotation_text` defined in the
            template or context color settings. Defaults to None.
        show_subject (bool, optional): If True, draws a point marker at (x, y) and
            a connecting line to the annotation box, that slightly shifts. Defaults to False.
        line_point_color (str, optional): Custom color for the connecting line and
            subject point marker. If omitted or None, falls back to `text_color`.
            Defaults to None.
        img_url (str, optional): URL or file path for an image/icon inside the annotation.
            Required if `text` is None. Defaults to None.
        img_width (int | float, optional): Width and height of the annotation image in pixels.
            Defaults to 60.
        x_img_offset (int | float, optional): Horizontal pixel offset for the image position.
            Defaults to 0.
        y_img_offset (int | float, optional): Vertical pixel offset for the image position.
            Defaults to 0.
        **kwargs: Unused keyword arguments.

    Returns:
        Story: The current instance (`self`) to enable method chaining.

    Raises:
        TypeError: If `show_subject` is not a boolean.
        ValueError: If both `x` and `y` are not provided, or are non-numeric.
        ValueError: If both `text` and `img_url` are missing.
        ValueError: If either the X or Y chart axes are not quantitative ('Q').
        ValueError: If axis numeric ranges cannot be resolved.
        ValueError: If `img_width` is less than or equal to 0 when `img_url` is provided.
    """

    if x is None or y is None:
        raise ValueError("add_annotation requires x and y coordinates.")
    if text is None and img_url is None:
        raise ValueError("add_annotation requires at least 'text' or 'img_url'.")
    if not isinstance(show_subject, bool):
        raise TypeError(f"'show_subject' must be a boolean, got {type(show_subject).__name__}")

    x_field, x_type = self._get_encoding_field_and_type('x')
    y_field, y_type = self._get_encoding_field_and_type('y')
    if x_type != 'Q' or y_type != 'Q':
        raise ValueError("add_annotation supports only quantitative ('Q') x/y axes.")

    try:
        x = float(x)
        y = float(y)
    except (TypeError, ValueError):
        raise ValueError("add_annotation requires numeric x and y values.")

    x_min, x_max = self._get_axis_range(x_field)
    y_min, y_max = self._get_axis_range(y_field)
    if x_min is None or x_max is None or y_min is None or y_max is None:
        raise ValueError("add_annotation requires valid numeric x/y ranges.")

    x_range = max(x_max - x_min, 1e-9)
    y_range = max(y_max - y_min, 1e-9)
    x_per_px = x_range / self.chart.width
    y_per_px = y_range / self.chart.height

    label_size = self.chart_style.get('annotation_label_size', self.chart_style.get('callout_label_size', 12))
    if label_size <= 6:
        label_size = self.em_to_px(label_size)
    # Derive line-height from effective font size + explicit inter-line padding.
    line_gap_px = self.chart_style.get('annotation_line_gap_px', max(1.5, label_size * 0.15))
    line_height_px = label_size + line_gap_px

    note_text = f"{title}\n{text}" if title else str(text)

    max_width_px = self.chart.width * self.chart_style.get(
        'annotation_max_width_ratio',
        self.chart_style.get('callout_max_width_ratio', 0.45)
    )
    approx_char_width = max(
        label_size * self.chart_style.get(
            'annotation_char_width_ratio',
            self.chart_style.get('callout_label_char_width_ratio', 0.5)
        ),
        self.chart_style.get(
            'annotation_min_char_width',
            self.chart_style.get('callout_label_min_char_width', 6)
        )
    )
    max_chars = max(int(max_width_px / max(approx_char_width, 1e-9)), self.chart_style['text_wrap_min_chars'])

    lines = text.split("\n")
    wrapped_text = []

    for line in lines:
        if not line.strip():
            wrapped_text.append("")
        else:
            wrapped_lines = textwrap.wrap(
                line, 
                width = max_chars - 15, #piccola correzione
                break_long_words=False, 
                break_on_hyphens=False,
                replace_whitespace=False,
            )

        wrapped_text.extend(wrapped_lines if wrapped_lines else [lines])


    max_line_chars = max(max(len(wrapped_line), 1) for wrapped_line in wrapped_text)
    box_width_px = min(max_line_chars * approx_char_width, max_width_px)
    box_height_px = max(len(wrapped_text), 1) * line_height_px
    box_padding_px = self.chart_style.get(
        'annotation_padding',
        self.chart_style.get('padding', 6)
    )
    text_left_padding_px = self.chart_style.get('annotation_text_left_padding_px', 2.0)
    text_top_padding_px = self.chart_style.get('annotation_text_top_padding_px', 2.0)

    box_w_data = (box_width_px + 5 * (box_padding_px + text_left_padding_px)) * x_per_px
    box_h_data = (box_height_px + (2 * box_padding_px)) * y_per_px
    text_padding_h = ((box_padding_px + text_top_padding_px) * y_per_px)

    # Box and text share the same top-left anchor by design.
    box_x_left = x + (dx * x_per_px)
    box_y_top = y - (dy * y_per_px)
    box_x_right = box_x_left + box_w_data
    box_y_bottom = box_y_top - box_h_data*line_gap_px 
    text_x = box_x_left + ((box_padding_px + text_left_padding_px) * x_per_px)
    text_y = box_y_top - text_padding_h

    def connector_start_offset(dx_px, dy_px, w_px, h_px):
        if abs(dx_px) >= abs(dy_px):
            x_off = dx_px if dx_px > 0 else dx_px + w_px
            y_off = dy_px + (h_px / 2)
            return (x_off, y_off)
        if dy_px < 0:
            return (dx_px + (w_px / 2), dy_px + h_px)
        if dy_px > 0:
            return (dx_px + (w_px / 2), dy_px)
        return (dx_px + (w_px / 2), dy_px + (h_px / 2))

    conn_dx, conn_dy = connector_start_offset(dx, dy, box_width_px, box_height_px)
    connector_x = x + (conn_dx * x_per_px)
    connector_y = y - (conn_dy * y_per_px)

    tip_offset_ratio = self.chart_style.get(
        'annotation_tip_offset_ratio',
        self.chart_style.get('callout_peak_offset_ratio', 0.05)
    )
    vx = x - connector_x
    vy = y - connector_y
    tip_x = x - (vx * tip_offset_ratio)
    tip_y = y - (vy * tip_offset_ratio)

    annotation_data = pd.DataFrame({
        'subject_x': [x],
        'subject_y': [y],
        'connector_x': [connector_x],
        'connector_y': [connector_y],
        'tip_x': [tip_x],
        'tip_y': [tip_y],
        'box_x1': [box_x_left],
        'box_x2': [box_x_right],
        'box_y1': [box_y_bottom],
        'box_y2': [box_y_top],
        'text_x': [text_x],
        'text_y': [text_y],
    })

    text_color = text_color if text_color else self.colors.get('annotation_text', self.colors.get('callout_text', self.colors['context']))
    box_fill = background_color if background_color else self.colors.get('annotation_fill', self.context_box['fill'])
    box_stroke = self.colors.get('annotation_stroke', self.context_box['stroke'])
    line_color = line_point_color if line_point_color else text_color
    point_color = line_color
    point_size = self.chart_style.get('annotation_point_size', self.chart_style.get('callout_point_size', 60))
    line_width = self.chart_style.get('annotation_line_width', self.chart_style.get('callout_arrow_line_width', 2))
    box_opacity = self.chart_style.get('annotation_box_opacity', self.chart_style.get('callout_box_opacity', 1.0))
    box_border_width = self.chart_style.get('annotation_box_border_width', self.chart_style.get('callout_box_border_width', 1))

    layers = []
    if show_subject == True:
        dx = -50
        dy = -50
        layers.append(
            alt.Chart(annotation_data).mark_point(color=point_color, size=point_size).encode(
                x=alt.X('subject_x:Q'),
                y=alt.Y('subject_y:Q'),
            ).properties(width=self.chart.width, height=self.chart.height)
        )

        layers.append(
            alt.Chart(annotation_data).mark_rule(stroke=line_color, strokeWidth=line_width).encode(
                x=alt.X('connector_x:Q'),
                y=alt.Y('connector_y:Q'),
                x2='tip_x',
                y2='tip_y'
            ).properties(width=self.chart.width, height=self.chart.height)
        )

    if text is not None:
        layers.append(
            alt.Chart(annotation_data).mark_rect(
                color=box_fill,
                opacity=box_opacity,
                cornerRadius=self.context_box['corner_radius'],
                stroke=box_stroke,
                strokeWidth=box_border_width,
                dx = dx,
                dy = dy,

            ).encode(
                x=alt.X('box_x1:Q'),
                x2='box_x2',
                y=alt.Y('box_y1:Q'),
                y2='box_y2'
            ).properties(width=self.chart.width, height=self.chart.height)
        )


        # SEI QUI
        layers.append(
            alt.Chart(annotation_data).mark_text(
                text = wrapped_text,
                align='left',
                baseline='top',
                font=self.font,
                fontSize=label_size,
                lineHeight=line_height_px,
                lineBreak=self.chart_style['text_line_break'],
                color=text_color,
            ).encode(
                x=alt.X('text_x:Q'),
                y=alt.Y('text_y:Q')
            ).properties(width=self.chart.width, height=self.chart.height)
        )

    if img_url:
        y_img_offset = x_img_offset if x_img_offset != 0 else (-50 if text is not None else 0)
        y_img_offset = y_img_offset if y_img_offset != 0 else (50 if text is not None else 0)
        layers.append(
            alt.Chart(annotation_data)
                .mark_image(
                    width = img_width,
                    height = img_width,
                    baseline = "middle",
                    xOffset = x_img_offset,
                    yOffset = y_img_offset
                ).encode(
                    x=alt.X('text_x:Q'),
                    y=alt.Y('text_y:Q'),
                    url = alt.value(img_url)
                )
        )

    self.story_layers.append({'type': 'annotation', 'chart': alt.layer(*layers)})
    return self

add_line(value, orientation='horizontal', math=None, color='', label_text='', label_dx=0, label_dy=0, label_font_size='', label_font_weight='normal', label_font_style='normal')

Adds a reference line (horizontal or vertical) with an optional label to the story.

Parameters:

Name Type Description Default
value int | float | list | Series

Position of the reference line. If math is specified, must be a list or pandas.Series of numbers. Otherwise, must be a single numeric value (int or float).

required
orientation str

Line orientation relative to axes. Must be either 'horizontal' or 'vertical'. Defaults to "horizontal".

'horizontal'
math str

Automatic aggregation function to calculate value. Must be one of 'mean', 'median', 'min', or 'max'. Defaults to None.

None
color str

Line and label text color. If omitted or None, falls back to the reference_line_color defined in the template layout settings. Defaults to None.

''
label_text str

Custom text displayed alongside the line. If None, defaults to the formatted calculated value (e.g., "Mean 12.5" or "12.5"). Set to "" (empty string) to hide the label entirely. Defaults to None.

''
label_dx int | float

Horizontal pixel offset for the label position. Defaults to 0.

0
label_dy int | float

Vertical pixel offset for the label position. Defaults to 0.

0
label_font_size int | float

Font size of the label text in pixels. If omitted or None, falls back to the line_label size defined in the template. Defaults to None.

''
label_font_weight str

Font weight of the label text. Must be one of 'normal' or 'bold'. Defaults to "normal".

'normal'
label_font_style str

Font style of the label text. Must be one of 'normal' or 'italic'. Defaults to "normal".

'normal'

Returns:

Name Type Description
Story

The current instance (self) to enable method chaining.

Raises:

Type Description
TypeError

If value type does not match the requirements of math.

TypeError

If label_font_size is provided but is not an integer or float.

ValueError

If orientation is not 'horizontal' or 'vertical'.

ValueError

If math is not one of 'mean', 'median', 'min', 'max', or None.

ValueError

If label_font_weight is not 'normal' or 'bold'.

ValueError

If label_font_style is not 'normal' or 'italic'.

Source code in pynarrative/src/pynarrative/story.py
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
def add_line(
        self,
        value,
        orientation='horizontal',
        math = None,
        color = "",
        label_text = "",
        label_dx = 0,
        label_dy = 0,
        label_font_size = "",
        label_font_weight = "normal",
        label_font_style = "normal",
        ):
    """Adds a reference line (horizontal or vertical) with an optional label to the story.

    Args:
        value (int | float | list | pandas.Series): Position of the reference line.
            If `math` is specified, must be a list or `pandas.Series` of numbers.
            Otherwise, must be a single numeric value (`int` or `float`).
        orientation (str, optional): Line orientation relative to axes. Must be either
            `'horizontal'` or `'vertical'`. Defaults to "horizontal".
        math (str, optional): Automatic aggregation function to calculate `value`.
            Must be one of `'mean'`, `'median'`, `'min'`, or `'max'`. Defaults to None.
        color (str, optional): Line and label text color. If omitted or None, falls back to
            the `reference_line_color` defined in the template layout settings.
            Defaults to None.
        label_text (str, optional): Custom text displayed alongside the line. If None,
            defaults to the formatted calculated value (e.g., "Mean 12.5" or "12.5").
            Set to `""` (empty string) to hide the label entirely. Defaults to None.
        label_dx (int | float, optional): Horizontal pixel offset for the label position.
            Defaults to 0.
        label_dy (int | float, optional): Vertical pixel offset for the label position.
            Defaults to 0.
        label_font_size (int | float, optional): Font size of the label text in pixels.
            If omitted or None, falls back to the `line_label` size defined in the template.
            Defaults to None.
        label_font_weight (str, optional): Font weight of the label text.
            Must be one of `'normal'` or `'bold'`. Defaults to "normal".
        label_font_style (str, optional): Font style of the label text.
            Must be one of `'normal'` or `'italic'`. Defaults to "normal".

    Returns:
        Story: The current instance (`self`) to enable method chaining.

    Raises:
        TypeError: If `value` type does not match the requirements of `math`.
        TypeError: If `label_font_size` is provided but is not an integer or float.
        ValueError: If `orientation` is not `'horizontal'` or `'vertical'`.
        ValueError: If `math` is not one of `'mean'`, `'median'`, `'min'`, `'max'`, or None.
        ValueError: If `label_font_weight` is not `'normal'` or `'bold'`.
        ValueError: If `label_font_style` is not `'normal'` or `'italic'`.
    """

    if orientation not in ['horizontal', 'vertical']:
        raise ValueError(f"Invalid 'orientation' value: '{orientation}'. Must be either 'horizontal' or 'vertical'")
    if label_font_weight not in ["normal", "bold"]:
        raise ValueError("label_font_weight must be one of: 'normal', 'bold'")
    if label_font_style not in ["normal", "italic"]:
        raise ValueError("label_font_style must be one of: 'normal', 'italic'")

    def axis_type(axis):
        if hasattr(self.chart, 'encoding') and hasattr(self.chart.encoding, axis):
            shorthand = getattr(self.chart.encoding, axis).shorthand
            if shorthand and ':' in shorthand:
                return shorthand.split(':')[-1]
        return 'Q'


    #Math
    if math not in ["mean", "median", "min", "max", None]:
        raise ValueError(f"Invalid 'math' value: '{math}'. Must be one of: 'mean', 'median', 'min', 'max', or None")
    if math is not None:
        if not isinstance(value, (list, pd.Series)):
            raise ValueError("When 'math' is specified, 'value' must be a list or a pandas Series. Try something as: 'value = dataframe['value_column']' or 'value = dataframe['value_column'].tolist()")

        if len(value) == 0:
            raise ValueError("Cannot perform 'math' calculation on an empty sequence.")

        clean_values = [v for v in value if pd.notna(v)]

        if math == "mean": #Mean
            line_value = round(sum(clean_values) / len(clean_values), 2)
            print(f"Mean = {line_value}")

        elif math == "median": #Median
            line_value = round(float(np.median(clean_values)), 2)
            print(f"Median = {line_value}")

        elif math == "min": #Min
            line_value = round(float(min(clean_values)), 2)
            print(f"Min = {line_value}")

        elif math == "max": #Max
            line_value = round(float(max(clean_values)), 2)
            print(f"Max = {line_value}")
    else:
        if value is None or not isinstance(value, (int, float, np.number)):
            raise ValueError("When 'math' is None, 'value' must be a numeric integer or float.")
        line_value = float(value)
        print(f"Single value = {line_value}")


    # Crea un DataFrame con un singolo valore
    if orientation == 'horizontal':
        y_type = axis_type('y')
        data = pd.DataFrame({'y': [line_value]})
        encoding = {'y': f'y:{y_type}'}
    else:  # vertical
        x_type = axis_type('x')
        data = pd.DataFrame({'x': [line_value]})
        encoding = {'x': f'x:{x_type}'}


    # Crea la linea
    line_color = color if color else self.chart_style["reference_line_color"]

    line = alt.Chart(data).mark_rule(
        color = line_color,
        strokeWidth=self.chart_style['reference_line_width'],
        strokeDash=self.chart_style['reference_line_dash']
    ).encode(
        **encoding,
    )

    data_label = data.copy()
    if label_text is None:
        if math == "mean":
            label_text = f"Mean {str(line_value)}"
        elif math == "median":
            label_text = f"Median {str(line_value)}"
        else:
            label_text = str(line_value) #etichetta di default col valore numerico
    else:
        label_text = str(label_text)

    if orientation == 'horizontal':
        data_label['label_x'] = 0 
        y_offset = self.chart_width + 10
        label_encoding = {**encoding, 'x': alt.value(y_offset)}
    else:  # vertical
        data_label['label_y'] = 0
        x_offset = -10
        label_encoding = {**encoding, 'y': alt.value(x_offset)}

    if label_font_size == "":
        font_size = self.em_to_px(self.font_sizes["line_label"])

    else:
        if not isinstance(label_font_size, (int, float)):
            raise TypeError("'label_font_size' must be an integer or float.")
        #else
        font_size = label_font_size

    label = alt.Chart(data_label).mark_text(
        color = line_color,
        fontSize = font_size,
        align = "left",
        dx = label_dx,
        dy = label_dy,
        fontWeight = label_font_weight,
        fontStyle = label_font_style,
    ).encode(
        **label_encoding,
        text = alt.value(label_text),
    )

    line = line + label

    # Aggiunge la linea ai layer della storia
    self.story_layers.append({
        'type': 'line',
        'chart': line
    })

    return self

render(save=False, filename=None, ppi=False)

Renders all elements, overlays, and structural layers of the story into a single compound chart.

Optionally exports the final visualization to a file in the export/ directory in SVG, PNG, or PDF format.

Parameters:

Name Type Description Default
save str | bool

Target output format for saving the chart. Must be one of 'svg', 'png', 'pdf', or False to skip saving. If an invalid string format is provided, a warning is raised and it falls back to 'svg'. Defaults to False.

False
filename str | bool

Custom filename for export (without extension). If False or empty, falls back to the story's title text (formatted as snake_case) or 'no_name_chart' if no title exists. Defaults to None.

None
ppi int | float

Pixels per inch resolution setting for PNG exports. Higher values increase render accuracy and image size. Defaults to 150.

False

Returns:

Type Description

alt.TopLevelSpec: The complete, stylized Altair chart specification.

Raises:

Type Description
TypeError

If ppi is provided and is not a positive integer or float.

TypeError

If filename is provided (and not False) but is not a string.

Source code in pynarrative/src/pynarrative/story.py
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
def render(self, save = False, filename = None, ppi = False):
    """Renders all elements, overlays, and structural layers of the story into a single compound chart.

    Optionally exports the final visualization to a file in the `export/` directory in SVG,
    PNG, or PDF format.

    Args:
        save (str | bool, optional): Target output format for saving the chart.
            Must be one of `'svg'`, `'png'`, `'pdf'`, or `False` to skip saving.
            If an invalid string format is provided, a warning is raised and it falls
            back to `'svg'`. Defaults to False.
        filename (str | bool, optional): Custom filename for export (without extension).
            If `False` or empty, falls back to the story's title text (formatted as snake_case)
            or `'no_name_chart'` if no title exists. Defaults to None.
        ppi (int | float, optional): Pixels per inch resolution setting for PNG exports.
            Higher values increase render accuracy and image size. Defaults to 150.

    Returns:
        alt.TopLevelSpec: The complete, stylized Altair chart specification.

    Raises:
        TypeError: If `ppi` is provided and is not a positive integer or float.
        TypeError: If `filename` is provided (and not False) but is not a string.
    """

    if save not in ["svg", "png", "pdf", False]:
        warnings.warn(
            f"Unsupported export format '{save}'. Supported formats are 'svg', 'png', 'pdf'. "
            "Falling back to 'svg'.",
            UserWarning,
        )
        save = "svg"

    if filename is not None and not isinstance(filename, str):
        raise TypeError(f"'filename' must be a string or False, got {type(filename).__name__}")

    if save == "png":
        if ppi is False or ppi is None: ppi = 150
        if not isinstance(ppi, (int, float)) or ppi <= 0:
            raise TypeError("'ppi' must be a positive number.")
        if ppi > 500:
            warnings.warn(
                f"A high ppi value ({ppi}) was provided. This may significantly slow down chart rendering.",
                UserWarning,
            )

    def _vstack(charts, spacing = 1):
        if not charts:
            return None
        if len(charts) == 1:
            return charts[0]
        return alt.vconcat(*charts, spacing = spacing)

    # Let's start with the basic graph
    main_chart = self._apply_trendline_end_labels(self.chart)

    if hasattr(self, "label_layer"): #NUOVO (gestione labels)
        main_chart = alt.layer(main_chart, self.label_layer)

    # Create separate lists to place special graphics
    title_charts = []
    top_other_charts = []
    top_context_charts = []
    top_source_charts = []
    bottom_charts = []
    left_charts = []
    right_charts = []
    left_outer_charts = []
    right_outer_charts = []
    overlay_charts = []

    # Organise the layers according to their position
    for layer in self.story_layers:
        if layer['type'] == 'special_cta':
            # We take the position from the layer
            if layer.get('position') == 'top':
                top_other_charts.append(layer['chart'])
            elif layer.get('position') == 'bottom':
                bottom_charts.append(layer['chart'])
            elif layer.get('position') == 'left':
                left_charts.append(layer['chart'])
            elif layer.get('position') == 'right':
                right_charts.append(layer['chart'])
        elif layer['type'] == 'context' and layer.get('position') in ['left', 'right', 'top', 'bottom']:
            context_chart = self.create_context_box_chart(layer)
            if layer.get('position') == 'top':
                top_context_charts.append(context_chart)
            elif layer.get('position') == 'bottom':
                bottom_charts.append(context_chart)
            elif layer.get('position') == 'left':
                left_charts.append(context_chart)
            elif layer.get('position') == 'right':
                right_charts.append(context_chart)
        elif layer['type'] == 'title':
            title_charts.append(self.create_title_layer(layer))

        elif layer['type'] == 'source' and layer.get('position') in ['left', 'right']:
            source_side = self.create_source_side_chart(layer)
            if layer.get('position') == 'left':
                left_outer_charts.append(source_side)
            else:
                right_outer_charts.append(source_side)
        elif layer['type'] == 'source' and layer.get('position') in ["top", "bottom"]:
            overlay_charts.append(self.create_source_anchored_layer(layer)) #MODIFICATO


        elif layer['type'] in ['context', 'cta', 'source']:
            overlay_charts.append(self.create_text_layer(layer))
        elif layer['type'] in ['shape', 'shape_label', 'annotation']:
            overlay_charts.append(layer['chart'])
        elif layer['type'] == 'line':
            overlay_charts.append(layer['chart'])

    #Overlaying management in add_highlight() method
    if hasattr(self, '_overlays') and self._overlays:
        overlay_charts.extend(self._overlays)

    # Overlaying the layers on the main graph
    for overlay in overlay_charts:
        main_chart += overlay


    # Build the final layout
    left_stack = _vstack(left_charts)
    left_source_stack = _vstack(left_outer_charts)
    if left_source_stack is not None and left_stack is not None:
        # Keep left source before left context, but aligned on the same row as the chart.
        main_chart = alt.hconcat(left_source_stack, left_stack, main_chart)
    elif left_source_stack is not None:
        main_chart = alt.hconcat(left_source_stack, main_chart)
    elif left_stack is not None:
        main_chart = alt.hconcat(left_stack, main_chart)

    right_stack = _vstack(right_charts)
    right_source_stack = _vstack(right_outer_charts)
    if right_source_stack is not None and right_stack is not None:
        # Keep right source after right context.
        main_chart = alt.hconcat(main_chart, right_stack, right_source_stack)
    elif right_source_stack is not None:
        main_chart = alt.hconcat(main_chart, right_source_stack)
    elif right_stack is not None:
        main_chart = alt.hconcat(main_chart, right_stack)
    top_charts = top_context_charts + top_source_charts + top_other_charts
    if top_charts:
        main_chart = alt.vconcat(*top_charts, main_chart, center = True) #MODIFICATO
    if bottom_charts:
        main_chart = alt.vconcat(main_chart, alt.hconcat(*bottom_charts, center = True), center = True) #MODIFICATO
    if title_charts:
        main_chart = alt.vconcat(*title_charts, main_chart, center = True)

    # Apply configurations
    if 'view' in self.config:
        main_chart = main_chart.configure_view(**self.config['view'])

    #NUOVO
    mark_info = getattr(self.chart, "mark", None) #tipo di grafico
    mark_type = mark_info.type if hasattr(mark_info, "type") else mark_info
    if mark_type == "point":
        point_size = 150
        point_color = self.chart_style["series_colors"][0]
    elif mark_type == "line":
        point_size = 80
        point_color = self.chart_style["series_colors"][1]
    else:
        point_color = "#000000"
        point_size = 75

    # Apply default chart styling
    main_chart = main_chart.configure_view(
        strokeWidth=self.chart_style['default_view_stroke_width']
    ).configure_axis(
        grid=self.chart_style['axis_grid'],
        labelFont=self.chart_style['font'],
        labelColor=self.chart_style['label_color'],
        labelFontSize=self.chart_style['axis_label_size'],
        titleFont=self.chart_style['font'],
        titleColor=self.chart_style['title_color'],
        titleFontSize=self.chart_style['axis_title_size'],
        tickColor=self.chart_style['axis_tick_color'],
        domainColor=self.chart_style['axis_domain_color']
    ).configure_legend(
        labelFont=self.chart_style['font'],
        labelColor=self.chart_style['label_color'],
        labelFontSize=self.chart_style['legend_label_size'],
        titleFont=self.chart_style['font'],
        titleColor=self.chart_style['title_color'],
        titleFontSize=self.chart_style['legend_title_size']
    ).configure_title(
        font=self.chart_style['font'],
        color=self.chart_style['title_color'],
        fontSize=self.em_to_px(self.font_sizes['title'])
    ).configure_mark(
        strokeWidth=self.chart_style['line_stroke_width']
    ).configure_bar(
        color=self.chart_style['bar_fill_color']
    ).configure_line( #NUOVO: prende il primo colore della serie
        color=self.chart_style["series_colors"][0]
    ).configure_point(
        size = point_size,
        filled = True,
        color = point_color
    ).configure_range(
        category = self.chart_style["series_colors"]
    )

    if self._is_bar_chart():
        wrap_threshold = self.chart_style['bar_label_wrap_threshold']
        main_chart = main_chart.configure_axisX(
            labelAngle=0,
            labelLineHeight=self.chart_style['bar_label_line_height'],
            labelExpr=(
                f"length(datum.label) > {wrap_threshold} "
                "? replace(datum.label, /\\s+/, '\\n') : datum.label"
            )
        )

    #SAVE
    if not filename: #default
        filename = "no_name_chart"
        for layer in self.story_layers:
            if layer["type"] == "title":
                filename = layer["title"]
        filename = filename.lower()
        filename = re.sub(" ", "_", filename)

    if save is not False:
        os.makedirs("export", exist_ok = True) #crea la cartella export se non esiste
        export_path = f"export/{filename}.{save}"

        if save == "png":
            try:
                main_chart.save(export_path, ppi = ppi)
            except TypeError:
                main_chart.save(export_path)
        else:
            main_chart.save(export_path)

        print(f"Your file '{filename}.{save}' was successfully exported to the 'export/' directory.")

    return main_chart.resolve_axis(x="shared", y="shared")