pynarrative.story

  1import altair as alt
  2import pandas as pd
  3
  4class Story:
  5    """
  6    Story class: Implements a structure for creating narrative views of data.
  7    
  8    This class extends the functionality of Altair to include narrative elements
  9    such as titles, contexts, call-to-action and annotations. It is designed to facilitate
 10    the creation of more engaging and informative data visualisations.
 11    """
 12
 13    def __init__(self, data=None, width=600, height=400, font='Arial', base_font_size=16, **kwargs):
 14        """
 15        Initialise a Story object.
 16
 17        Parameters:
 18        - data: DataFrame or URL for graph data  (default: None)
 19        - width: Graph width in pixels (default: 600)
 20        - height: Height of the graph in pixels (default: 400)
 21        - font: Font to be used for all text elements (default: 'Arial')
 22        - base_font_size: Basic font size in pixels (default: 16)
 23        - **kwargs: Additional parameters to be passed to the constructor of alt.Chart
 24        """
 25        # Initialising the Altair Chart object with basic parameters
 26        self.chart = alt.Chart(data, width=width, height=height, **kwargs)
 27        self.font = font
 28        self.base_font_size = base_font_size
 29        self.story_layers = []  # List for storing history layers
 30        
 31        # Dictionaries for the sizes and colours of various text elements
 32        # These values are multipliers for base_font_size
 33        self.font_sizes = {
 34            'title': 2,        # The title will be twice as big as the basic font
 35            'subtitle': 1.5,   # The subtitle will be 1.5 times bigger
 36            'context': 1.2,    # The context text will be 1.2 times larger
 37            'nextstep': 1.3,   # Next-Step text will be 1.3 times larger
 38            'source': 1        # The source text will have the basic size
 39        }
 40        # Predefined colours for various text elements
 41        self.colors = {
 42            'title': 'black',
 43            'subtitle': 'gray',
 44            'context': 'black',
 45            'nextstep': 'black',
 46            'source': 'gray'
 47        }
 48        self.config = {}
 49
 50    def __getattr__(self, name):
 51        """
 52        Special method to delegate attributes not found to the Altair Chart object.
 53        
 54        This method is crucial for maintaining compatibility with Altair, allowing
 55        to call Altair methods directly on the Story object.
 56
 57        Parameters:
 58        - name: Name of the required attribute
 59
 60        Returns:
 61        - Altair attribute if it exists, otherwise raises an AttributeError
 62        """
 63        # Search for the attribute in Altair's Chart object
 64        attr = getattr(self.chart, name)
 65        
 66        # If the attribute is callable (i.e. it is a method), we create a wrapper
 67        if callable(attr):
 68            def wrapped(*args, **kwargs):
 69                # This wrapped function is created dynamically for each Altair method
 70                # It is used to intercept Altair method calls and handle them correctly
 71                
 72                # Let us call Altair's original method
 73                result = attr(*args, **kwargs)
 74                
 75                # If the result is a new Altair Chart object
 76                if isinstance(result, alt.Chart):
 77                    # We update the chart attribute of our Story instance 
 78                    self.chart = result
 79                    # We return self (the Story instance) to allow method chaining
 80                    return self
 81                # If the result is not a Chart, we return it as it is
 82                return result
 83            
 84            # We return the wrapped function instead of the original method
 85            return wrapped
 86        
 87        # If the attribute is not callable (it is a property), we return it directly
 88        return attr
 89
 90    def add_title(self, title, subtitle=None, title_color=None, subtitle_color=None, title_font_size=None, subtitle_font_size=None, dx=None, dy=None, s_dx=None, s_dy=None):
 91        """
 92        Adds a title layer (and optional subtitle) to the story.
 93
 94        Parameters:
 95        - title: Main title text
 96        - subtitle: Subtitle text (optional)
 97        - title_color: Custom colour for the title (optional)
 98        - subtitle_color: Custom colour for the subtitle (optional)
 99        - title_font_size: Custom font size for title in pixels (optional)
100        - subtitle_font_size: Custom font size for subtitle in pixels (optional)
101        - dx: Horizontal offset of the title in pixels (optional)
102        - dy: Vertical offset of the title in pixels (optional)
103        - s_dx: Horizontal offset of the subtitle in pixels (optional)
104        - s_dy: Vertical offset of the subtitle in pixels (optional)
105
106        returns:
107        - self, to allow method chaining
108        """
109        self.story_layers.append({
110            'type': 'title', 
111            'title': title, 
112            'subtitle': subtitle,
113            'title_color': title_color or self.colors['title'],
114            'subtitle_color': subtitle_color or self.colors['subtitle'],
115            'title_font_size': title_font_size or self.em_to_px(self.font_sizes['title']),
116            'subtitle_font_size': subtitle_font_size or self.em_to_px(self.font_sizes['subtitle']),
117            'dx': dx or 0,
118            'dy': dy or 0,
119            's_dx': s_dx or 0,
120            's_dy': s_dy or 0
121        })
122        return self
123
124    def add_context(self, text, position='left', color=None, dx=0, dy=0, font_size=None):
125        """
126        Adds a context layer to the story.
127
128        Parameters:
129        - text: The context text to be added
130        - position: The position of the text (default: ‘left’)
131        - colour: Custom text colour (optional)
132        - dx: Horizontal offset in pixels from the base position (default: 0)
133        - dy: Vertical offset in pixels from the base position (default: 0)
134        - font_size: Custom font size in pixels (optional)
135
136        returns:
137        - self, to allow method chaining
138        """
139        self.story_layers.append({
140            'type': 'context', 
141            'text': text, 
142            'position': position,
143            'color': color or self.colors['context'],
144            'dx': dx,
145            'dy': dy,
146            'font_size' : font_size or self.em_to_px(self.font_sizes['context'])
147            
148        })
149        return self
150
151
152    def add_next_steps(self, 
153        #Basic parameters
154        text=None,
155        position='bottom',
156        mode=None,
157        title="What can we do next?",        
158    
159        # General styling parameters (used as fallback)
160        color=None,             # general color of elements
161        font_family='Arial',
162        font_size=14,
163        text_color=None,
164        opacity=None,
165        width=None,
166        height=None,
167        chart_width=None,
168        chart_height=None,
169        space=None,
170        
171        # List of texts for steps
172        texts=None,
173        
174        # Parameters per button
175        url=None,
176        button_width=120,
177        button_height=40,
178        button_color='#80C11E',
179        button_opacity=0.2,
180        button_corner_radius=5,
181        button_text_color='black',
182        button_font_family=None,    # If None, use font_family
183        button_font_size=None,      # If None, use font_size
184        
185        # Parameters for line_steps
186        line_steps_rect_width=10,
187        line_steps_rect_height=10,
188        line_steps_space=5,
189        line_steps_chart_width=700,
190        line_steps_chart_height=100,
191        line_steps_color='#80C11E',
192        line_steps_opacity=0.2,
193        line_steps_text_color='black',
194        line_steps_font_family=None,  # If None, use font_family
195        line_steps_font_size=None,    # If None, use font_size
196        
197        # Parameters for stair_steps
198        stair_steps_rect_width=10,
199        stair_steps_rect_height=3,
200        stair_steps_chart_width=700,
201        stair_steps_chart_height=300,
202        stair_steps_color='#80C11E',
203        stair_steps_opacity=0.2,
204        stair_steps_text_color='black',
205        stair_steps_font_family=None,  # If None, use font_family
206        stair_steps_font_size=None,    # If None, use font_size
207        
208        # Title Parameters
209        title_color='black',
210        title_font_family=None,     # If None, use font_family
211        title_font_size=None,       # If None, use font_size * 1.4
212        **kwargs):
213
214
215        """
216        Adds a next-step element to the visualisation with multiple customisation options.
217        
218        Parameters
219        ---------
220        text : str                                              Text for the basic version or for the button
221        position : str, default=‘bottom’                        Position of the element (‘bottom’, ‘top’, ‘left’, ‘right’)
222        type : str, optional                                    Display type (‘line_steps’, ‘button’, ‘stair_steps’)
223        title : str, default="What can we do next?’             Title for special visualisations
224        colour : str, optional                                  Text colour for the basic version
225        font_family : str, default=‘Arial’                      Default font
226        font_size : int, default=14                             Default font size
227        texts : list of str                                     List of texts for line_steps and stair_steps
228        url : str                                               URL for the button
229        
230        Parameters for Button
231        ------------------
232        button_width : int, default=120                         Button width
233        button_height : int, default=40                         Height of the button
234        button_color : str, default=‘#80C11E’                   Background colour of the button
235        button_opacity : float, default=0.2                     Opacity of the button
236        button_corner_radius : int, default=5                   Corner radius of the button
237        button_text_color : str, default=‘black’                Button text colour
238        button_font_family : str, optional                      Font specific to the button
239        button_font_size : int, optional                        Font size for the button
240        
241        Parameters for Line Steps
242        ----------------------
243        line_steps_rect_width : int, default=10                 Rectangle width
244        line_steps_rect_height : int, default=10                Height of rectangles
245        line_steps_space : int, default=5                       Space between rectangles
246        line_steps_chart_width : int, default=700               Total width of the chart
247        line_steps_chart_height : int, default=100              Total chart height
248        line_steps_color : str, default=‘#80C11E’               Colour of rectangles
249        line_steps_opacity : float, default=0.2                 Opacity of rectangles
250        line_steps_text_colour : str, default=‘black’           Text colour
251        line_steps_font_family : str, optional                  Font specific to line steps
252        line_steps_font_size : int, optional                    Font size for line steps
253        
254        Parameters for Stair Steps
255        -----------------------
256        stair_steps_rect_width : int, default=10                Width of steps
257        stair_steps_rect_height : int, default=3                Height of steps
258        stair_steps_chart_width : int, default=700              Total width of the chart
259        stair_steps_chart_height : int, default=300             Total height of the chart
260        stair_steps_color : str, default=‘#80C11E’              Colour of the steps
261        stair_steps_opacity : float, default=0.2                Opacity of the steps
262        stair_steps_text_colour : str, default=‘black’          Text colour
263        stair_steps_font_family : str,                          Font specific to stair steps
264        stair_steps_font_size : int, optional                   Font size for stair steps
265        
266        Title parameters
267        ----------------------
268        title_color : str, default=‘black’                      Title colour
269        title_font_family : str, optional                       Specific font for the title
270        title_font_size : int, optional                         Font size for the title
271        
272        Returns
273        -------
274        self : Story object                                     The current instance for method chaining
275        """
276 
277 
278        # Apply general parameters to specific parameters if not set
279        # Colors and styling
280        button_color = button_color or color
281        button_opacity = button_opacity or opacity
282        button_text_color = button_text_color or text_color
283        button_width = button_width or width
284        button_height = button_height or height
285        
286        line_steps_color = line_steps_color or color
287        line_steps_opacity = line_steps_opacity or opacity
288        line_steps_text_color = line_steps_text_color or text_color
289        line_steps_rect_width = line_steps_rect_width or width
290        line_steps_rect_height = line_steps_rect_height or height
291        line_steps_space = line_steps_space or space
292        line_steps_chart_width = line_steps_chart_width or chart_width
293        line_steps_chart_height = line_steps_chart_height or chart_height
294        
295        stair_steps_color = stair_steps_color or color
296        stair_steps_opacity = stair_steps_opacity or opacity
297        stair_steps_text_color = stair_steps_text_color or text_color
298        stair_steps_rect_width = stair_steps_rect_width or width
299        stair_steps_rect_height = stair_steps_rect_height or height
300        stair_steps_chart_width = stair_steps_chart_width or chart_width
301        stair_steps_chart_height = stair_steps_chart_height or chart_height
302        
303        title_color = title_color or text_color       
304
305    
306      
307        # Basic version (text only)
308        if mode is None:
309            if text is None:
310                raise ValueError("The parameter ‘text’ is required for the basic version")
311            self.story_layers.append({
312                'type': 'cta', 
313                'text': text, 
314                'position': position,
315                'color': color or self.colors['cta']
316            })
317            return self
318
319        # Mode validation
320        valid_types = ['line_steps', 'button', 'stair_steps']
321        if mode not in valid_types:
322            raise ValueError(f"Invalid type. Use one of: {', '.join(valid_types)}")
323
324        # Chart creation by mode
325        if mode == 'button':
326            if text is None:
327                raise ValueError("The parameter ‘text’ is required for the button type")
328            if url is None:
329                raise ValueError("The ‘url’ parameter is required for the button type")
330            
331            # Button chart creation
332            df = pd.DataFrame([{
333                'text': text,
334                'url': url,
335                'x': 0,
336                'y': 0
337            }])
338            
339            base = alt.Chart(df).encode(
340                x=alt.X('x:Q', axis=None),
341                y=alt.Y('y:Q', axis=None)
342            ).properties(
343                width=button_width,
344                height=button_height
345            )
346            
347            button_bg = base.mark_rect(
348                color=button_color,
349                opacity=button_opacity,
350                cornerRadius=button_corner_radius,
351                width=button_width,
352                height=button_height
353            ).encode(
354                href='url:N'
355            )
356            
357            button_text = base.mark_text(
358                fontSize=button_font_size or font_size,
359                font=button_font_family or font_family,
360                align='center',
361                baseline='middle',
362                color=button_text_color
363            ).encode(
364                text='text'
365            )
366            
367            chart = alt.layer(button_bg, button_text)
368            
369        elif mode == 'line_steps':
370            if not texts or not isinstance(texts, list):
371                raise ValueError("It is necessary to provide a list of texts for line_steps")
372            if len(texts) > 5:
373                raise ValueError("Maximum number of steps is 5")
374            if len(texts) < 1:
375                raise ValueError("Must provide at least one step")
376                
377            # Create DataFrame for rectangles and text
378            N = len(texts)
379            x = [i*(line_steps_rect_width+line_steps_space) for i in range(N)]
380            y = [0 for _ in range(N)]
381            x2 = [(i+1)*line_steps_rect_width+i*line_steps_space for i in range(N)]
382            y2 = [line_steps_rect_height for _ in range(N)]
383            
384            df_rect = pd.DataFrame({   
385                'x': x, 'y': y, 'x2': x2, 'y2': y2, 'text': texts
386            })
387            
388            # Create rectangles
389            rect = alt.Chart(df_rect).mark_rect(
390                color=line_steps_color,
391                opacity=line_steps_opacity
392            ).encode(
393                x=alt.X('x:Q', axis=None),
394                y=alt.Y('y:Q', axis=None),
395                x2='x2:Q',
396                y2='y2:Q'
397            ).properties(
398                width=line_steps_chart_width,
399                height=line_steps_chart_height
400            )
401            
402            # Add text labels
403            text = alt.Chart(df_rect).mark_text(
404                fontSize=line_steps_font_size or font_size,
405                font=line_steps_font_family or font_family,
406                align='left',
407                dx=10,
408                lineHeight=18,
409                color=line_steps_text_color
410            ).encode(
411                text='text:N',
412                x=alt.X('x:Q', axis=None),
413                y=alt.Y('y_half:Q', axis=None),
414            ).transform_calculate(
415                y_half='datum.y2/2'
416            )
417            
418            if N > 1:
419                df_line = pd.DataFrame({   
420                    'x': [line_steps_rect_width*i+line_steps_space*(i-1) for i in range(1,N)],
421                    'y': [line_steps_rect_height/2 for _ in range(N-1)],
422                    'x2': [(line_steps_rect_width+line_steps_space)*i for i in range(1,N)],
423                    'y2': [line_steps_rect_height/2 for _ in range(N-1)]
424                })
425                
426                line = alt.Chart(df_line).mark_line(
427                    point=True,
428                    strokeWidth=2
429                ).encode(
430                    x=alt.X('x:Q', axis=None),
431                    y=alt.Y('y:Q', axis=None),
432                    x2='x2:Q',
433                    y2='y2:Q'
434                )
435                
436                chart = alt.layer(rect, line, text)
437            else:
438                chart = alt.layer(rect, text)
439                
440        elif mode == 'stair_steps':
441            if not texts or not isinstance(texts, list):
442                raise ValueError("You must provide a list of texts for stair_steps")
443            if len(texts) > 5:
444                raise ValueError("Maximum number of steps is 5")
445            if len(texts) < 1:
446                raise ValueError("Must provide at least one step")
447                
448            # Create DataFrame for rectangles and text
449            N = len(texts)
450            x = [i*stair_steps_rect_width for i in range(N)]
451            y = [i*stair_steps_rect_height for i in range(N)]
452            x2 = [(i+1)*stair_steps_rect_width for i in range(N)]
453            y2 = [(i+1)*stair_steps_rect_height for i in range(N)]
454            
455            df_rect = pd.DataFrame({   
456                'x': x, 'y': y, 'x2': x2, 'y2': y2, 'text': texts
457            })
458            
459            # Create rectangles
460            rect = alt.Chart(df_rect).mark_rect(
461                color=stair_steps_color,
462                opacity=stair_steps_opacity
463            ).encode(
464                x=alt.X('x:Q', axis=None),
465                y=alt.Y('y:Q', axis=None, scale=alt.Scale(domain=[0, N*stair_steps_rect_height])),
466                x2='x2:Q',
467                y2='y2:Q'
468            ).properties(
469                width=stair_steps_chart_width,
470                height=stair_steps_chart_height
471            )
472            
473            # Add text labels
474            text = alt.Chart(df_rect).mark_text(
475                fontSize=stair_steps_font_size or font_size,
476                font=stair_steps_font_family or font_family,
477                align='left',
478                dx=10,
479                dy=0,
480                color=stair_steps_text_color
481            ).encode(
482                text=alt.Text('text'),
483                x=alt.X('x:Q', axis=None),
484                y=alt.Y('y_mid:Q', axis=None),
485            ).transform_calculate(
486                y_mid='(datum.y + datum.y2)/2'
487            )
488            
489            if N > 1:
490                line_data = []
491                for i in range(N-1):
492                    line_data.append({
493                        'x': x2[i],
494                        'y': y2[i],
495                        'x2': x[i+1],
496                        'y2': y[i+1]
497                    })
498                
499                df_line = pd.DataFrame(line_data)
500                
501                line = alt.Chart(df_line).mark_line(
502                    point=True,
503                    strokeWidth=2
504                ).encode(
505                    x=alt.X('x:Q', axis=None),
506                    y=alt.Y('y:Q', axis=None),
507                    x2='x2:Q',
508                    y2='y2:Q'
509                )
510                
511                chart = alt.layer(rect, line, text)
512            else:
513                chart = alt.layer(rect, text)
514
515        # Addition of title with customisable font
516        if title:
517            chart = chart.properties(
518                title=alt.TitleParams(
519                    text=[title],
520                    fontSize=title_font_size or (font_size * 1.4),
521                    font=title_font_family or font_family,
522                    color=title_color,
523                    offset=10
524                )
525            )
526
527        # Addition to layer
528        self.story_layers.append({
529            'type': 'special_cta',
530            'chart': chart,
531            'position': position
532        })
533        
534        return self
535    
536    def add_source(self, text, position='bottom', vertical=False, color=None, dx=None, dy=None, font_size=None):
537        """
538        Add a source layer to the story.
539
540        Parameters:
541        - text: The source text
542        - position: The position of the text (default: 'bottom')
543        - vertical: If True, the text will be rotated 90 degrees (default: False)
544        - color: Custom text color (optional)
545        - dx: Horizontal movement of the text (optional)
546        - dy: Vertical movement of the text (optional)
547        - font_size: Custom font size (optional)
548
549        Ritorna:
550        - self, to allow the method chaining
551        """
552        self.story_layers.append({
553            'type': 'source', 
554            'text': text, 
555            'position': position, 
556            'vertical': vertical,
557            'color': color or self.colors['source'],
558            'dx': dx or 0,
559            'dy': dy or 0,
560            'font_size': font_size or self.em_to_px(self.font_sizes['source'])
561            
562        })
563        return self
564
565    def add_annotation(self, x_point, y_point, annotation_text="Point of interest", 
566                                     arrow_direction='right', arrow_color='blue', arrow_size=40,
567                                     label_color='black', label_size=12,
568                                     show_point=True, point_color='red', point_size=60,
569                                     arrow_dx=0, arrow_dy=-45,
570                                     label_dx=37, label_dy=-37):
571        """
572        Create an arrow annotation on the graph.
573        
574        This method is essential for highlighting specific points in the graph and adding
575        contextual explanations. It is particularly useful for data narration, allowing
576        to guide the observer's attention to relevant aspects of the visualisation.
577
578        Parameters:
579        - x_point, y_point: Coordinates of the point to be annotated
580        - annotation_text: Text of the annotation (default: ‘Point of interest’)
581        - arrow_direction: Direction of the arrow (default: ‘right’)
582        - arrow_color, arrow_size: Arrow colour and size
583        - label_colour, label_size: Colour and size of the annotation text
584        - show_point: If True, shows a point at the annotation location
585        - point_color, point_size: Colour and size of the point
586        - arrow_dx, arrow_dy: Distances in pixels to be added to the arrow position (default:0, -45)
587        - label_dx, label_dy: Distances in pixels to be added to the label position (default:37, -37)
588
589        returns:
590        - self, to allow method chaining
591        """
592        
593        # Dictionary that maps arrow directions to corresponding Unicode symbols
594        arrow_symbols = {
595            'left': '←', 'right': '→', 'up': '↑', 'down': '↓',
596            'upleft': '↖', 'upright': '↗', 'downleft': '↙', 'downright': '↘',
597            'leftup': '↰', 'leftdown': '↲', 'rightup': '↱', 'rightdown': '↳',
598            'upleftcurve': '↺', 'uprightcurve': '↻'
599        }
600
601        # Check that the direction of the arrow is valid
602        if arrow_direction not in arrow_symbols:
603            raise ValueError(f"Invalid arrow direction. Use one of: {', '.join(arrow_symbols.keys())}")
604
605        # Select the appropriate arrow symbol
606        arrow_symbol = arrow_symbols[arrow_direction]
607        
608        # checks whether the encoding has already been defined
609        if not hasattr(self.chart, 'encoding')or not hasattr(self.chart.encoding, 'x'):
610            # If encoding is not defined use of default values
611            x_type = 'Q'
612            y_type = 'Q'
613        else:
614            # If encoding is defined, we extract the data types for the x and y axes
615            x_type = self.chart.encoding.x.shorthand.split(':')[-1]
616            y_type = self.chart.encoding.y.shorthand.split(':')[-1]
617
618        # Explanation of data type extraction:
619        # 1. We first check whether the encoding has been defined. This is important because
620        # the user may call this method before defining the encoding of the graph.
621        # (The encoding in Altair defines how the data is mapped to the visual properties of the graph).
622        # 2. If encoding is not defined, we use ‘Q’ (Quantitative) as the default data type
623        # for both axes. This allows the method to work even without a defined encoding.
624        # 3. If the encoding is defined, we proceed with the data type extraction as before:
625        # - self.chart.encoding.x.shorthand: accesses the shorthand of the x-axis
626        # .shorthand: In Altair, ‘shorthand’ is a concise notation for specifying the encoding.
627        # Example of shorthand: ‘column:Q’ where ‘column’ is the name of the data column
628        # and ‘Q’ is the data type (in this case, Quantitative).
629        # - split(‘:’)[-1]: splits the shorthand at ‘:’ and takes the last element (the data type)
630        # Example: If shorthand is ‘price:Q’, split(‘:’) will return [‘price’, ‘Q’].
631        # 4. The extracted data type will be one of:
632        # - ‘Q’: Quantitative (continuous numeric)
633        # - ‘O’: Ordinal (ordered categories)
634        # N': Nominal (unordered categories)
635        # T': Temporal (dates or times)
636        
637        # Important: This operation is essential to ensure that the annotations
638        # added to the graph are consistent with the original axis data types.
639        # Without this match, annotations may be positioned
640        # incorrectly or cause rendering errors.
641
642        # Create a DataFrame with a single point for the annotation
643        annotation_data = pd.DataFrame({'x': [x_point], 'y': [y_point]})
644        
645        # Internal function to create the correct encoding based on the data type
646        def create_encoding(field, dtype):
647            """
648            Creates the appropriate encoding for Altair based on the data type.
649            This function is crucial to ensure that the annotation is
650            consistent with the original chart data type.
651            """
652            
653            if dtype == 'O':  # Ordinal
654                return alt.X(field + ':O', title='') if field == 'x' else alt.Y(field + ':O', title='')
655            elif dtype == 'N':  # Nominal
656                return alt.X(field + ':N', title='') if field == 'x' else alt.Y(field + ':N', title='')
657            elif dtype == 'T':  # Temporal
658                return alt.X(field + ':T', title='') if field == 'x' else alt.Y(field + ':T', title='')
659            else:  # Quantity (default)
660                return alt.X(field + ':Q', title='') if field == 'x' else alt.Y(field + ':Q', title='')
661
662        # Initialises the list of annotation layers
663        layers = []
664
665        # Adds full stop if required
666        if show_point:
667            point_layer = alt.Chart(annotation_data).mark_point(
668                color=point_color,
669                size=point_size
670            ).encode(
671                x=create_encoding('x', x_type),
672                y=create_encoding('y', y_type)
673            )
674            layers.append(point_layer)
675
676        # Adds the arrow
677        # We use mark_text to draw the arrow using a Unicode character
678        arrow_layer = alt.Chart(annotation_data).mark_text(
679            text=arrow_symbol, 
680            fontSize=arrow_size,
681            dx=arrow_dx,  # Horizontal offset to position the arrow                   was 22
682            dy=arrow_dy,  # Vertical offset to position the arrow                     was -22
683            color=arrow_color
684        ).encode(
685            x=create_encoding('x', x_type),
686            y=create_encoding('y', y_type)
687        )
688        layers.append(arrow_layer)
689
690        # Adds text label
691        label_layer = alt.Chart(annotation_data).mark_text(
692            align='left',
693            baseline='top',
694            dx= label_dx,  # Horizontal offset to position text                     was 37
695            dy= label_dy,  # Vertical offset to position text                       was -37
696            fontSize=label_size,
697            color=label_color,
698            text=annotation_text
699        ).encode(
700            x=create_encoding('x', x_type),
701            y=create_encoding('y', y_type)
702        )
703        layers.append(label_layer)
704
705        # Combine all layers into a single annotation
706        annotation = alt.layer(*layers)
707
708        # Adds annotation to history layers
709        self.story_layers.append({
710            'type': 'annotation',
711            'chart': annotation
712        })
713
714        # Note on flexibility and robustness:
715        # This approach makes the add_annotation method more flexible,
716        # as it can automatically adapt to different types of graphs without
717        # requiring manual input on the axis data type. In addition, using
718        # the Altair shorthand, the code automatically adapts even if the user
719        # has specified the encoding in different ways (e.g., using alt.X(‘column:Q’)
720        # or alt.X(‘column’, type=‘quantitative’)).
721
722
723        return self  # Return self to allow method chaining
724    
725    
726    def add_line(self, value, orientation='horizontal', color='red', stroke_width=2, stroke_dash=[]):
727        """
728        Adds a reference line to the story.
729
730        Parameters:
731        - value: The position of the line (x or y value)
732        - orientation: 'horizontal' or 'vertical' (default: 'horizontal')
733        - color: Color of the line (default: 'red')
734        - stroke_width: Width of the line in pixels (default: 2)
735        - stroke_dash: Pattern for dashed line, e.g. [5,5] (default: [] for solid line)
736
737        Returns:
738        - self, to allow method chaining
739        """
740        
741        if orientation not in ['horizontal', 'vertical']:
742            raise ValueError("orientation must be either 'horizontal' or 'vertical'")
743
744        # Crea un DataFrame con un singolo valore
745        if orientation == 'horizontal':
746            data = pd.DataFrame({'y': [value]})
747            encoding = {'y': 'y:Q'}
748        else:  # vertical
749            data = pd.DataFrame({'x': [value]})
750            encoding = {'x': 'x:Q'}
751        
752        # definizione dei parametri della line
753        mark_params = {
754            'color': color,
755            'strokeWidth': stroke_width,
756        }
757        
758        # Viene aggiunto stokeDash solo se è specificato un pattern
759        if stroke_dash:
760            mark_params['strokeDash'] = stroke_dash
761
762        # Crea la linea
763        line = alt.Chart(data).mark_rule(
764            color=color,
765            strokeWidth=stroke_width,
766            strokeDash=stroke_dash
767        ).encode(
768            **encoding
769        )
770
771        # Aggiunge la linea ai layer della storia
772        self.story_layers.append({
773            'type': 'line',
774            'chart': line
775        })
776
777        return self
778    
779        
780
781    def em_to_px(self, em):
782        """
783        Converts a dimension from em to pixels.
784
785        This function is essential for maintaining visual consistency
786        between different textual elements and devices.
787
788        Parameters:
789        - em: Size in em
790
791        Returns:
792        - Equivalent size in pixels (integer)
793        """
794        return int(em * self.base_font_size)
795
796    def _get_position(self, position):
797        """
798        Calculates the x and y co-ordinates for positioning text elements in the graph.
799
800        This method is crucial for the correct positioning of various elements
801        narrative elements such as context, call-to-action and sources.
802
803        Parameters:
804        - position: String indicating the desired position (e.g. ‘left’, ‘right’, ‘top’, etc.).
805        
806        Returns:
807        - Tuple (x, y) representing the coordinates in pixels
808        """
809        # Dictionary mapping positions to coordinates (x, y)
810        # Co-ordinates are calculated from the size of the graph
811        positions = {
812            'left': (-100, self.chart.height / 2),  # 10 pixel from the left edge, centred vertically
813            'right': (self.chart.width + 20, self.chart.height / 2),  # 10 pixel from the right edge, centred vertically
814            'top': (self.chart.width / 2, 80),  # Centred horizontally, 80 pixels from above
815            'bottom': (self.chart.width / 2, self.chart.height + 40),  # Horizontally centred, 40 pixels from bottom
816            'center': (self.chart.width / 2, self.chart.height / 2),  # Graph Centre
817            'side-left': (-150, self.chart.height / 2),  # 10 pixels to the left of the border, centred vertically
818            'side-right': (self.chart.width + 50, self.chart.height / 2),  # 10 pixels to the right of the border, centred vertically
819        }
820        
821        # If the required position is not in the dictionary, use a default position
822        # In this case, horizontally centred and 20 pixels from the bottom
823        return positions.get(position, (self.chart.width / 2, self.chart.height - 20))
824
825    def create_title_layer(self, layer):
826        """
827        Creates the title layer (and subtitle if present).
828
829        This method is responsible for visually creating the main title
830        and the optional subtitle of the graphic.
831
832        Parameters:
833        - Layer: Dictionary containing the title information
834
835        Returns:
836        - Altair Chart object representing the title layer
837        """
838        title_chart = alt.Chart(self.chart.data).mark_text(
839            text=layer['title'],
840            fontSize=layer['title_font_size'],
841            fontWeight='bold',
842            align='center',
843            font=self.font,
844            color=layer['title_color']
845        ).encode(
846            x=alt.value(self.chart.width / 2 + layer.get('dx', 0)),  # Centre horizontally
847            y=alt.value(-50 + layer.get('dy', 0))  # Position 20 pixels from above
848        )
849        
850        if layer['subtitle']:
851            subtitle_chart = alt.Chart(self.chart.data).mark_text(
852                text=layer['subtitle'],
853                fontSize=layer['subtitle_font_size'],
854                align='center',
855                font=self.font,
856                color=layer['subtitle_color']
857            ).encode(
858                x=alt.value(self.chart.width / 2 + layer.get('s_dx', 0)),  # Centre horizontally
859                y=alt.value(-20 + layer.get('s_dy', 0))  # Position 50 pixels from the top (below the title)
860            )
861            return title_chart + subtitle_chart
862        return title_chart
863
864    def create_text_layer(self, layer):
865        """
866        Creates a generic text layer (context, next-steps, source).
867
868        This method is used to create text layers for various narrative purposes,
869        such as adding context, next-steps or data source information.
870
871        Parameters:
872        - Layer: Dictionary containing the text information to be added
873
874        Returns:
875        - Altair Chart object representing the text layer
876        """
877        x, y = self._get_position(layer['position'])
878        
879        # Apply offsets if they exist (for context layers)
880        if layer['type'] in ['context', 'source']:
881            x += layer.get('dx', 0)
882            y += layer.get('dy', 0)
883        
884        return alt.Chart(self.chart.data).mark_text(
885            text=layer['text'],
886            fontSize=layer.get('font_size', self.em_to_px(self.font_sizes[layer['type']])),
887            align='center',
888            baseline='middle',
889            font=self.font,
890            angle=270 if layer.get('vertical', False) else 0,  #  Rotate text if ‘vertical’ is True
891            color=layer['color']
892        ).encode(
893            x=alt.value(x),
894            y=alt.value(y)
895        )
896
897    def configure_view(self, *args, **kwargs):
898        """
899        Configure aspects of the graph view using Altair's configure_view method.
900
901        This method allows you to configure various aspects of the chart view, such as
902        the background colour, border style, internal spacing, etc.
903
904        Parameters:
905        *args, **kwargs: Arguments to pass to the Altair configure_view method.
906
907        stores the view configuration for application during rendering.
908        """
909        self.config['view'] = kwargs
910        return self
911
912
913    def render(self):
914        """
915        It renders all layers of the story in a single graphic.       
916        """
917        # Let's start with the basic graph
918        main_chart = self.chart
919        
920        # Create separate lists to place special graphics
921        top_charts = []
922        bottom_charts = []
923        left_charts = []
924        right_charts = []
925        overlay_charts = []
926        
927        # Organise the layers according to their position
928        for layer in self.story_layers:
929            if layer['type'] == 'special_cta':
930                # We take the position from the layer
931                if layer.get('position') == 'top':
932                    top_charts.append(layer['chart'])
933                elif layer.get('position') == 'bottom':
934                    bottom_charts.append(layer['chart'])
935                elif layer.get('position') == 'left':
936                    left_charts.append(layer['chart'])
937                elif layer.get('position') == 'right':
938                    right_charts.append(layer['chart'])
939            elif layer['type'] == 'title':
940                overlay_charts.append(self.create_title_layer(layer))
941            elif layer['type'] in ['context', 'cta', 'source']:
942                overlay_charts.append(self.create_text_layer(layer))
943            elif layer['type'] in ['shape', 'shape_label', 'annotation']:
944                overlay_charts.append(layer['chart'])
945            elif layer['type'] == 'line':
946                overlay_charts.append(layer['chart'])
947
948        # Overlaying the layers on the main graph
949        for overlay in overlay_charts:
950            main_chart += overlay
951
952        # Build the final layout
953        if left_charts:
954            main_chart = alt.hconcat(alt.vconcat(*left_charts), main_chart)
955        if right_charts:
956            main_chart = alt.hconcat(main_chart, alt.vconcat(*right_charts))
957        if top_charts:
958            main_chart = alt.vconcat(alt.hconcat(*top_charts), main_chart)
959        if bottom_charts:
960            main_chart = alt.vconcat(main_chart, alt.hconcat(*bottom_charts))
961
962        # Apply configurations
963        if 'view' in self.config:
964            main_chart = main_chart.configure_view(**self.config['view'])
965
966        return main_chart.resolve_axis(x='independent', y='independent')
967    
968
969
970    
971
972def story(data=None, **kwargs):
973    """
974    Utility function for creating a Story instance.
975
976    This function simplifies the creation of a Story object, allowing
977    to initialise it in a more concise and intuitive way.
978
979    Parameters:
980    - data: DataFrame or URL for chart data (default: None)
981    - **kwargs: Additional parameters to be passed to the Story constructor
982
983    Returns:
984    - An instance of the Story class
985    """
986    return Story(data, **kwargs)
class Story:
  5class Story:
  6    """
  7    Story class: Implements a structure for creating narrative views of data.
  8    
  9    This class extends the functionality of Altair to include narrative elements
 10    such as titles, contexts, call-to-action and annotations. It is designed to facilitate
 11    the creation of more engaging and informative data visualisations.
 12    """
 13
 14    def __init__(self, data=None, width=600, height=400, font='Arial', base_font_size=16, **kwargs):
 15        """
 16        Initialise a Story object.
 17
 18        Parameters:
 19        - data: DataFrame or URL for graph data  (default: None)
 20        - width: Graph width in pixels (default: 600)
 21        - height: Height of the graph in pixels (default: 400)
 22        - font: Font to be used for all text elements (default: 'Arial')
 23        - base_font_size: Basic font size in pixels (default: 16)
 24        - **kwargs: Additional parameters to be passed to the constructor of alt.Chart
 25        """
 26        # Initialising the Altair Chart object with basic parameters
 27        self.chart = alt.Chart(data, width=width, height=height, **kwargs)
 28        self.font = font
 29        self.base_font_size = base_font_size
 30        self.story_layers = []  # List for storing history layers
 31        
 32        # Dictionaries for the sizes and colours of various text elements
 33        # These values are multipliers for base_font_size
 34        self.font_sizes = {
 35            'title': 2,        # The title will be twice as big as the basic font
 36            'subtitle': 1.5,   # The subtitle will be 1.5 times bigger
 37            'context': 1.2,    # The context text will be 1.2 times larger
 38            'nextstep': 1.3,   # Next-Step text will be 1.3 times larger
 39            'source': 1        # The source text will have the basic size
 40        }
 41        # Predefined colours for various text elements
 42        self.colors = {
 43            'title': 'black',
 44            'subtitle': 'gray',
 45            'context': 'black',
 46            'nextstep': 'black',
 47            'source': 'gray'
 48        }
 49        self.config = {}
 50
 51    def __getattr__(self, name):
 52        """
 53        Special method to delegate attributes not found to the Altair Chart object.
 54        
 55        This method is crucial for maintaining compatibility with Altair, allowing
 56        to call Altair methods directly on the Story object.
 57
 58        Parameters:
 59        - name: Name of the required attribute
 60
 61        Returns:
 62        - Altair attribute if it exists, otherwise raises an AttributeError
 63        """
 64        # Search for the attribute in Altair's Chart object
 65        attr = getattr(self.chart, name)
 66        
 67        # If the attribute is callable (i.e. it is a method), we create a wrapper
 68        if callable(attr):
 69            def wrapped(*args, **kwargs):
 70                # This wrapped function is created dynamically for each Altair method
 71                # It is used to intercept Altair method calls and handle them correctly
 72                
 73                # Let us call Altair's original method
 74                result = attr(*args, **kwargs)
 75                
 76                # If the result is a new Altair Chart object
 77                if isinstance(result, alt.Chart):
 78                    # We update the chart attribute of our Story instance 
 79                    self.chart = result
 80                    # We return self (the Story instance) to allow method chaining
 81                    return self
 82                # If the result is not a Chart, we return it as it is
 83                return result
 84            
 85            # We return the wrapped function instead of the original method
 86            return wrapped
 87        
 88        # If the attribute is not callable (it is a property), we return it directly
 89        return attr
 90
 91    def add_title(self, title, subtitle=None, title_color=None, subtitle_color=None, title_font_size=None, subtitle_font_size=None, dx=None, dy=None, s_dx=None, s_dy=None):
 92        """
 93        Adds a title layer (and optional subtitle) to the story.
 94
 95        Parameters:
 96        - title: Main title text
 97        - subtitle: Subtitle text (optional)
 98        - title_color: Custom colour for the title (optional)
 99        - subtitle_color: Custom colour for the subtitle (optional)
100        - title_font_size: Custom font size for title in pixels (optional)
101        - subtitle_font_size: Custom font size for subtitle in pixels (optional)
102        - dx: Horizontal offset of the title in pixels (optional)
103        - dy: Vertical offset of the title in pixels (optional)
104        - s_dx: Horizontal offset of the subtitle in pixels (optional)
105        - s_dy: Vertical offset of the subtitle in pixels (optional)
106
107        returns:
108        - self, to allow method chaining
109        """
110        self.story_layers.append({
111            'type': 'title', 
112            'title': title, 
113            'subtitle': subtitle,
114            'title_color': title_color or self.colors['title'],
115            'subtitle_color': subtitle_color or self.colors['subtitle'],
116            'title_font_size': title_font_size or self.em_to_px(self.font_sizes['title']),
117            'subtitle_font_size': subtitle_font_size or self.em_to_px(self.font_sizes['subtitle']),
118            'dx': dx or 0,
119            'dy': dy or 0,
120            's_dx': s_dx or 0,
121            's_dy': s_dy or 0
122        })
123        return self
124
125    def add_context(self, text, position='left', color=None, dx=0, dy=0, font_size=None):
126        """
127        Adds a context layer to the story.
128
129        Parameters:
130        - text: The context text to be added
131        - position: The position of the text (default: ‘left’)
132        - colour: Custom text colour (optional)
133        - dx: Horizontal offset in pixels from the base position (default: 0)
134        - dy: Vertical offset in pixels from the base position (default: 0)
135        - font_size: Custom font size in pixels (optional)
136
137        returns:
138        - self, to allow method chaining
139        """
140        self.story_layers.append({
141            'type': 'context', 
142            'text': text, 
143            'position': position,
144            'color': color or self.colors['context'],
145            'dx': dx,
146            'dy': dy,
147            'font_size' : font_size or self.em_to_px(self.font_sizes['context'])
148            
149        })
150        return self
151
152
153    def add_next_steps(self, 
154        #Basic parameters
155        text=None,
156        position='bottom',
157        mode=None,
158        title="What can we do next?",        
159    
160        # General styling parameters (used as fallback)
161        color=None,             # general color of elements
162        font_family='Arial',
163        font_size=14,
164        text_color=None,
165        opacity=None,
166        width=None,
167        height=None,
168        chart_width=None,
169        chart_height=None,
170        space=None,
171        
172        # List of texts for steps
173        texts=None,
174        
175        # Parameters per button
176        url=None,
177        button_width=120,
178        button_height=40,
179        button_color='#80C11E',
180        button_opacity=0.2,
181        button_corner_radius=5,
182        button_text_color='black',
183        button_font_family=None,    # If None, use font_family
184        button_font_size=None,      # If None, use font_size
185        
186        # Parameters for line_steps
187        line_steps_rect_width=10,
188        line_steps_rect_height=10,
189        line_steps_space=5,
190        line_steps_chart_width=700,
191        line_steps_chart_height=100,
192        line_steps_color='#80C11E',
193        line_steps_opacity=0.2,
194        line_steps_text_color='black',
195        line_steps_font_family=None,  # If None, use font_family
196        line_steps_font_size=None,    # If None, use font_size
197        
198        # Parameters for stair_steps
199        stair_steps_rect_width=10,
200        stair_steps_rect_height=3,
201        stair_steps_chart_width=700,
202        stair_steps_chart_height=300,
203        stair_steps_color='#80C11E',
204        stair_steps_opacity=0.2,
205        stair_steps_text_color='black',
206        stair_steps_font_family=None,  # If None, use font_family
207        stair_steps_font_size=None,    # If None, use font_size
208        
209        # Title Parameters
210        title_color='black',
211        title_font_family=None,     # If None, use font_family
212        title_font_size=None,       # If None, use font_size * 1.4
213        **kwargs):
214
215
216        """
217        Adds a next-step element to the visualisation with multiple customisation options.
218        
219        Parameters
220        ---------
221        text : str                                              Text for the basic version or for the button
222        position : str, default=‘bottom’                        Position of the element (‘bottom’, ‘top’, ‘left’, ‘right’)
223        type : str, optional                                    Display type (‘line_steps’, ‘button’, ‘stair_steps’)
224        title : str, default="What can we do next?’             Title for special visualisations
225        colour : str, optional                                  Text colour for the basic version
226        font_family : str, default=‘Arial’                      Default font
227        font_size : int, default=14                             Default font size
228        texts : list of str                                     List of texts for line_steps and stair_steps
229        url : str                                               URL for the button
230        
231        Parameters for Button
232        ------------------
233        button_width : int, default=120                         Button width
234        button_height : int, default=40                         Height of the button
235        button_color : str, default=‘#80C11E’                   Background colour of the button
236        button_opacity : float, default=0.2                     Opacity of the button
237        button_corner_radius : int, default=5                   Corner radius of the button
238        button_text_color : str, default=‘black’                Button text colour
239        button_font_family : str, optional                      Font specific to the button
240        button_font_size : int, optional                        Font size for the button
241        
242        Parameters for Line Steps
243        ----------------------
244        line_steps_rect_width : int, default=10                 Rectangle width
245        line_steps_rect_height : int, default=10                Height of rectangles
246        line_steps_space : int, default=5                       Space between rectangles
247        line_steps_chart_width : int, default=700               Total width of the chart
248        line_steps_chart_height : int, default=100              Total chart height
249        line_steps_color : str, default=‘#80C11E’               Colour of rectangles
250        line_steps_opacity : float, default=0.2                 Opacity of rectangles
251        line_steps_text_colour : str, default=‘black’           Text colour
252        line_steps_font_family : str, optional                  Font specific to line steps
253        line_steps_font_size : int, optional                    Font size for line steps
254        
255        Parameters for Stair Steps
256        -----------------------
257        stair_steps_rect_width : int, default=10                Width of steps
258        stair_steps_rect_height : int, default=3                Height of steps
259        stair_steps_chart_width : int, default=700              Total width of the chart
260        stair_steps_chart_height : int, default=300             Total height of the chart
261        stair_steps_color : str, default=‘#80C11E’              Colour of the steps
262        stair_steps_opacity : float, default=0.2                Opacity of the steps
263        stair_steps_text_colour : str, default=‘black’          Text colour
264        stair_steps_font_family : str,                          Font specific to stair steps
265        stair_steps_font_size : int, optional                   Font size for stair steps
266        
267        Title parameters
268        ----------------------
269        title_color : str, default=‘black’                      Title colour
270        title_font_family : str, optional                       Specific font for the title
271        title_font_size : int, optional                         Font size for the title
272        
273        Returns
274        -------
275        self : Story object                                     The current instance for method chaining
276        """
277 
278 
279        # Apply general parameters to specific parameters if not set
280        # Colors and styling
281        button_color = button_color or color
282        button_opacity = button_opacity or opacity
283        button_text_color = button_text_color or text_color
284        button_width = button_width or width
285        button_height = button_height or height
286        
287        line_steps_color = line_steps_color or color
288        line_steps_opacity = line_steps_opacity or opacity
289        line_steps_text_color = line_steps_text_color or text_color
290        line_steps_rect_width = line_steps_rect_width or width
291        line_steps_rect_height = line_steps_rect_height or height
292        line_steps_space = line_steps_space or space
293        line_steps_chart_width = line_steps_chart_width or chart_width
294        line_steps_chart_height = line_steps_chart_height or chart_height
295        
296        stair_steps_color = stair_steps_color or color
297        stair_steps_opacity = stair_steps_opacity or opacity
298        stair_steps_text_color = stair_steps_text_color or text_color
299        stair_steps_rect_width = stair_steps_rect_width or width
300        stair_steps_rect_height = stair_steps_rect_height or height
301        stair_steps_chart_width = stair_steps_chart_width or chart_width
302        stair_steps_chart_height = stair_steps_chart_height or chart_height
303        
304        title_color = title_color or text_color       
305
306    
307      
308        # Basic version (text only)
309        if mode is None:
310            if text is None:
311                raise ValueError("The parameter ‘text’ is required for the basic version")
312            self.story_layers.append({
313                'type': 'cta', 
314                'text': text, 
315                'position': position,
316                'color': color or self.colors['cta']
317            })
318            return self
319
320        # Mode validation
321        valid_types = ['line_steps', 'button', 'stair_steps']
322        if mode not in valid_types:
323            raise ValueError(f"Invalid type. Use one of: {', '.join(valid_types)}")
324
325        # Chart creation by mode
326        if mode == 'button':
327            if text is None:
328                raise ValueError("The parameter ‘text’ is required for the button type")
329            if url is None:
330                raise ValueError("The ‘url’ parameter is required for the button type")
331            
332            # Button chart creation
333            df = pd.DataFrame([{
334                'text': text,
335                'url': url,
336                'x': 0,
337                'y': 0
338            }])
339            
340            base = alt.Chart(df).encode(
341                x=alt.X('x:Q', axis=None),
342                y=alt.Y('y:Q', axis=None)
343            ).properties(
344                width=button_width,
345                height=button_height
346            )
347            
348            button_bg = base.mark_rect(
349                color=button_color,
350                opacity=button_opacity,
351                cornerRadius=button_corner_radius,
352                width=button_width,
353                height=button_height
354            ).encode(
355                href='url:N'
356            )
357            
358            button_text = base.mark_text(
359                fontSize=button_font_size or font_size,
360                font=button_font_family or font_family,
361                align='center',
362                baseline='middle',
363                color=button_text_color
364            ).encode(
365                text='text'
366            )
367            
368            chart = alt.layer(button_bg, button_text)
369            
370        elif mode == 'line_steps':
371            if not texts or not isinstance(texts, list):
372                raise ValueError("It is necessary to provide a list of texts for line_steps")
373            if len(texts) > 5:
374                raise ValueError("Maximum number of steps is 5")
375            if len(texts) < 1:
376                raise ValueError("Must provide at least one step")
377                
378            # Create DataFrame for rectangles and text
379            N = len(texts)
380            x = [i*(line_steps_rect_width+line_steps_space) for i in range(N)]
381            y = [0 for _ in range(N)]
382            x2 = [(i+1)*line_steps_rect_width+i*line_steps_space for i in range(N)]
383            y2 = [line_steps_rect_height for _ in range(N)]
384            
385            df_rect = pd.DataFrame({   
386                'x': x, 'y': y, 'x2': x2, 'y2': y2, 'text': texts
387            })
388            
389            # Create rectangles
390            rect = alt.Chart(df_rect).mark_rect(
391                color=line_steps_color,
392                opacity=line_steps_opacity
393            ).encode(
394                x=alt.X('x:Q', axis=None),
395                y=alt.Y('y:Q', axis=None),
396                x2='x2:Q',
397                y2='y2:Q'
398            ).properties(
399                width=line_steps_chart_width,
400                height=line_steps_chart_height
401            )
402            
403            # Add text labels
404            text = alt.Chart(df_rect).mark_text(
405                fontSize=line_steps_font_size or font_size,
406                font=line_steps_font_family or font_family,
407                align='left',
408                dx=10,
409                lineHeight=18,
410                color=line_steps_text_color
411            ).encode(
412                text='text:N',
413                x=alt.X('x:Q', axis=None),
414                y=alt.Y('y_half:Q', axis=None),
415            ).transform_calculate(
416                y_half='datum.y2/2'
417            )
418            
419            if N > 1:
420                df_line = pd.DataFrame({   
421                    'x': [line_steps_rect_width*i+line_steps_space*(i-1) for i in range(1,N)],
422                    'y': [line_steps_rect_height/2 for _ in range(N-1)],
423                    'x2': [(line_steps_rect_width+line_steps_space)*i for i in range(1,N)],
424                    'y2': [line_steps_rect_height/2 for _ in range(N-1)]
425                })
426                
427                line = alt.Chart(df_line).mark_line(
428                    point=True,
429                    strokeWidth=2
430                ).encode(
431                    x=alt.X('x:Q', axis=None),
432                    y=alt.Y('y:Q', axis=None),
433                    x2='x2:Q',
434                    y2='y2:Q'
435                )
436                
437                chart = alt.layer(rect, line, text)
438            else:
439                chart = alt.layer(rect, text)
440                
441        elif mode == 'stair_steps':
442            if not texts or not isinstance(texts, list):
443                raise ValueError("You must provide a list of texts for stair_steps")
444            if len(texts) > 5:
445                raise ValueError("Maximum number of steps is 5")
446            if len(texts) < 1:
447                raise ValueError("Must provide at least one step")
448                
449            # Create DataFrame for rectangles and text
450            N = len(texts)
451            x = [i*stair_steps_rect_width for i in range(N)]
452            y = [i*stair_steps_rect_height for i in range(N)]
453            x2 = [(i+1)*stair_steps_rect_width for i in range(N)]
454            y2 = [(i+1)*stair_steps_rect_height for i in range(N)]
455            
456            df_rect = pd.DataFrame({   
457                'x': x, 'y': y, 'x2': x2, 'y2': y2, 'text': texts
458            })
459            
460            # Create rectangles
461            rect = alt.Chart(df_rect).mark_rect(
462                color=stair_steps_color,
463                opacity=stair_steps_opacity
464            ).encode(
465                x=alt.X('x:Q', axis=None),
466                y=alt.Y('y:Q', axis=None, scale=alt.Scale(domain=[0, N*stair_steps_rect_height])),
467                x2='x2:Q',
468                y2='y2:Q'
469            ).properties(
470                width=stair_steps_chart_width,
471                height=stair_steps_chart_height
472            )
473            
474            # Add text labels
475            text = alt.Chart(df_rect).mark_text(
476                fontSize=stair_steps_font_size or font_size,
477                font=stair_steps_font_family or font_family,
478                align='left',
479                dx=10,
480                dy=0,
481                color=stair_steps_text_color
482            ).encode(
483                text=alt.Text('text'),
484                x=alt.X('x:Q', axis=None),
485                y=alt.Y('y_mid:Q', axis=None),
486            ).transform_calculate(
487                y_mid='(datum.y + datum.y2)/2'
488            )
489            
490            if N > 1:
491                line_data = []
492                for i in range(N-1):
493                    line_data.append({
494                        'x': x2[i],
495                        'y': y2[i],
496                        'x2': x[i+1],
497                        'y2': y[i+1]
498                    })
499                
500                df_line = pd.DataFrame(line_data)
501                
502                line = alt.Chart(df_line).mark_line(
503                    point=True,
504                    strokeWidth=2
505                ).encode(
506                    x=alt.X('x:Q', axis=None),
507                    y=alt.Y('y:Q', axis=None),
508                    x2='x2:Q',
509                    y2='y2:Q'
510                )
511                
512                chart = alt.layer(rect, line, text)
513            else:
514                chart = alt.layer(rect, text)
515
516        # Addition of title with customisable font
517        if title:
518            chart = chart.properties(
519                title=alt.TitleParams(
520                    text=[title],
521                    fontSize=title_font_size or (font_size * 1.4),
522                    font=title_font_family or font_family,
523                    color=title_color,
524                    offset=10
525                )
526            )
527
528        # Addition to layer
529        self.story_layers.append({
530            'type': 'special_cta',
531            'chart': chart,
532            'position': position
533        })
534        
535        return self
536    
537    def add_source(self, text, position='bottom', vertical=False, color=None, dx=None, dy=None, font_size=None):
538        """
539        Add a source layer to the story.
540
541        Parameters:
542        - text: The source text
543        - position: The position of the text (default: 'bottom')
544        - vertical: If True, the text will be rotated 90 degrees (default: False)
545        - color: Custom text color (optional)
546        - dx: Horizontal movement of the text (optional)
547        - dy: Vertical movement of the text (optional)
548        - font_size: Custom font size (optional)
549
550        Ritorna:
551        - self, to allow the method chaining
552        """
553        self.story_layers.append({
554            'type': 'source', 
555            'text': text, 
556            'position': position, 
557            'vertical': vertical,
558            'color': color or self.colors['source'],
559            'dx': dx or 0,
560            'dy': dy or 0,
561            'font_size': font_size or self.em_to_px(self.font_sizes['source'])
562            
563        })
564        return self
565
566    def add_annotation(self, x_point, y_point, annotation_text="Point of interest", 
567                                     arrow_direction='right', arrow_color='blue', arrow_size=40,
568                                     label_color='black', label_size=12,
569                                     show_point=True, point_color='red', point_size=60,
570                                     arrow_dx=0, arrow_dy=-45,
571                                     label_dx=37, label_dy=-37):
572        """
573        Create an arrow annotation on the graph.
574        
575        This method is essential for highlighting specific points in the graph and adding
576        contextual explanations. It is particularly useful for data narration, allowing
577        to guide the observer's attention to relevant aspects of the visualisation.
578
579        Parameters:
580        - x_point, y_point: Coordinates of the point to be annotated
581        - annotation_text: Text of the annotation (default: ‘Point of interest’)
582        - arrow_direction: Direction of the arrow (default: ‘right’)
583        - arrow_color, arrow_size: Arrow colour and size
584        - label_colour, label_size: Colour and size of the annotation text
585        - show_point: If True, shows a point at the annotation location
586        - point_color, point_size: Colour and size of the point
587        - arrow_dx, arrow_dy: Distances in pixels to be added to the arrow position (default:0, -45)
588        - label_dx, label_dy: Distances in pixels to be added to the label position (default:37, -37)
589
590        returns:
591        - self, to allow method chaining
592        """
593        
594        # Dictionary that maps arrow directions to corresponding Unicode symbols
595        arrow_symbols = {
596            'left': '←', 'right': '→', 'up': '↑', 'down': '↓',
597            'upleft': '↖', 'upright': '↗', 'downleft': '↙', 'downright': '↘',
598            'leftup': '↰', 'leftdown': '↲', 'rightup': '↱', 'rightdown': '↳',
599            'upleftcurve': '↺', 'uprightcurve': '↻'
600        }
601
602        # Check that the direction of the arrow is valid
603        if arrow_direction not in arrow_symbols:
604            raise ValueError(f"Invalid arrow direction. Use one of: {', '.join(arrow_symbols.keys())}")
605
606        # Select the appropriate arrow symbol
607        arrow_symbol = arrow_symbols[arrow_direction]
608        
609        # checks whether the encoding has already been defined
610        if not hasattr(self.chart, 'encoding')or not hasattr(self.chart.encoding, 'x'):
611            # If encoding is not defined use of default values
612            x_type = 'Q'
613            y_type = 'Q'
614        else:
615            # If encoding is defined, we extract the data types for the x and y axes
616            x_type = self.chart.encoding.x.shorthand.split(':')[-1]
617            y_type = self.chart.encoding.y.shorthand.split(':')[-1]
618
619        # Explanation of data type extraction:
620        # 1. We first check whether the encoding has been defined. This is important because
621        # the user may call this method before defining the encoding of the graph.
622        # (The encoding in Altair defines how the data is mapped to the visual properties of the graph).
623        # 2. If encoding is not defined, we use ‘Q’ (Quantitative) as the default data type
624        # for both axes. This allows the method to work even without a defined encoding.
625        # 3. If the encoding is defined, we proceed with the data type extraction as before:
626        # - self.chart.encoding.x.shorthand: accesses the shorthand of the x-axis
627        # .shorthand: In Altair, ‘shorthand’ is a concise notation for specifying the encoding.
628        # Example of shorthand: ‘column:Q’ where ‘column’ is the name of the data column
629        # and ‘Q’ is the data type (in this case, Quantitative).
630        # - split(‘:’)[-1]: splits the shorthand at ‘:’ and takes the last element (the data type)
631        # Example: If shorthand is ‘price:Q’, split(‘:’) will return [‘price’, ‘Q’].
632        # 4. The extracted data type will be one of:
633        # - ‘Q’: Quantitative (continuous numeric)
634        # - ‘O’: Ordinal (ordered categories)
635        # N': Nominal (unordered categories)
636        # T': Temporal (dates or times)
637        
638        # Important: This operation is essential to ensure that the annotations
639        # added to the graph are consistent with the original axis data types.
640        # Without this match, annotations may be positioned
641        # incorrectly or cause rendering errors.
642
643        # Create a DataFrame with a single point for the annotation
644        annotation_data = pd.DataFrame({'x': [x_point], 'y': [y_point]})
645        
646        # Internal function to create the correct encoding based on the data type
647        def create_encoding(field, dtype):
648            """
649            Creates the appropriate encoding for Altair based on the data type.
650            This function is crucial to ensure that the annotation is
651            consistent with the original chart data type.
652            """
653            
654            if dtype == 'O':  # Ordinal
655                return alt.X(field + ':O', title='') if field == 'x' else alt.Y(field + ':O', title='')
656            elif dtype == 'N':  # Nominal
657                return alt.X(field + ':N', title='') if field == 'x' else alt.Y(field + ':N', title='')
658            elif dtype == 'T':  # Temporal
659                return alt.X(field + ':T', title='') if field == 'x' else alt.Y(field + ':T', title='')
660            else:  # Quantity (default)
661                return alt.X(field + ':Q', title='') if field == 'x' else alt.Y(field + ':Q', title='')
662
663        # Initialises the list of annotation layers
664        layers = []
665
666        # Adds full stop if required
667        if show_point:
668            point_layer = alt.Chart(annotation_data).mark_point(
669                color=point_color,
670                size=point_size
671            ).encode(
672                x=create_encoding('x', x_type),
673                y=create_encoding('y', y_type)
674            )
675            layers.append(point_layer)
676
677        # Adds the arrow
678        # We use mark_text to draw the arrow using a Unicode character
679        arrow_layer = alt.Chart(annotation_data).mark_text(
680            text=arrow_symbol, 
681            fontSize=arrow_size,
682            dx=arrow_dx,  # Horizontal offset to position the arrow                   was 22
683            dy=arrow_dy,  # Vertical offset to position the arrow                     was -22
684            color=arrow_color
685        ).encode(
686            x=create_encoding('x', x_type),
687            y=create_encoding('y', y_type)
688        )
689        layers.append(arrow_layer)
690
691        # Adds text label
692        label_layer = alt.Chart(annotation_data).mark_text(
693            align='left',
694            baseline='top',
695            dx= label_dx,  # Horizontal offset to position text                     was 37
696            dy= label_dy,  # Vertical offset to position text                       was -37
697            fontSize=label_size,
698            color=label_color,
699            text=annotation_text
700        ).encode(
701            x=create_encoding('x', x_type),
702            y=create_encoding('y', y_type)
703        )
704        layers.append(label_layer)
705
706        # Combine all layers into a single annotation
707        annotation = alt.layer(*layers)
708
709        # Adds annotation to history layers
710        self.story_layers.append({
711            'type': 'annotation',
712            'chart': annotation
713        })
714
715        # Note on flexibility and robustness:
716        # This approach makes the add_annotation method more flexible,
717        # as it can automatically adapt to different types of graphs without
718        # requiring manual input on the axis data type. In addition, using
719        # the Altair shorthand, the code automatically adapts even if the user
720        # has specified the encoding in different ways (e.g., using alt.X(‘column:Q’)
721        # or alt.X(‘column’, type=‘quantitative’)).
722
723
724        return self  # Return self to allow method chaining
725    
726    
727    def add_line(self, value, orientation='horizontal', color='red', stroke_width=2, stroke_dash=[]):
728        """
729        Adds a reference line to the story.
730
731        Parameters:
732        - value: The position of the line (x or y value)
733        - orientation: 'horizontal' or 'vertical' (default: 'horizontal')
734        - color: Color of the line (default: 'red')
735        - stroke_width: Width of the line in pixels (default: 2)
736        - stroke_dash: Pattern for dashed line, e.g. [5,5] (default: [] for solid line)
737
738        Returns:
739        - self, to allow method chaining
740        """
741        
742        if orientation not in ['horizontal', 'vertical']:
743            raise ValueError("orientation must be either 'horizontal' or 'vertical'")
744
745        # Crea un DataFrame con un singolo valore
746        if orientation == 'horizontal':
747            data = pd.DataFrame({'y': [value]})
748            encoding = {'y': 'y:Q'}
749        else:  # vertical
750            data = pd.DataFrame({'x': [value]})
751            encoding = {'x': 'x:Q'}
752        
753        # definizione dei parametri della line
754        mark_params = {
755            'color': color,
756            'strokeWidth': stroke_width,
757        }
758        
759        # Viene aggiunto stokeDash solo se è specificato un pattern
760        if stroke_dash:
761            mark_params['strokeDash'] = stroke_dash
762
763        # Crea la linea
764        line = alt.Chart(data).mark_rule(
765            color=color,
766            strokeWidth=stroke_width,
767            strokeDash=stroke_dash
768        ).encode(
769            **encoding
770        )
771
772        # Aggiunge la linea ai layer della storia
773        self.story_layers.append({
774            'type': 'line',
775            'chart': line
776        })
777
778        return self
779    
780        
781
782    def em_to_px(self, em):
783        """
784        Converts a dimension from em to pixels.
785
786        This function is essential for maintaining visual consistency
787        between different textual elements and devices.
788
789        Parameters:
790        - em: Size in em
791
792        Returns:
793        - Equivalent size in pixels (integer)
794        """
795        return int(em * self.base_font_size)
796
797    def _get_position(self, position):
798        """
799        Calculates the x and y co-ordinates for positioning text elements in the graph.
800
801        This method is crucial for the correct positioning of various elements
802        narrative elements such as context, call-to-action and sources.
803
804        Parameters:
805        - position: String indicating the desired position (e.g. ‘left’, ‘right’, ‘top’, etc.).
806        
807        Returns:
808        - Tuple (x, y) representing the coordinates in pixels
809        """
810        # Dictionary mapping positions to coordinates (x, y)
811        # Co-ordinates are calculated from the size of the graph
812        positions = {
813            'left': (-100, self.chart.height / 2),  # 10 pixel from the left edge, centred vertically
814            'right': (self.chart.width + 20, self.chart.height / 2),  # 10 pixel from the right edge, centred vertically
815            'top': (self.chart.width / 2, 80),  # Centred horizontally, 80 pixels from above
816            'bottom': (self.chart.width / 2, self.chart.height + 40),  # Horizontally centred, 40 pixels from bottom
817            'center': (self.chart.width / 2, self.chart.height / 2),  # Graph Centre
818            'side-left': (-150, self.chart.height / 2),  # 10 pixels to the left of the border, centred vertically
819            'side-right': (self.chart.width + 50, self.chart.height / 2),  # 10 pixels to the right of the border, centred vertically
820        }
821        
822        # If the required position is not in the dictionary, use a default position
823        # In this case, horizontally centred and 20 pixels from the bottom
824        return positions.get(position, (self.chart.width / 2, self.chart.height - 20))
825
826    def create_title_layer(self, layer):
827        """
828        Creates the title layer (and subtitle if present).
829
830        This method is responsible for visually creating the main title
831        and the optional subtitle of the graphic.
832
833        Parameters:
834        - Layer: Dictionary containing the title information
835
836        Returns:
837        - Altair Chart object representing the title layer
838        """
839        title_chart = alt.Chart(self.chart.data).mark_text(
840            text=layer['title'],
841            fontSize=layer['title_font_size'],
842            fontWeight='bold',
843            align='center',
844            font=self.font,
845            color=layer['title_color']
846        ).encode(
847            x=alt.value(self.chart.width / 2 + layer.get('dx', 0)),  # Centre horizontally
848            y=alt.value(-50 + layer.get('dy', 0))  # Position 20 pixels from above
849        )
850        
851        if layer['subtitle']:
852            subtitle_chart = alt.Chart(self.chart.data).mark_text(
853                text=layer['subtitle'],
854                fontSize=layer['subtitle_font_size'],
855                align='center',
856                font=self.font,
857                color=layer['subtitle_color']
858            ).encode(
859                x=alt.value(self.chart.width / 2 + layer.get('s_dx', 0)),  # Centre horizontally
860                y=alt.value(-20 + layer.get('s_dy', 0))  # Position 50 pixels from the top (below the title)
861            )
862            return title_chart + subtitle_chart
863        return title_chart
864
865    def create_text_layer(self, layer):
866        """
867        Creates a generic text layer (context, next-steps, source).
868
869        This method is used to create text layers for various narrative purposes,
870        such as adding context, next-steps or data source information.
871
872        Parameters:
873        - Layer: Dictionary containing the text information to be added
874
875        Returns:
876        - Altair Chart object representing the text layer
877        """
878        x, y = self._get_position(layer['position'])
879        
880        # Apply offsets if they exist (for context layers)
881        if layer['type'] in ['context', 'source']:
882            x += layer.get('dx', 0)
883            y += layer.get('dy', 0)
884        
885        return alt.Chart(self.chart.data).mark_text(
886            text=layer['text'],
887            fontSize=layer.get('font_size', self.em_to_px(self.font_sizes[layer['type']])),
888            align='center',
889            baseline='middle',
890            font=self.font,
891            angle=270 if layer.get('vertical', False) else 0,  #  Rotate text if ‘vertical’ is True
892            color=layer['color']
893        ).encode(
894            x=alt.value(x),
895            y=alt.value(y)
896        )
897
898    def configure_view(self, *args, **kwargs):
899        """
900        Configure aspects of the graph view using Altair's configure_view method.
901
902        This method allows you to configure various aspects of the chart view, such as
903        the background colour, border style, internal spacing, etc.
904
905        Parameters:
906        *args, **kwargs: Arguments to pass to the Altair configure_view method.
907
908        stores the view configuration for application during rendering.
909        """
910        self.config['view'] = kwargs
911        return self
912
913
914    def render(self):
915        """
916        It renders all layers of the story in a single graphic.       
917        """
918        # Let's start with the basic graph
919        main_chart = self.chart
920        
921        # Create separate lists to place special graphics
922        top_charts = []
923        bottom_charts = []
924        left_charts = []
925        right_charts = []
926        overlay_charts = []
927        
928        # Organise the layers according to their position
929        for layer in self.story_layers:
930            if layer['type'] == 'special_cta':
931                # We take the position from the layer
932                if layer.get('position') == 'top':
933                    top_charts.append(layer['chart'])
934                elif layer.get('position') == 'bottom':
935                    bottom_charts.append(layer['chart'])
936                elif layer.get('position') == 'left':
937                    left_charts.append(layer['chart'])
938                elif layer.get('position') == 'right':
939                    right_charts.append(layer['chart'])
940            elif layer['type'] == 'title':
941                overlay_charts.append(self.create_title_layer(layer))
942            elif layer['type'] in ['context', 'cta', 'source']:
943                overlay_charts.append(self.create_text_layer(layer))
944            elif layer['type'] in ['shape', 'shape_label', 'annotation']:
945                overlay_charts.append(layer['chart'])
946            elif layer['type'] == 'line':
947                overlay_charts.append(layer['chart'])
948
949        # Overlaying the layers on the main graph
950        for overlay in overlay_charts:
951            main_chart += overlay
952
953        # Build the final layout
954        if left_charts:
955            main_chart = alt.hconcat(alt.vconcat(*left_charts), main_chart)
956        if right_charts:
957            main_chart = alt.hconcat(main_chart, alt.vconcat(*right_charts))
958        if top_charts:
959            main_chart = alt.vconcat(alt.hconcat(*top_charts), main_chart)
960        if bottom_charts:
961            main_chart = alt.vconcat(main_chart, alt.hconcat(*bottom_charts))
962
963        # Apply configurations
964        if 'view' in self.config:
965            main_chart = main_chart.configure_view(**self.config['view'])
966
967        return main_chart.resolve_axis(x='independent', y='independent')

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, call-to-action and annotations. It is designed to facilitate the creation of more engaging and informative data visualisations.

