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)