Skip to content

Commit fb7068a

Browse files
author
Ben Welsh
committed
Add typed multiple column panel model
1 parent 695ba79 commit fb7068a

6 files changed

Lines changed: 190 additions & 23 deletions

File tree

datawrapper/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
LineSymbol,
3030
LineValueLabel,
3131
MultipleColumnChart,
32+
MultipleColumnPanel,
3233
MultipleColumnRangeAnnotation,
3334
MultipleColumnTextAnnotation,
3435
MultipleColumnXLineAnnotation,
@@ -109,6 +110,7 @@
109110
"AreaChart",
110111
"ArrowChart",
111112
"MultipleColumnChart",
113+
"MultipleColumnPanel",
112114
"MultipleColumnTextAnnotation",
113115
"MultipleColumnRangeAnnotation",
114116
"MultipleColumnXLineAnnotation",

datawrapper/charts/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
from .models.text_annotations import ConnectorLine, TextAnnotation
6161
from .multiple_column import (
6262
MultipleColumnChart,
63+
MultipleColumnPanel,
6364
MultipleColumnRangeAnnotation,
6465
MultipleColumnTextAnnotation,
6566
MultipleColumnXLineAnnotation,
@@ -132,6 +133,7 @@
132133
"AreaChart",
133134
"ArrowChart",
134135
"MultipleColumnChart",
136+
"MultipleColumnPanel",
135137
"MultipleColumnTextAnnotation",
136138
"MultipleColumnRangeAnnotation",
137139
"MultipleColumnXLineAnnotation",

datawrapper/charts/multiple_column.py

Lines changed: 99 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import pandas as pd
55
from pydantic import (
6+
BaseModel,
67
ConfigDict,
78
Field,
89
field_validator,
@@ -37,6 +38,59 @@
3738
)
3839

3940

41+
class MultipleColumnPanel(BaseModel):
42+
"""Panel configuration for a :class:`MultipleColumnChart` column.
43+
44+
Datawrapper stores multiple-column panel settings as a mapping in
45+
``metadata.visualize.panels`` where each key is a data column name and each
46+
value is that panel's configuration. This model keeps the column name as a
47+
first-class Python attribute while serializing back to the keyed API shape.
48+
49+
Unknown extra fields are preserved so new Datawrapper panel options can
50+
round-trip before this wrapper has explicit typed attributes for them.
51+
"""
52+
53+
model_config = ConfigDict(
54+
populate_by_name=True,
55+
strict=True,
56+
extra="allow",
57+
)
58+
59+
#: The data column this panel configures. Serialized as the API mapping key.
60+
column: str = Field(
61+
description="The data column this panel configures",
62+
min_length=1,
63+
)
64+
65+
#: Optional custom panel title, which can include Datawrapper-supported HTML.
66+
title: str | None = Field(
67+
default=None,
68+
description="Optional custom panel title",
69+
)
70+
71+
#: Whether to show this panel on mobile layouts.
72+
show_on_mobile: bool | None = Field(
73+
default=None,
74+
alias="showOnMobile",
75+
description="Whether to show this panel on mobile layouts",
76+
)
77+
78+
#: Whether to show this panel on desktop layouts.
79+
show_on_desktop: bool | None = Field(
80+
default=None,
81+
alias="showOnDesktop",
82+
description="Whether to show this panel on desktop layouts",
83+
)
84+
85+
def serialize_model(self) -> dict[str, Any]:
86+
"""Serialize the panel to the value stored under its column key."""
87+
return self.model_dump(
88+
by_alias=True,
89+
exclude={"column"},
90+
exclude_none=True,
91+
)
92+
93+
4094
class MultipleColumnTextAnnotation(TextAnnotation):
4195
"""Text annotation with additional fields specific to MultipleColumnChart.
4296
@@ -366,11 +420,51 @@ class MultipleColumnChart(
366420
)
367421

368422
#: Panels configuration
369-
panels: list[dict[str, Any]] = Field(
423+
panels: list[MultipleColumnPanel] = Field(
370424
default_factory=list,
371425
description="Panel configurations for the chart",
372426
)
373427

428+
@field_validator("panels", mode="before")
429+
@classmethod
430+
def convert_panels(
431+
cls,
432+
value: (
433+
dict[str, dict[str, Any]]
434+
| Sequence[MultipleColumnPanel | dict[str, Any]]
435+
| None
436+
),
437+
) -> list[MultipleColumnPanel]:
438+
"""Convert API and legacy panel inputs to MultipleColumnPanel objects.
439+
440+
Accepts both the Datawrapper API's keyed dictionary shape and the
441+
wrapper's historical list-of-dicts shape for backwards compatibility.
442+
"""
443+
if not value:
444+
return []
445+
446+
if isinstance(value, dict):
447+
result = []
448+
for column, panel in value.items():
449+
if isinstance(panel, MultipleColumnPanel):
450+
result.append(panel)
451+
elif isinstance(panel, dict):
452+
panel_data = {"column": column, **panel}
453+
result.append(MultipleColumnPanel(**panel_data))
454+
else:
455+
result.append(panel)
456+
return result
457+
458+
result = []
459+
for item in value:
460+
if isinstance(item, MultipleColumnPanel):
461+
result.append(item)
462+
elif isinstance(item, dict):
463+
result.append(MultipleColumnPanel(**item))
464+
else:
465+
result.append(item)
466+
return result
467+
374468
#
375469
# Layout
376470
#
@@ -701,7 +795,7 @@ def serialize_model(self) -> dict:
701795
self.plot_height_fixed,
702796
self.plot_height_ratio,
703797
),
704-
"panels": {panel["column"]: panel for panel in self.panels},
798+
"panels": {panel.column: panel.serialize_model() for panel in self.panels},
705799
# Tooltips
706800
"show-tooltips": self.show_tooltips,
707801
"syncMultipleTooltips": self.sync_multiple_tooltips,
@@ -837,14 +931,9 @@ def deserialize_model(cls, api_response: dict[str, Any]) -> dict[str, Any]:
837931
# Plot height
838932
init_data.update(PlotHeight.deserialize(visualize))
839933

840-
# Parse panels (dict to list)
841-
panels_obj = visualize.get("panels", {})
842-
if isinstance(panels_obj, dict):
843-
init_data["panels"] = [
844-
{"column": col, **config} for col, config in panels_obj.items()
845-
]
846-
else:
847-
init_data["panels"] = []
934+
# Parse panels. The field validator accepts the API's keyed dict shape
935+
# and converts values to MultipleColumnPanel objects.
936+
init_data["panels"] = visualize.get("panels", {})
848937

849938
# Tooltips
850939
if "show-tooltips" in visualize:

docs/user-guide/api/models.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ Annotations
4141
:members:
4242
:show-inheritance:
4343

44+
Multiple Column Panels
45+
----------------------
46+
.. currentmodule:: datawrapper.charts.multiple_column
47+
48+
.. autoclass:: MultipleColumnPanel
49+
:members:
50+
:show-inheritance:
51+
4452
Column Format
4553
-------------
4654
.. currentmodule:: datawrapper.charts.models

docs/user-guide/charts/multiple-column-charts.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,14 @@ chart = dw.MultipleColumnChart(
4444
y_grid_label_align="left",
4545
# Custom panel titles with city and country information
4646
panels=[
47-
{"column": "Delhi", "title": "Delhi, <span style=\"color:gray; font-weight: normal;\">India</span>"},
48-
{"column": "Dhaka", "title": "Dhaka, <span style=\"color:gray; font-weight: normal;\"> Bangladesh </span>"},
49-
{"column": "Lagos", "title": "Lagos, <span style=\"color:gray; font-weight: normal;\">Nigeria</span>"},
50-
{"column": "Paris", "title": "Paris, <span style=\"color:gray; font-weight: normal;\">France</span>"},
51-
{"column": "Tokyo", "title": "Tokyo, <span style=\"color:gray; font-weight: normal;\">Japan</span>"},
52-
{"column": "Beijing", "title": "Beijing, <span style=\"color:gray; font-weight: normal;\">China</span>"},
53-
{"column": "Mumbai (Bombay)", "title": "Mumbai, <span style=\"color:gray; font-weight: normal;\">India</span>"},
54-
{"column": "New York-Newark", "title": "New York/Newark, <span style=\"color:gray; font-weight: normal;\">U.S.</span>"},
47+
dw.MultipleColumnPanel(column="Delhi", title="Delhi, <span style=\"color:gray; font-weight: normal;\">India</span>"),
48+
dw.MultipleColumnPanel(column="Dhaka", title="Dhaka, <span style=\"color:gray; font-weight: normal;\"> Bangladesh </span>"),
49+
dw.MultipleColumnPanel(column="Lagos", title="Lagos, <span style=\"color:gray; font-weight: normal;\">Nigeria</span>"),
50+
dw.MultipleColumnPanel(column="Paris", title="Paris, <span style=\"color:gray; font-weight: normal;\">France</span>"),
51+
dw.MultipleColumnPanel(column="Tokyo", title="Tokyo, <span style=\"color:gray; font-weight: normal;\">Japan</span>"),
52+
dw.MultipleColumnPanel(column="Beijing", title="Beijing, <span style=\"color:gray; font-weight: normal;\">China</span>"),
53+
dw.MultipleColumnPanel(column="Mumbai (Bombay)", title="Mumbai, <span style=\"color:gray; font-weight: normal;\">India</span>"),
54+
dw.MultipleColumnPanel(column="New York-Newark", title="New York/Newark, <span style=\"color:gray; font-weight: normal;\">U.S.</span>"),
5555
],
5656
# Add text annotations to label specific panels
5757
text_annotations=[

tests/integration/test_multiple_column_chart.py

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import pandas as pd
88

9-
from datawrapper import MultipleColumnChart
9+
from datawrapper import MultipleColumnChart, MultipleColumnPanel
1010

1111

1212
# Helper functions to load sample data
@@ -305,24 +305,78 @@ def test_serialize_color_category(self):
305305
assert viz["color-by-column"] is True
306306

307307
def test_serialize_panels(self):
308-
"""Test that panels list is converted to dict."""
308+
"""Test that panels list is converted to the keyed API dict."""
309309
data = pd.DataFrame({"Year": [2020, 2021], "Value": [100, 110]})
310310
chart = MultipleColumnChart(
311311
title="Test",
312312
data=data,
313313
panels=[
314-
{"column": "Series A", "config": "value1"},
315-
{"column": "Series B", "config": "value2"},
314+
{"column": "Series A", "title": "Custom A", "config": "value1"},
315+
MultipleColumnPanel(
316+
column="Series B",
317+
show_on_mobile=False,
318+
show_on_desktop=True,
319+
config="value2",
320+
),
316321
],
317322
)
318323

324+
assert all(isinstance(panel, MultipleColumnPanel) for panel in chart.panels)
325+
319326
serialized = chart.serialize_model()
320327
panels = serialized["metadata"]["visualize"]["panels"]
321328

322329
assert isinstance(panels, dict)
323330
assert "Series A" in panels
324331
assert "Series B" in panels
325-
assert panels["Series A"]["config"] == "value1"
332+
assert "column" not in panels["Series A"]
333+
assert panels["Series A"] == {"title": "Custom A", "config": "value1"}
334+
assert panels["Series B"] == {
335+
"showOnMobile": False,
336+
"showOnDesktop": True,
337+
"config": "value2",
338+
}
339+
340+
def test_panels_accept_api_dict_shape(self):
341+
"""Test panels can be initialized from Datawrapper's keyed API shape."""
342+
data = pd.DataFrame({"Year": [2020, 2021], "Value": [100, 110]})
343+
chart = MultipleColumnChart(
344+
title="Test",
345+
data=data,
346+
panels={
347+
"Health": {},
348+
"Transport": {"showOnMobile": False, "showOnDesktop": False},
349+
},
350+
)
351+
352+
assert chart.panels == [
353+
MultipleColumnPanel(column="Health"),
354+
MultipleColumnPanel(
355+
column="Transport",
356+
show_on_mobile=False,
357+
show_on_desktop=False,
358+
),
359+
]
360+
assert chart.serialize_model()["metadata"]["visualize"]["panels"] == {
361+
"Health": {},
362+
"Transport": {"showOnMobile": False, "showOnDesktop": False},
363+
}
364+
365+
def test_panels_accept_legacy_keyed_dict_with_column_value(self):
366+
"""Test panels tolerate the wrapper's previous keyed dict output shape."""
367+
data = pd.DataFrame({"Year": [2020, 2021], "Value": [100, 110]})
368+
chart = MultipleColumnChart(
369+
title="Test",
370+
data=data,
371+
panels={"Health": {"column": "Health", "title": "Custom Health"}},
372+
)
373+
374+
assert chart.panels == [
375+
MultipleColumnPanel(column="Health", title="Custom Health")
376+
]
377+
assert chart.serialize_model()["metadata"]["visualize"]["panels"] == {
378+
"Health": {"title": "Custom Health"}
379+
}
326380

327381
def test_serialize_value_labels(self):
328382
"""Test that valueLabels is serialized correctly."""
@@ -481,6 +535,18 @@ def mock_get(url):
481535

482536
assert chart.chart_type == "multiple-columns"
483537
assert chart.grid_layout == "fixedCount"
538+
assert all(isinstance(panel, MultipleColumnPanel) for panel in chart.panels)
539+
assert chart.panels[0] == MultipleColumnPanel(column="Health")
540+
541+
transport = next(
542+
panel for panel in chart.panels if panel.column == "Transport"
543+
)
544+
assert transport.show_on_mobile is False
545+
assert transport.show_on_desktop is False
546+
assert transport.serialize_model() == {
547+
"showOnMobile": False,
548+
"showOnDesktop": False,
549+
}
484550

485551
def test_parse_preserves_all_fields(self):
486552
"""Test that parsing preserves all important fields."""

0 commit comments

Comments
 (0)