Story( data=None, width=600, height=400, font='Arial', base_font_size=16, **kwargs)
14    def __init__(self, data=None, width=600, height=400, font='Arial', base_font_size=16, **kwargs):
15        """
16        Initialise a Story object.
17
18        Parameters:
19        - data: DataFrame or URL for graph data  (default: None)
20        - width: Graph width in pixels (default: 600)
21        - height: Height of the graph in pixels (default: 400)
22        - font: Font to be used for all text elements (default: 'Arial')
23        - base_font_size: Basic font size in pixels (default: 16)
24        - **kwargs: Additional parameters to be passed to the constructor of alt.Chart
25        """
26        # Initialising the Altair Chart object with basic parameters
27        self.chart = alt.Chart(data, width=width, height=height, **kwargs)
28        self.font = font
29        self.base_font_size = base_font_size
30        self.story_layers = []  # List for storing history layers
31        
32        # Dictionaries for the sizes and colours of various text elements
33        # These values are multipliers for base_font_size
34        self.font_sizes = {
35            'title': 2,        # The title will be twice as big as the basic font
36            'subtitle': 1.5,   # The subtitle will be 1.5 times bigger
37            'context': 1.2,    # The context text will be 1.2 times larger
38            'nextstep': 1.3,   # Next-Step text will be 1.3 times larger
39            'source': 1        # The source text will have the basic size
40        }
41        # Predefined colours for various text elements
42        self.colors = {
43            'title': 'black',
44            'subtitle': 'gray',
45            'context': 'black',
46            'nextstep': 'black',
47            'source': 'gray'
48        }
49        self.config = {}

