Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions datawrapper/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
LineSymbol,
LineValueLabel,
MultipleColumnChart,
MultipleColumnPanel,
MultipleColumnRangeAnnotation,
MultipleColumnTextAnnotation,
MultipleColumnXLineAnnotation,
Expand Down Expand Up @@ -109,6 +110,7 @@
"AreaChart",
"ArrowChart",
"MultipleColumnChart",
"MultipleColumnPanel",
"MultipleColumnTextAnnotation",
"MultipleColumnRangeAnnotation",
"MultipleColumnXLineAnnotation",
Expand Down
2 changes: 2 additions & 0 deletions datawrapper/charts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
from .models.text_annotations import ConnectorLine, TextAnnotation
from .multiple_column import (
MultipleColumnChart,
MultipleColumnPanel,
MultipleColumnRangeAnnotation,
MultipleColumnTextAnnotation,
MultipleColumnXLineAnnotation,
Expand Down Expand Up @@ -132,6 +133,7 @@
"AreaChart",
"ArrowChart",
"MultipleColumnChart",
"MultipleColumnPanel",
"MultipleColumnTextAnnotation",
"MultipleColumnRangeAnnotation",
"MultipleColumnXLineAnnotation",
Expand Down
115 changes: 105 additions & 10 deletions datawrapper/charts/multiple_column.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import pandas as pd
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
Expand Down Expand Up @@ -37,6 +38,59 @@
)


class MultipleColumnPanel(BaseModel):
"""Panel configuration for a :class:`MultipleColumnChart` column.

Datawrapper stores multiple-column panel settings as a mapping in
``metadata.visualize.panels`` where each key is a data column name and each
value is that panel's configuration. This model keeps the column name as a
first-class Python attribute while serializing back to the keyed API shape.

Unknown extra fields are preserved so new Datawrapper panel options can
round-trip before this wrapper has explicit typed attributes for them.
"""

model_config = ConfigDict(
populate_by_name=True,
strict=True,
extra="allow",
)

#: The data column this panel configures. Serialized as the API mapping key.
column: str = Field(
description="The data column this panel configures",
min_length=1,
)

#: Optional custom panel title, which can include Datawrapper-supported HTML.
title: str | None = Field(
default=None,
description="Optional custom panel title",
)

#: Whether to show this panel on mobile layouts.
show_on_mobile: bool | None = Field(
default=None,
alias="showOnMobile",
description="Whether to show this panel on mobile layouts",
)

#: Whether to show this panel on desktop layouts.
show_on_desktop: bool | None = Field(
default=None,
alias="showOnDesktop",
description="Whether to show this panel on desktop layouts",
)

def serialize_model(self) -> dict[str, Any]:
"""Serialize the panel to the value stored under its column key."""
return self.model_dump(
by_alias=True,
exclude={"column"},
exclude_none=True,
)


class MultipleColumnTextAnnotation(TextAnnotation):
"""Text annotation with additional fields specific to MultipleColumnChart.

Expand Down Expand Up @@ -366,11 +420,57 @@ class MultipleColumnChart(
)

#: Panels configuration
panels: list[dict[str, Any]] = Field(
panels: list[MultipleColumnPanel] = Field(
default_factory=list,
description="Panel configurations for the chart",
)

@field_validator("panels", mode="before")
@classmethod
def convert_panels(
cls,
value: (
dict[str, dict[str, Any]]
| Sequence[MultipleColumnPanel | dict[str, Any]]
| None
),
) -> list[MultipleColumnPanel]:
"""Convert API and legacy panel inputs to MultipleColumnPanel objects.