Initialise a Story object.

Parameters:

  • data: DataFrame or URL for graph data (default: None)
  • width: Graph width in pixels (default: 600)
  • height: Height of the graph in pixels (default: 400)
  • font: Font to be used for all text elements (default: 'Arial')
  • base_font_size: Basic font size in pixels (default: 16)
  • **kwargs: Additional parameters to be passed to the constructor of alt.Chart
chart
font
base_font_size
story_layers
font_sizes
colors
config
def add_title( self, title, subtitle=None, title_color=None, subtitle_color=None, title_font_size=None, subtitle_font_size=None, dx=None, dy=None, s_dx=None, s_dy=None):
 91    def add_title(self, title, subtitle=None, title_color=None, subtitle_color=None, title_font_size=None, subtitle_font_size=None, dx=None, dy=None, s_dx=None, s_dy=None):
 92        """
 93        Adds a title layer (and optional subtitle) to the story.
 94
 95        Parameters:
 96        - title: Main title text
 97        - subtitle: Subtitle text (optional)
 98        - title_color: Custom colour for the title (optional)
 99        - subtitle_color: Custom colour for the subtitle (optional)
100        - title_font_size: Custom font size for title in pixels (optional)
101        - subtitle_font_size: Custom font size for subtitle in pixels (optional)
102        - dx: Horizontal offset of the title in pixels (optional)
103        - dy: Vertical offset of the title in pixels (optional)
104        - s_dx: Horizontal offset of the subtitle in pixels (optional)
105        - s_dy: Vertical offset of the subtitle in pixels (optional)
106
107        returns:
108        - self, to allow method chaining
109        """
110        self.story_layers.append({
111            'type': 'title', 
112            'title': title, 
113            'subtitle': subtitle,
114            'title_color': title_color or self.colors['title'],
115            'subtitle_color': subtitle_color or self.colors['subtitle'],
116            'title_font_size': title_font_size or self.em_to_px(self.font_sizes['title']),
117            'subtitle_font_size': subtitle_font_size or self.em_to_px(self.font_sizes['subtitle']),
118            'dx': dx or 0,
119            'dy': dy or 0,
120            's_dx': s_dx or 0,
121            's_dy': s_dy or 0
122        })
123        return self

Adds a title layer (and optional subtitle) to the story.

Parameters:

  • title: Main title text
  • subtitle: Subtitle text (optional)
  • title_color: Custom colour for the title (optional)
  • subtitle_color: Custom colour for the subtitle (optional)
  • title_font_size: Custom font size for title in pixels (optional)
  • subtitle_font_size: Custom font size for subtitle in pixels (optional)
  • dx: Horizontal offset of the title in pixels (optional)
  • dy: Vertical offset of the title in pixels (optional)
  • s_dx: Horizontal offset of the subtitle in pixels (optional)
  • s_dy: Vertical offset of the subtitle in pixels (optional)

returns:

  • self, to allow method chaining
def add_context(self, text, position='left', color=None, dx=0, dy=0, font_size=None):
125    def add_context(self, text, position='left', color=None, dx=0, dy=0, font_size=None):
126        """
127        Adds a context layer to the story.
128
129        Parameters:
130        - text: The context text to be added
131        - position: The position of the text (default: ‘left’)
132        - colour: Custom text colour (optional)
133        - dx: Horizontal offset in pixels from the base position (default: 0)
134        - dy: Vertical offset in pixels from the base position (default: 0)
135        - font_size: Custom font size in pixels (optional)
136
137        returns:
138        - self, to allow method chaining
139        """
140        self.story_layers.append({
141            'type': 'context', 
142            'text': text, 
143            'position': position,
144            'color': color or self.colors['context'],
145            'dx': dx,
146            'dy': dy,
147            'font_size' : font_size or self.em_to_px(self.font_sizes['context'])
148            
149        })
150        return self