Accepts both the Datawrapper API's keyed dictionary shape and the
wrapper's historical list-of-dicts shape for backwards compatibility.
"""
if not value:
return []

if isinstance(value, dict):
result = []
for column, panel in value.items():
if isinstance(panel, MultipleColumnPanel):
result.append(panel.model_copy(update={"column": column}))
elif isinstance(panel, dict):
panel_data = {**panel, "column": column}
result.append(MultipleColumnPanel(**panel_data))
else:
raise TypeError(
"panels keyed-dict values must be MultipleColumnPanel "
f"or dict instances, got {type(panel).__name__}"
)
return result

result = []
for item in value:
if isinstance(item, MultipleColumnPanel):
result.append(item)
elif isinstance(item, dict):
result.append(MultipleColumnPanel(**item))
else:
raise TypeError(
"panels list items must be MultipleColumnPanel or dict "
f"instances, got {type(item).__name__}"
)
return result

#
# Layout
#
Expand Down Expand Up @@ -701,7 +801,7 @@ def serialize_model(self) -> dict:
self.plot_height_fixed,
self.plot_height_ratio,
),
"panels": {panel["column"]: panel for panel in self.panels},
"panels": {panel.column: panel.serialize_model() for panel in self.panels},
# Tooltips
"show-tooltips": self.show_tooltips,
"syncMultipleTooltips": self.sync_multiple_tooltips,
Expand Down Expand Up @@ -837,14 +937,9 @@ def deserialize_model(cls, api_response: dict[str, Any]) -> dict[str, Any]:
# Plot height
init_data.update(PlotHeight.deserialize(visualize))

# Parse panels (dict to list)
panels_obj = visualize.get("panels", {})
if isinstance(panels_obj, dict):
init_data["panels"] = [
{"column": col, **config} for col, config in panels_obj.items()
]
else:
init_data["panels"] = []
# Parse panels. The field validator accepts the API's keyed dict shape
# and converts values to MultipleColumnPanel objects.
init_data["panels"] = visualize.get("panels", {})

# Tooltips
if "show-tooltips" in visualize:
Expand Down
8 changes: 8 additions & 0 deletions docs/user-guide/api/models.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ Annotations
:members:
:show-inheritance:

Multiple Column Panels
----------------------
.. currentmodule:: datawrapper.charts.multiple_column

.. autoclass:: MultipleColumnPanel
:members:
:show-inheritance:

Column Format
-------------
.. currentmodule:: datawrapper.charts.models
Expand Down
16 changes: 8 additions & 8 deletions docs/user-guide/charts/multiple-column-charts.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,14 @@ chart = dw.MultipleColumnChart(
y_grid_label_align="left",
# Custom panel titles with city and country information
panels=[
{"column": "Delhi", "title": "Delhi, <span style=\"color:gray; font-weight: normal;\">India</span>"},
{"column": "Dhaka", "title": "Dhaka, <span style=\"color:gray; font-weight: normal;\"> Bangladesh </span>"},
{"column": "Lagos", "title": "Lagos, <span style=\"color:gray; font-weight: normal;\">Nigeria</span>"},
{"column": "Paris", "title": "Paris, <span style=\"color:gray; font-weight: normal;\">France</span>"},
{"column": "Tokyo", "title": "Tokyo, <span style=\"color:gray; font-weight: normal;\">Japan</span>"},
{"column": "Beijing", "title": "Beijing, <span style=\"color:gray; font-weight: normal;\">China</span>"},
{"column": "Mumbai (Bombay)", "title": "Mumbai, <span style=\"color:gray; font-weight: normal;\">India</span>"},
{"column": "New York-Newark", "title": "New York/Newark, <span style=\"color:gray; font-weight: normal;\">U.S.</span>"},
dw.MultipleColumnPanel(column="Delhi", title="Delhi, <span style=\"color:gray; font-weight: normal;\">India</span>"),
dw.MultipleColumnPanel(column="Dhaka", title="Dhaka, <span style=\"color:gray; font-weight: normal;\"> Bangladesh </span>"),
dw.MultipleColumnPanel(column="Lagos", title="Lagos, <span style=\"color:gray; font-weight: normal;\">Nigeria</span>"),
dw.MultipleColumnPanel(column="Paris", title="Paris, <span style=\"color:gray; font-weight: normal;\">France</span>"),
dw.MultipleColumnPanel(column="Tokyo", title="Tokyo, <span style=\"color:gray; font-weight: normal;\">Japan</span>"),
dw.MultipleColumnPanel(column="Beijing", title="Beijing, <span style=\"color:gray; font-weight: normal;\">China</span>"),
dw.MultipleColumnPanel(column="Mumbai (Bombay)", title="Mumbai, <span style=\"color:gray; font-weight: normal;\">India</span>"),
dw.MultipleColumnPanel(column="New York-Newark", title="New York/Newark, <span style=\"color:gray; font-weight: normal;\">U.S.</span>"),
],
# Add text annotations to label specific panels
text_annotations=[
Expand Down
141 changes: 136 additions & 5 deletions tests/integration/test_multiple_column_chart.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import pandas as pd

from datawrapper import MultipleColumnChart
from datawrapper import MultipleColumnChart, MultipleColumnPanel


# Helper functions to load sample data
Expand Down Expand Up @@ -305,24 +305,143 @@ def test_serialize_color_category(self):
assert viz["color-by-column"] is True

def test_serialize_panels(self):
"""Test that panels list is converted to dict."""
"""Test that panels list is converted to the keyed API dict."""
data = pd.DataFrame({"Year": [2020, 2021], "Value": [100, 110]})
chart = MultipleColumnChart(
title="Test",
data=data,
panels=[
{"column": "Series A", "config": "value1"},
{"column": "Series B", "config": "value2"},
{"column": "Series A", "title": "Custom A", "config": "value1"},
MultipleColumnPanel(
column="Series B",
show_on_mobile=False,
show_on_desktop=True,
config="value2",
),
],
)

assert all(isinstance(panel, MultipleColumnPanel) for panel in chart.panels)

serialized = chart.serialize_model()
panels = serialized["metadata"]["visualize"]["panels"]

assert isinstance(panels, dict)
assert "Series A" in panels
assert "Series B" in panels
assert panels["Series A"]["config"] == "value1"
assert "column" not in panels["Series A"]
assert panels["Series A"] == {"title": "Custom A", "config": "value1"}
assert panels["Series B"] == {
"showOnMobile": False,
"showOnDesktop": True,
"config": "value2",
}

def test_panels_accept_api_dict_shape(self):
"""Test panels can be initialized from Datawrapper's keyed API shape."""
data = pd.DataFrame({"Year": [2020, 2021], "Value": [100, 110]})
chart = MultipleColumnChart(
title="Test",
data=data,
panels={
"Health": {},
"Transport": {"showOnMobile": False, "showOnDesktop": False},
},
)