Adds a context layer to the story.

Parameters:

  • text: The context text to be added
  • position: The position of the text (default: ‘left’)
  • colour: Custom text colour (optional)
  • dx: Horizontal offset in pixels from the base position (default: 0)
  • dy: Vertical offset in pixels from the base position (default: 0)
  • font_size: Custom font size in pixels (optional)

returns:

  • self, to allow method chaining
def add_next_steps( self, text=None, position='bottom', mode=None, title='What can we do next?', color=None, font_family='Arial', font_size=14, text_color=None, opacity=None, width=None, height=None, chart_width=None, chart_height=None, space=None, texts=None, url=None, button_width=120, button_height=40, button_color='#80C11E', button_opacity=0.2, button_corner_radius=5, button_text_color='black', button_font_family=None, button_font_size=None, line_steps_rect_width=10, line_steps_rect_height=10, line_steps_space=5, line_steps_chart_width=700, line_steps_chart_height=100, line_steps_color='#80C11E', line_steps_opacity=0.2, line_steps_text_color='black', line_steps_font_family=None, line_steps_font_size=None, stair_steps_rect_width=10, stair_steps_rect_height=3, stair_steps_chart_width=700, stair_steps_chart_height=300, stair_steps_color='#80C11E', stair_steps_opacity=0.2, stair_steps_text_color='black', stair_steps_font_family=None, stair_steps_font_size=None, title_color='black', title_font_family=None, title_font_size=None, **kwargs):
153    def add_next_steps(self, 
154        #Basic parameters
155        text=None,
156        position='bottom',
157        mode=None,
158        title="What can we do next?",        
159    
160        # General styling parameters (used as fallback)
161        color=None,             # general color of elements
162        font_family='Arial',
163        font_size=14,
164        text_color=None,
165        opacity=None,
166        width=None,
167        height=None,
168        chart_width=None,
169        chart_height=None,
170        space=None,
171        
172        # List of texts for steps
173        texts=None,
174        
175        # Parameters per button
176        url=None,
177        button_width=120,
178        button_height=40,
179        button_color='#80C11E',
180        button_opacity=0.2,
181        button_corner_radius=5,
182        button_text_color='black',
183        button_font_family=None,    # If None, use font_family
184        button_font_size=None,      # If None, use font_size
185        
186        # Parameters for line_steps
187        line_steps_rect_width=10,
188        line_steps_rect_height=10,
189        line_steps_space=5,
190        line_steps_chart_width=700,
191        line_steps_chart_height=100,
192        line_steps_color='#80C11E',
193        line_steps_opacity=0.2,
194        line_steps_text_color='black',
195        line_steps_font_family=None,  # If None, use font_family
196        line_steps_font_size=None,    # If None, use font_size
197        
198        # Parameters for stair_steps
199        stair_steps_rect_width=10,
200        stair_steps_rect_height=3,
201        stair_steps_chart_width=700,
202        stair_steps_chart_height=300,
203        stair_steps_color='#80C11E',
204        stair_steps_opacity=0.2,
205        stair_steps_text_color='black',
206        stair_steps_font_family=None,  # If None, use font_family
207        stair_steps_font_size=None,    # If None, use font_size
208        
209        # Title Parameters
210        title_color='black',
211        title_font_family=None,     # If None, use font_family
212        title_font_size=None,       # If None, use font_size * 1.4
213        **kwargs):
214
215
216        """
217        Adds a next-step element to the visualisation with multiple customisation options.
218        
219        Parameters
220        ---------
221        text : str                                              Text for the basic version or for the button
222        position : str, default=‘bottom’                        Position of the element (‘bottom’, ‘top’, ‘left’, ‘right’)
223        type : str, optional                                    Display type (‘line_steps’, ‘button’, ‘stair_steps’)
224        title : str, default="What can we do next?’             Title for special visualisations
225        colour : str, optional                                  Text colour for the basic version
226        font_family : str, default=‘Arial’                      Default font
227        font_size : int, default=14                             Default font size
228        texts : list of str                                     List of texts for line_steps and stair_steps
229        url : str                                               URL for the button
230        
231        Parameters for Button
232        ------------------
233        button_width : int, default=120                         Button width
234        button_height : int, default=40                         Height of the button
235        button_color : str, default=‘#80C11E’                   Background colour of the button
236        button_opacity : float, default=0.2                     Opacity of the button
237        button_corner_radius : int, default=5                   Corner radius of the button
238        button_text_color : str, default=‘black’                Button text colour
239        button_font_family : str, optional                      Font specific to the button
240        button_font_size : int, optional                        Font size for the button
241        
242        Parameters for Line Steps
243        ----------------------
244        line_steps_rect_width : int, default=10                 Rectangle width
245        line_steps_rect_height : int, default=10                Height of rectangles
246        line_steps_space : int, default=5                       Space between rectangles
247        line_steps_chart_width : int, default=700               Total width of the chart
248        line_steps_chart_height : int, default=100              Total chart height
249        line_steps_color : str, default=‘#80C11E’               Colour of rectangles
250        line_steps_opacity : float, default=0.2                 Opacity of rectangles
251        line_steps_text_colour : str, default=‘black’           Text colour
252        line_steps_font_family : str, optional                  Font specific to line steps
253        line_steps_font_size : int, optional                    Font size for line steps
254        
255        Parameters for Stair Steps
256        -----------------------
257        stair_steps_rect_width : int, default=10                Width of steps
258        stair_steps_rect_height : int, default=3                Height of steps
259        stair_steps_chart_width : int, default=700              Total width of the chart
260        stair_steps_chart_height : int, default=300             Total height of the chart
261        stair_steps_color : str, default=‘#80C11E’              Colour of the steps
262        stair_steps_opacity : float, default=0.2                Opacity of the steps
263        stair_steps_text_colour : str, default=‘black’          Text colour
264        stair_steps_font_family : str,                          Font specific to stair steps
265        stair_steps_font_size : int, optional                   Font size for stair steps
266        
267        Title parameters
268        ----------------------
269        title_color : str, default=‘black’                      Title colour
270        title_font_family : str, optional                       Specific font for the title
271        title_font_size : int, optional                         Font size for the title
272        
273        Returns
274        -------
275        self : Story object                                     The current instance for method chaining
276        """
277 
278 
279        # Apply general parameters to specific parameters if not set
280        # Colors and styling
281        button_color = button_color or color
282        button_opacity = button_opacity or opacity
283        button_text_color = button_text_color or text_color
284        button_width = button_width or width
285        button_height = button_height or height
286        
287        line_steps_color = line_steps_color or color
288        line_steps_opacity = line_steps_opacity or opacity
289        line_steps_text_color = line_steps_text_color or text_color
290        line_steps_rect_width = line_steps_rect_width or width
291        line_steps_rect_height = line_steps_rect_height or height
292        line_steps_space = line_steps_space or space
293        line_steps_chart_width = line_steps_chart_width or chart_width
294        line_steps_chart_height = line_steps_chart_height or chart_height
295        
296        stair_steps_color = stair_steps_color or color
297        stair_steps_opacity = stair_steps_opacity or opacity
298        stair_steps_text_color = stair_steps_text_color or text_color
299        stair_steps_rect_width = stair_steps_rect_width or width
300        stair_steps_rect_height = stair_steps_rect_height or height
301        stair_steps_chart_width = stair_steps_chart_width or chart_width
302        stair_steps_chart_height = stair_steps_chart_height or chart_height
303        
304        title_color = title_color or text_color       
305
306    
307      
308        # Basic version (text only)
309        if mode is None:
310            if text is None:
311                raise ValueError("The parameter ‘text’ is required for the basic version")
312            self.story_layers.append({
313                'type': 'cta', 
314                'text': text, 
315                'position': position,
316                'color': color or self.colors['cta']
317            })
318            return self
319
320        # Mode validation
321        valid_types = ['line_steps', 'button', 'stair_steps']
322        if mode not in valid_types:
323            raise ValueError(f"Invalid type. Use one of: {', '.join(valid_types)}")
324
325        # Chart creation by mode
326        if mode == 'button':
327            if text is None:
328                raise ValueError("The parameter ‘text’ is required for the button type")
329            if url is None:
330                raise ValueError("The ‘url’ parameter is required for the button type")
331            
332            # Button chart creation
333            df = pd.DataFrame([{
334                'text': text,
335                'url': url,
336                'x': 0,
337                'y': 0
338            }])
339            
340            base = alt.Chart(df).encode(
341                x=alt.X('x:Q', axis=None),
342                y=alt.Y('y:Q', axis=None)
343            ).properties(
344                width=button_width,
345                height=button_height
346            )
347            
348            button_bg = base.mark_rect(
349                color=button_color,
350                opacity=button_opacity,
351                cornerRadius=button_corner_radius,
352                width=button_width,
353                height=button_height
354            ).encode(
355                href='url:N'
356            )
357            
358            button_text = base.mark_text(
359                fontSize=button_font_size or font_size,
360                font=button_font_family or font_family,
361                align='center',
362                baseline='middle',
363                color=button_text_color
364            ).encode(
365                text='text'
366            )
367            
368            chart = alt.layer(button_bg, button_text)
369            
370        elif mode == 'line_steps':
371            if not texts or not isinstance(texts, list):
372                raise ValueError("It is necessary to provide a list of texts for line_steps")
373            if len(texts) > 5:
374                raise ValueError("Maximum number of steps is 5")
375            if len(texts) < 1:
376                raise ValueError("Must provide at least one step")
377                
378            # Create DataFrame for rectangles and text
379            N = len(texts)
380            x = [i*(line_steps_rect_width+line_steps_space) for i in range(N)]
381            y = [0 for _ in range(N)]
382            x2 = [(i+1)*line_steps_rect_width+i*line_steps_space for i in range(N)]
383            y2 = [line_steps_rect_height for _ in range(N)]
384            
385            df_rect = pd.DataFrame({   
386                'x': x, 'y': y, 'x2': x2, 'y2': y2, 'text': texts
387            })
388            
389            # Create rectangles
390            rect = alt.Chart(df_rect).mark_rect(
391                color=line_steps_color,
392                opacity=line_steps_opacity
393            ).encode(
394                x=alt.X('x:Q', axis=None),
395                y=alt.Y('y:Q', axis=None),
396                x2='x2:Q',
397                y2='y2:Q'
398            ).properties(
399                width=line_steps_chart_width,
400                height=line_steps_chart_height
401            )
402            
403            # Add text labels
404            text = alt.Chart(df_rect).mark_text(
405                fontSize=line_steps_font_size or font_size,
406                font=line_steps_font_family or font_family,
407                align='left',
408                dx=10,
409                lineHeight=18,
410                color=line_steps_text_color
411            ).encode(
412                text='text:N',
413                x=alt.X('x:Q', axis=None),
414                y=alt.Y('y_half:Q', axis=None),
415            ).transform_calculate(
416                y_half='datum.y2/2'
417            )
418            
419            if N > 1:
420                df_line = pd.DataFrame({   
421                    'x': [line_steps_rect_width*i+line_steps_space*(i-1) for i in range(1,N)],
422                    'y': [line_steps_rect_height/2 for _ in range(N-1)],
423                    'x2': [(line_steps_rect_width+line_steps_space)*i for i in range(1,N)],
424                    'y2': [line_steps_rect_height/2 for _ in range(N-1)]
425                })
426                
427                line = alt.Chart(df_line).mark_line(
428                    point=True,
429                    strokeWidth=2
430                ).encode(
431                    x=alt.X('x:Q', axis=None),
432                    y=alt.Y('y:Q', axis=None),
433                    x2='x2:Q',
434                    y2='y2:Q'
435                )
436                
437                chart = alt.layer(rect, line, text)
438            else:
439                chart = alt.layer(rect, text)
440                
441        elif mode == 'stair_steps':
442            if not texts or not isinstance(texts, list):
443                raise ValueError("You must provide a list of texts for stair_steps")
444            if len(texts) > 5:
445                raise ValueError("Maximum number of steps is 5")
446            if len(texts) < 1:
447                raise ValueError("Must provide at least one step")
448                
449            # Create DataFrame for rectangles and text
450            N = len(texts)
451            x = [i*stair_steps_rect_width for i in range(N)]
452            y = [i*stair_steps_rect_height for i in range(N)]
453            x2 = [(i+1)*stair_steps_rect_width for i in range(N)]
454            y2 = [(i+1)*stair_steps_rect_height for i in range(N)]
455            
456            df_rect = pd.DataFrame({   
457                'x': x, 'y': y, 'x2': x2, 'y2': y2, 'text': texts
458            })
459            
460            # Create rectangles
461            rect = alt.Chart(df_rect).mark_rect(
462                color=stair_steps_color,
463                opacity=stair_steps_opacity
464            ).encode(
465                x=alt.X('x:Q', axis=None),
466                y=alt.Y('y:Q', axis=None, scale=alt.Scale(domain=[0, N*stair_steps_rect_height])),
467                x2='x2:Q',
468                y2='y2:Q'
469            ).properties(
470                width=stair_steps_chart_width,
471                height=stair_steps_chart_height
472            )
473            
474            # Add text labels
475            text = alt.Chart(df_rect).mark_text(
476                fontSize=stair_steps_font_size or font_size,
477                font=stair_steps_font_family or font_family,
478                align='left',
479                dx=10,
480                dy=0,
481                color=stair_steps_text_color
482            ).encode(
483                text=alt.Text('text'),
484                x=alt.X('x:Q', axis=None),
485                y=alt.Y('y_mid:Q', axis=None),
486            ).transform_calculate(
487                y_mid='(datum.y + datum.y2)/2'
488            )
489            
490            if N > 1:
491                line_data = []
492                for i in range(N-1):
493                    line_data.append({
494                        'x': x2[i],
495                        'y': y2[i],
496                        'x2': x[i+1],
497                        'y2': y[i+1]
498                    })
499                
500                df_line = pd.DataFrame(line_data)
501                
502                line = alt.Chart(df_line).mark_line(
503                    point=True,
504                    strokeWidth=2
505                ).encode(
506                    x=alt.X('x:Q', axis=None),
507                    y=alt.Y('y:Q', axis=None),
508                    x2='x2:Q',
509                    y2='y2:Q'
510                )
511                
512                chart = alt.layer(rect, line, text)
513            else:
514                chart = alt.layer(rect, text)
515
516        # Addition of title with customisable font
517        if title:
518            chart = chart.properties(
519                title=alt.TitleParams(
520                    text=[title],
521                    fontSize=title_font_size or (font_size * 1.4),
522                    font=title_font_family or font_family,
523                    color=title_color,
524                    offset=10
525                )
526            )
527
528        # Addition to layer
529        self.story_layers.append({
530            'type': 'special_cta',
531            'chart': chart,
532            'position': position
533        })
534        
535        return self

Adds a next-step element to the visualisation with multiple customisation options.

Parameters

text : str Text for the basic version or for the button position : str, default=‘bottom’ Position of the element (‘bottom’, ‘top’, ‘left’, ‘right’) type : str, optional Display type (‘line_steps’, ‘button’, ‘stair_steps’) title : str, default="What can we do next?’ Title for special visualisations colour : str, optional Text colour for the basic version font_family : str, default=‘Arial’ Default font font_size : int, default=14 Default font size texts : list of str List of texts for line_steps and stair_steps url : str URL for the button

Parameters for Button

button_width : int, default=120 Button width button_height : int, default=40 Height of the button button_color : str, default=‘#80C11E’ Background colour of the button button_opacity : float, default=0.2 Opacity of the button button_corner_radius : int, default=5 Corner radius of the button button_text_color : str, default=‘black’ Button text colour button_font_family : str, optional Font specific to the button button_font_size : int, optional Font size for the button

Parameters for Line Steps

line_steps_rect_width : int, default=10 Rectangle width line_steps_rect_height : int, default=10 Height of rectangles line_steps_space : int, default=5 Space between rectangles line_steps_chart_width : int, default=700 Total width of the chart line_steps_chart_height : int, default=100 Total chart height line_steps_color : str, default=‘#80C11E’ Colour of rectangles line_steps_opacity : float, default=0.2 Opacity of rectangles line_steps_text_colour : str, default=‘black’ Text colour line_steps_font_family : str, optional Font specific to line steps line_steps_font_size : int, optional Font size for line steps