assert chart.panels == [
MultipleColumnPanel(column="Health"),
MultipleColumnPanel(
column="Transport",
show_on_mobile=False,
show_on_desktop=False,
),
]
assert chart.serialize_model()["metadata"]["visualize"]["panels"] == {
"Health": {},
"Transport": {"showOnMobile": False, "showOnDesktop": False},
}

def test_panels_accept_legacy_keyed_dict_with_column_value(self):
"""Test panels tolerate the wrapper's previous keyed dict output shape."""
data = pd.DataFrame({"Year": [2020, 2021], "Value": [100, 110]})
chart = MultipleColumnChart(
title="Test",
data=data,
panels={"Health": {"column": "Health", "title": "Custom Health"}},
)

assert chart.panels == [
MultipleColumnPanel(column="Health", title="Custom Health")
]
assert chart.serialize_model()["metadata"]["visualize"]["panels"] == {
"Health": {"title": "Custom Health"}
}

def test_panels_keyed_dict_column_key_overrides_value_column(self):
"""Test the API dict key is authoritative when column values conflict."""
data = pd.DataFrame({"Year": [2020, 2021], "Value": [100, 110]})
chart = MultipleColumnChart(
title="Test",
data=data,
panels={"Health": {"column": "Transport", "title": "Custom Health"}},
)

assert chart.panels == [
MultipleColumnPanel(column="Health", title="Custom Health")
]
assert chart.serialize_model()["metadata"]["visualize"]["panels"] == {
"Health": {"title": "Custom Health"}
}

def test_panels_keyed_dict_panel_object_uses_mapping_key_column(self):
"""Test keyed-dict panel objects also treat the key as authoritative."""
data = pd.DataFrame({"Year": [2020, 2021], "Value": [100, 110]})
chart = MultipleColumnChart(
title="Test",
data=data,
panels={
"Health": MultipleColumnPanel(
column="Transport",
title="Custom Health",
)
},
)

assert chart.panels == [
MultipleColumnPanel(column="Health", title="Custom Health")
]
assert chart.serialize_model()["metadata"]["visualize"]["panels"] == {
"Health": {"title": "Custom Health"}
}

def test_panels_reject_unsupported_keyed_dict_values(self):
"""Test invalid API dict panel values raise a clear error."""
data = pd.DataFrame({"Year": [2020, 2021], "Value": [100, 110]})

try:
MultipleColumnChart(title="Test", data=data, panels={"Health": 123})
except TypeError as exc:
assert (
"panels keyed-dict values must be MultipleColumnPanel "
"or dict instances, got int"
) in str(exc)
else:
raise AssertionError("Expected TypeError for invalid keyed panel value")

def test_panels_reject_unsupported_list_items(self):
"""Test invalid panel list items raise a clear error."""
data = pd.DataFrame({"Year": [2020, 2021], "Value": [100, 110]})

try:
MultipleColumnChart(title="Test", data=data, panels=[123])
except TypeError as exc:
assert (
"panels list items must be MultipleColumnPanel or dict "
"instances, got int"
) in str(exc)
else:
raise AssertionError("Expected TypeError for invalid panel list item")

def test_serialize_value_labels(self):
"""Test that valueLabels is serialized correctly."""
Expand Down Expand Up @@ -481,6 +600,18 @@ def mock_get(url):

assert chart.chart_type == "multiple-columns"
assert chart.grid_layout == "fixedCount"
assert all(isinstance(panel, MultipleColumnPanel) for panel in chart.panels)
assert chart.panels[0] == MultipleColumnPanel(column="Health")

transport = next(
panel for panel in chart.panels if panel.column == "Transport"
)
assert transport.show_on_mobile is False
assert transport.show_on_desktop is False
assert transport.serialize_model() == {
"showOnMobile": False,
"showOnDesktop": False,
}

def test_parse_preserves_all_fields(self):
"""Test that parsing preserves all important fields."""
Expand Down
Loading