Parameters for Stair Steps

stair_steps_rect_width : int, default=10 Width of steps stair_steps_rect_height : int, default=3 Height of steps stair_steps_chart_width : int, default=700 Total width of the chart stair_steps_chart_height : int, default=300 Total height of the chart stair_steps_color : str, default=‘#80C11E’ Colour of the steps stair_steps_opacity : float, default=0.2 Opacity of the steps stair_steps_text_colour : str, default=‘black’ Text colour stair_steps_font_family : str, Font specific to stair steps stair_steps_font_size : int, optional Font size for stair steps

Title parameters

title_color : str, default=‘black’ Title colour title_font_family : str, optional Specific font for the title title_font_size : int, optional Font size for the title

Returns

self : Story object The current instance for method chaining

def add_source( self, text, position='bottom', vertical=False, color=None, dx=None, dy=None, font_size=None):
537    def add_source(self, text, position='bottom', vertical=False, color=None, dx=None, dy=None, font_size=None):
538        """
539        Add a source layer to the story.
540
541        Parameters:
542        - text: The source text
543        - position: The position of the text (default: 'bottom')
544        - vertical: If True, the text will be rotated 90 degrees (default: False)
545        - color: Custom text color (optional)
546        - dx: Horizontal movement of the text (optional)
547        - dy: Vertical movement of the text (optional)
548        - font_size: Custom font size (optional)
549
550        Ritorna:
551        - self, to allow the method chaining
552        """
553        self.story_layers.append({
554            'type': 'source', 
555            'text': text, 
556            'position': position, 
557            'vertical': vertical,
558            'color': color or self.colors['source'],
559            'dx': dx or 0,
560            'dy': dy or 0,
561            'font_size': font_size or self.em_to_px(self.font_sizes['source'])
562            
563        })
564        return self

Add a source layer to the story.

Parameters:

  • text: The source text
  • position: The position of the text (default: 'bottom')
  • vertical: If True, the text will be rotated 90 degrees (default: False)
  • color: Custom text color (optional)
  • dx: Horizontal movement of the text (optional)
  • dy: Vertical movement of the text (optional)
  • font_size: Custom font size (optional)

Ritorna:

  • self, to allow the method chaining
def add_annotation( self, x_point, y_point, annotation_text='Point of interest', arrow_direction='right', arrow_color='blue', arrow_size=40, label_color='black', label_size=12, show_point=True, point_color='red', point_size=60, arrow_dx=0, arrow_dy=-45, label_dx=37, label_dy=-37):
566    def add_annotation(self, x_point, y_point, annotation_text="Point of interest", 
567                                     arrow_direction='right', arrow_color='blue', arrow_size=40,
568                                     label_color='black', label_size=12,
569                                     show_point=True, point_color='red', point_size=60,
570                                     arrow_dx=0, arrow_dy=-45,
571                                     label_dx=37, label_dy=-37):
572        """
573        Create an arrow annotation on the graph.
574        
575        This method is essential for highlighting specific points in the graph and adding
576        contextual explanations. It is particularly useful for data narration, allowing
577        to guide the observer's attention to relevant aspects of the visualisation.
578
579        Parameters:
580        - x_point, y_point: Coordinates of the point to be annotated
581        - annotation_text: Text of the annotation (default: ‘Point of interest’)
582        - arrow_direction: Direction of the arrow (default: ‘right’)
583        - arrow_color, arrow_size: Arrow colour and size
584        - label_colour, label_size: Colour and size of the annotation text
585        - show_point: If True, shows a point at the annotation location
586        - point_color, point_size: Colour and size of the point
587        - arrow_dx, arrow_dy: Distances in pixels to be added to the arrow position (default:0, -45)
588        - label_dx, label_dy: Distances in pixels to be added to the label position (default:37, -37)
589
590        returns:
591        - self, to allow method chaining
592        """
593        
594        # Dictionary that maps arrow directions to corresponding Unicode symbols
595        arrow_symbols = {
596            'left': '←', 'right': '→', 'up': '↑', 'down': '↓',
597            'upleft': '↖', 'upright': '↗', 'downleft': '↙', 'downright': '↘',
598            'leftup': '↰', 'leftdown': '↲', 'rightup': '↱', 'rightdown': '↳',
599            'upleftcurve': '↺', 'uprightcurve': '↻'
600        }
601
602        # Check that the direction of the arrow is valid
603        if arrow_direction not in arrow_symbols:
604            raise ValueError(f"Invalid arrow direction. Use one of: {', '.join(arrow_symbols.keys())}")
605
606        # Select the appropriate arrow symbol
607        arrow_symbol = arrow_symbols[arrow_direction]
608        
609        # checks whether the encoding has already been defined
610        if not hasattr(self.chart, 'encoding')or not hasattr(self.chart.encoding, 'x'):
611            # If encoding is not defined use of default values
612            x_type = 'Q'
613            y_type = 'Q'
614        else:
615            # If encoding is defined, we extract the data types for the x and y axes
616            x_type = self.chart.encoding.x.shorthand.split(':')[-1]
617            y_type = self.chart.encoding.y.shorthand.split(':')[-1]
618
619        # Explanation of data type extraction:
620        # 1. We first check whether the encoding has been defined. This is important because
621        # the user may call this method before defining the encoding of the graph.
622        # (The encoding in Altair defines how the data is mapped to the visual properties of the graph).
623        # 2. If encoding is not defined, we use ‘Q’ (Quantitative) as the default data type
624        # for both axes. This allows the method to work even without a defined encoding.
625        # 3. If the encoding is defined, we proceed with the data type extraction as before:
626        # - self.chart.encoding.x.shorthand: accesses the shorthand of the x-axis
627        # .shorthand: In Altair, ‘shorthand’ is a concise notation for specifying the encoding.
628        # Example of shorthand: ‘column:Q’ where ‘column’ is the name of the data column
629        # and ‘Q’ is the data type (in this case, Quantitative).
630        # - split(‘:’)[-1]: splits the shorthand at ‘:’ and takes the last element (the data type)
631        # Example: If shorthand is ‘price:Q’, split(‘:’) will return [‘price’, ‘Q’].
632        # 4. The extracted data type will be one of:
633        # - ‘Q’: Quantitative (continuous numeric)
634        # - ‘O’: Ordinal (ordered categories)
635        # N': Nominal (unordered categories)
636        # T': Temporal (dates or times)
637        
638        # Important: This operation is essential to ensure that the annotations
639        # added to the graph are consistent with the original axis data types.
640        # Without this match, annotations may be positioned
641        # incorrectly or cause rendering errors.
642
643        # Create a DataFrame with a single point for the annotation
644        annotation_data = pd.DataFrame({'x': [x_point], 'y': [y_point]})
645        
646        # Internal function to create the correct encoding based on the data type
647        def create_encoding(field, dtype):
648            """
649            Creates the appropriate encoding for Altair based on the data type.
650            This function is crucial to ensure that the annotation is
651            consistent with the original chart data type.
652            """
653            
654            if dtype == 'O':  # Ordinal
655                return alt.X(field + ':O', title='') if field == 'x' else alt.Y(field + ':O', title='')
656            elif dtype == 'N':  # Nominal
657                return alt.X(field + ':N', title='') if field == 'x' else alt.Y(field + ':N', title='')
658            elif dtype == 'T':  # Temporal
659                return alt.X(field + ':T', title='') if field == 'x' else alt.Y(field + ':T', title='')
660            else:  # Quantity (default)
661                return alt.X(field + ':Q', title='') if field == 'x' else alt.Y(field + ':Q', title='')
662
663        # Initialises the list of annotation layers
664        layers = []
665
666        # Adds full stop if required
667        if show_point:
668            point_layer = alt.Chart(annotation_data).mark_point(
669                color=point_color,
670                size=point_size
671            ).encode(
672                x=create_encoding('x', x_type),
673                y=create_encoding('y', y_type)
674            )
675            layers.append(point_layer)
676
677        # Adds the arrow
678        # We use mark_text to draw the arrow using a Unicode character
679        arrow_layer = alt.Chart(annotation_data).mark_text(
680            text=arrow_symbol, 
681            fontSize=arrow_size,
682            dx=arrow_dx,  # Horizontal offset to position the arrow                   was 22
683            dy=arrow_dy,  # Vertical offset to position the arrow                     was -22
684            color=arrow_color
685        ).encode(
686            x=create_encoding('x', x_type),
687            y=create_encoding('y', y_type)
688        )
689        layers.append(arrow_layer)
690
691        # Adds text label
692        label_layer = alt.Chart(annotation_data).mark_text(
693            align='left',
694            baseline='top',
695            dx= label_dx,  # Horizontal offset to position text                     was 37
696            dy= label_dy,  # Vertical offset to position text                       was -37
697            fontSize=label_size,
698            color=label_color,
699            text=annotation_text
700        ).encode(
701            x=create_encoding('x', x_type),
702            y=create_encoding('y', y_type)
703        )
704        layers.append(label_layer)
705
706        # Combine all layers into a single annotation
707        annotation = alt.layer(*layers)
708
709        # Adds annotation to history layers
710        self.story_layers.append({
711            'type': 'annotation',
712            'chart': annotation
713        })
714
715        # Note on flexibility and robustness:
716        # This approach makes the add_annotation method more flexible,
717        # as it can automatically adapt to different types of graphs without
718        # requiring manual input on the axis data type. In addition, using
719        # the Altair shorthand, the code automatically adapts even if the user
720        # has specified the encoding in different ways (e.g., using alt.X(‘column:Q’)
721        # or alt.X(‘column’, type=‘quantitative’)).
722
723
724        return self  # Return self to allow method chaining

Create an arrow annotation on the graph.

This method is essential for highlighting specific points in the graph and adding contextual explanations. It is particularly useful for data narration, allowing to guide the observer's attention to relevant aspects of the visualisation.

Parameters:

  • x_point, y_point: Coordinates of the point to be annotated
  • annotation_text: Text of the annotation (default: ‘Point of interest’)
  • arrow_direction: Direction of the arrow (default: ‘right’)
  • arrow_color, arrow_size: Arrow colour and size
  • label_colour, label_size: Colour and size of the annotation text
  • show_point: If True, shows a point at the annotation location
  • point_color, point_size: Colour and size of the point
  • arrow_dx, arrow_dy: Distances in pixels to be added to the arrow position (default:0, -45)
  • label_dx, label_dy: Distances in pixels to be added to the label position (default:37, -37)

returns:

  • self, to allow method chaining
def add_line( self, value, orientation='horizontal', color='red', stroke_width=2, stroke_dash=[]):
727    def add_line(self, value, orientation='horizontal', color='red', stroke_width=2, stroke_dash=[]):
728        """
729        Adds a reference line to the story.
730
731        Parameters:
732        - value: The position of the line (x or y value)
733        - orientation: 'horizontal' or 'vertical' (default: 'horizontal')
734        - color: Color of the line (default: 'red')
735        - stroke_width: Width of the line in pixels (default: 2)
736        - stroke_dash: Pattern for dashed line, e.g. [5,5] (default: [] for solid line)
737
738        Returns:
739        - self, to allow method chaining
740        """
741        
742        if orientation not in ['horizontal', 'vertical']:
743            raise ValueError("orientation must be either 'horizontal' or 'vertical'")
744
745        # Crea un DataFrame con un singolo valore
746        if orientation == 'horizontal':
747            data = pd.DataFrame({'y': [value]})
748            encoding = {'y': 'y:Q'}
749        else:  # vertical
750            data = pd.DataFrame({'x': [value]})
751            encoding = {'x': 'x:Q'}
752        
753        # definizione dei parametri della line
754        mark_params = {
755            'color': color,
756            'strokeWidth': stroke_width,
757        }
758        
759        # Viene aggiunto stokeDash solo se è specificato un pattern
760        if stroke_dash:
761            mark_params['strokeDash'] = stroke_dash
762
763        # Crea la linea
764        line = alt.Chart(data).mark_rule(
765            color=color,
766            strokeWidth=stroke_width,
767            strokeDash=stroke_dash
768        ).encode(
769            **encoding
770        )
771
772        # Aggiunge la linea ai layer della storia
773        self.story_layers.append({
774            'type': 'line',
775            'chart': line
776        })
777
778        return self

Adds a reference line to the story.

Parameters:

  • value: The position of the line (x or y value)
  • orientation: 'horizontal' or 'vertical' (default: 'horizontal')
  • color: Color of the line (default: 'red')
  • stroke_width: Width of the line in pixels (default: 2)
  • stroke_dash: Pattern for dashed line, e.g. [5,5] (default: [] for solid line)

Returns:

  • self, to allow method chaining
def em_to_px(self, em):
782    def em_to_px(self, em):
783        """
784        Converts a dimension from em to pixels.
785
786        This function is essential for maintaining visual consistency
787        between different textual elements and devices.
788
789        Parameters:
790        - em: Size in em
791
792        Returns:
793        - Equivalent size in pixels (integer)
794        """
795        return int(em * self.base_font_size)

Converts a dimension from em to pixels.

This function is essential for maintaining visual consistency between different textual elements and devices.

Parameters:

  • em: Size in em

Returns:

  • Equivalent size in pixels (integer)
def create_title_layer(self, layer):
826    def create_title_layer(self, layer):
827        """
828        Creates the title layer (and subtitle if present).
829
830        This method is responsible for visually creating the main title
831        and the optional subtitle of the graphic.
832
833        Parameters:
834        - Layer: Dictionary containing the title information
835
836        Returns:
837        - Altair Chart object representing the title layer
838        """
839        title_chart = alt.Chart(self.chart.data).mark_text(
840            text=layer['title'],
841            fontSize=layer['title_font_size'],
842            fontWeight='bold',
843            align='center',
844            font=self.font,
845            color=layer['title_color']
846        ).encode(
847            x=alt.value(self.chart.width / 2 + layer.get('dx', 0)),  # Centre horizontally
848            y=alt.value(-50 + layer.get('dy', 0))  # Position 20 pixels from above
849        )
850        
851        if layer['subtitle']:
852            subtitle_chart = alt.Chart(self.chart.data).mark_text(
853                text=layer['subtitle'],
854                fontSize=layer['subtitle_font_size'],
855                align='center',
856                font=self.font,
857                color=layer['subtitle_color']
858            ).encode(
859                x=alt.value(self.chart.width / 2 + layer.get('s_dx', 0)),  # Centre horizontally
860                y=alt.value(-20 + layer.get('s_dy', 0))  # Position 50 pixels from the top (below the title)
861            )
862            return title_chart + subtitle_chart
863        return title_chart

Creates the title layer (and subtitle if present).

This method is responsible for visually creating the main title and the optional subtitle of the graphic.

Parameters:

  • Layer: Dictionary containing the title information

Returns:

  • Altair Chart object representing the title layer
def create_text_layer(self, layer):
865    def create_text_layer(self, layer):
866        """
867        Creates a generic text layer (context, next-steps, source).
868
869        This method is used to create text layers for various narrative purposes,
870        such as adding context, next-steps or data source information.
871
872        Parameters:
873        - Layer: Dictionary containing the text information to be added
874
875        Returns:
876        - Altair Chart object representing the text layer
877        """
878        x, y = self._get_position(layer['position'])
879        
880        # Apply offsets if they exist (for context layers)
881        if layer['type'] in ['context', 'source']:
882            x += layer.get('dx', 0)
883            y += layer.get('dy', 0)
884        
885        return alt.Chart(self.chart.data).mark_text(
886            text=layer['text'],
887            fontSize=layer.get('font_size', self.em_to_px(self.font_sizes[layer['type']])),
888            align='center',
889            baseline='middle',
890            font=self.font,
891            angle=270 if layer.get('vertical', False) else 0,  #  Rotate text if ‘vertical’ is True
892            color=layer['color']
893        ).encode(
894            x=alt.value(x),
895            y=alt.value(y)
896        )

Creates a generic text layer (context, next-steps, source).

This method is used to create text layers for various narrative purposes, such as adding context, next-steps or data source information.

Parameters:

  • Layer: Dictionary containing the text information to be added

Returns:

  • Altair Chart object representing the text layer
def configure_view(self, *args, **kwargs):
898    def configure_view(self, *args, **kwargs):
899        """
900        Configure aspects of the graph view using Altair's configure_view method.
901
902        This method allows you to configure various aspects of the chart view, such as
903        the background colour, border style, internal spacing, etc.
904
905        Parameters:
906        *args, **kwargs: Arguments to pass to the Altair configure_view method.
907
908        stores the view configuration for application during rendering.
909        """
910        self.config['view'] = kwargs
911        return self

Configure aspects of the graph view using Altair's configure_view method.

This method allows you to configure various aspects of the chart view, such as the background colour, border style, internal spacing, etc.

Parameters: args, *kwargs: Arguments to pass to the Altair configure_view method.

stores the view configuration for application during rendering.

def render(self):
914    def render(self):
915        """
916        It renders all layers of the story in a single graphic.       
917        """
918        # Let's start with the basic graph
919        main_chart = self.chart
920        
921        # Create separate lists to place special graphics
922        top_charts = []
923        bottom_charts = []
924        left_charts = []
925        right_charts = []
926        overlay_charts = []
927        
928        # Organise the layers according to their position
929        for layer in self.story_layers:
930            if layer['type'] == 'special_cta':
931                # We take the position from the layer
932                if layer.get('position') == 'top':
933                    top_charts.append(layer['chart'])
934                elif layer.get('position') == 'bottom':
935                    bottom_charts.append(layer['chart'])
936                elif layer.get('position') == 'left':
937                    left_charts.append(layer['chart'])
938                elif layer.get('position') == 'right':
939                    right_charts.append(layer['chart'])
940            elif layer['type'] == 'title':
941                overlay_charts.append(self.create_title_layer(layer))
942            elif layer['type'] in ['context', 'cta', 'source']:
943                overlay_charts.append(self.create_text_layer(layer))
944            elif layer['type'] in ['shape', 'shape_label', 'annotation']:
945                overlay_charts.append(layer['chart'])
946            elif layer['type'] == 'line':
947                overlay_charts.append(layer['chart'])
948
949        # Overlaying the layers on the main graph
950        for overlay in overlay_charts:
951            main_chart += overlay
952
953        # Build the final layout
954        if left_charts:
955            main_chart = alt.hconcat(alt.vconcat(*left_charts), main_chart)
956        if right_charts:
957            main_chart = alt.hconcat(main_chart, alt.vconcat(*right_charts))
958        if top_charts:
959            main_chart = alt.vconcat(alt.hconcat(*top_charts), main_chart)
960        if bottom_charts:
961            main_chart = alt.vconcat(main_chart, alt.hconcat(*bottom_charts))
962
963        # Apply configurations
964        if 'view' in self.config:
965            main_chart = main_chart.configure_view(**self.config['view'])
966
967        return main_chart.resolve_axis(x='independent', y='independent')

It renders all layers of the story in a single graphic.

def story(data=None, **kwargs):
973def story(data=None, **kwargs):
974    """
975    Utility function for creating a Story instance.
976
977    This function simplifies the creation of a Story object, allowing
978    to initialise it in a more concise and intuitive way.
979
980    Parameters:
981    - data: DataFrame or URL for chart data (default: None)
982    - **kwargs: Additional parameters to be passed to the Story constructor
983
984    Returns:
985    - An instance of the Story class
986    """
987    return Story(data, **kwargs)

Utility function for creating a Story instance.

This function simplifies the creation of a Story object, allowing to initialise it in a more concise and intuitive way.

Parameters:

  • data: DataFrame or URL for chart data (default: None)
  • **kwargs: Additional parameters to be passed to the Story constructor

Returns:

  • An instance of the Story class