Skip to content

Commit c677239

Browse files
authored
feat: add fast_bar() method for high-performance bar-like charts (#17)
* Add barlike option to area plot Summary of changes: 1. plotting.py: Added barlike parameter to area() with helper _apply_barlike_style() 2. accessor.py: Updated both DataArrayPlotlyAccessor.area() and DatasetPlotlyAccessor.area() 3. Tests: Added 3 tests for barlike (basic, trace styling, animation frames) 4. Notebook: Created docs/examples/barlike.ipynb demonstrating the feature Usage: xpx(da).area(barlike=True) # Looks like bars, renders faster * Add barlike option to area plot Summary of changes: 1. plotting.py: Added barlike parameter to area() with helper _apply_barlike_style() 2. accessor.py: Updated both DataArrayPlotlyAccessor.area() and DatasetPlotlyAccessor.area() 3. Tests: Added 3 tests for barlike (basic, trace styling, animation frames) 4. Notebook: Created docs/examples/barlike.ipynb demonstrating the feature Usage: xpx(da).area(barlike=True) # Looks like bars, renders faster * ⏺ Done. The fast_bar() method now handles all cases: ┌──────────────┬─────────────────────────────────────┐ │ Data │ Behavior │ ├──────────────┼─────────────────────────────────────┤ │ All positive │ Stacked (stackgroup=1) │ ├──────────────┼─────────────────────────────────────┤ │ All negative │ Stacked (stackgroup=1) │ ├──────────────┼─────────────────────────────────────┤ │ Mixed +/- │ No stacking, fill to zero (tozeroy) │ └──────────────┴─────────────────────────────────────┘ Changes made: - plotting.py: Added _has_mixed_signs() detection, updated _apply_barlike_style() to handle mixed signs - test_accessor.py: Added 2 new tests for mixed/same-sign behavior - fast_bar.ipynb: Updated with negative and mixed value examples, documented the behavior Note: For mixed data, series will overlap since stacking doesn't work well with mixed signs. The notebook documents that bar() should be used if proper stacking of mixed data is needed. * Summary of changes: fast_bar() now properly handles mixed positive/negative data: - Same-sign data (all positive or all negative): Uses single stackgroup for normal stacking - Mixed-sign data: Splits each trace into positive and negative parts with separate stackgroups - Positives stack upward from zero (stackgroup='positive') - Negatives stack downward from zero (stackgroup='negative') - This matches bar chart behavior with barmode='relative' Implementation: - Added _split_traces_by_sign() helper that creates separate traces for positive and negative values - Updated fast_bar() to detect mixed signs and apply the split - Animation frames are also handled correctly * Done. The implementation is now much cleaner. Summary: ┌─────────────────────┬─────────────────────────────────────────┐ │ Trace type │ Behavior │ ├─────────────────────┼─────────────────────────────────────────┤ │ All positive values │ stackgroup='positive' - stacks upward │ ├─────────────────────┼─────────────────────────────────────────┤ │ All negative values │ stackgroup='negative' - stacks downward │ ├─────────────────────┼─────────────────────────────────────────┤ │ Mixed +/- values │ stackgroup=None, dashed line, no fill │ └─────────────────────┴─────────────────────────────────────────┘ Code is simpler: - _style_traces_as_bars() - single function that classifies and styles all traces - No trace splitting needed - Works correctly for facets and animations Notebook updated with examples showing: 1. Split columns (Profit positive, Loss negative) → proper stacking 2. Truly mixed columns → dashed lines indicating user should use bar() * Add warning for mixed columns * Improve notebook * Ad fastbar to accessor.py
1 parent f40ecda commit c677239

File tree

6 files changed

+598
-2
lines changed

6 files changed

+598
-2
lines changed

docs/examples/fast_bar.ipynb

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "markdown",
5+
"id": "0",
6+
"metadata": {},
7+
"source": [
8+
"# Fast Bar Charts\n",
9+
"\n",
10+
"The `fast_bar()` method creates bar-like visualizations using stacked areas. This renders much faster than actual bar charts for large datasets because it uses a single polygon per trace instead of individual rectangles."
11+
]
12+
},
13+
{
14+
"cell_type": "code",
15+
"execution_count": null,
16+
"id": "1",
17+
"metadata": {},
18+
"outputs": [],
19+
"source": [
20+
"import numpy as np\n",
21+
"import xarray as xr\n",
22+
"\n",
23+
"from xarray_plotly import config, xpx\n",
24+
"\n",
25+
"config.notebook()"
26+
]
27+
},
28+
{
29+
"cell_type": "markdown",
30+
"id": "2",
31+
"metadata": {},
32+
"source": [
33+
"## Basic Example"
34+
]
35+
},
36+
{
37+
"cell_type": "code",
38+
"execution_count": null,
39+
"id": "3",
40+
"metadata": {},
41+
"outputs": [],
42+
"source": [
43+
"# Quarterly revenue data by product and region\n",
44+
"np.random.seed(42)\n",
45+
"da = xr.DataArray(\n",
46+
" np.random.rand(4, 3, 2) * 100 + 50,\n",
47+
" dims=[\"quarter\", \"product\", \"region\"],\n",
48+
" coords={\n",
49+
" \"quarter\": [\"Q1\", \"Q2\", \"Q3\", \"Q4\"],\n",
50+
" \"product\": [\"Widgets\", \"Gadgets\", \"Gizmos\"],\n",
51+
" \"region\": [\"North\", \"South\"],\n",
52+
" },\n",
53+
" name=\"revenue\",\n",
54+
")\n",
55+
"\n",
56+
"xpx(da).fast_bar()"
57+
]
58+
},
59+
{
60+
"cell_type": "code",
61+
"execution_count": null,
62+
"id": "4",
63+
"metadata": {},
64+
"outputs": [],
65+
"source": [
66+
"# Comparison with regular bar()\n",
67+
"xpx(da).bar()"
68+
]
69+
},
70+
{
71+
"cell_type": "markdown",
72+
"id": "5",
73+
"metadata": {},
74+
"source": [
75+
"## With Faceting"
76+
]
77+
},
78+
{
79+
"cell_type": "code",
80+
"execution_count": null,
81+
"id": "6",
82+
"metadata": {},
83+
"outputs": [],
84+
"source": [
85+
"xpx(da).fast_bar(facet_col=\"region\")"
86+
]
87+
},
88+
{
89+
"cell_type": "markdown",
90+
"id": "7",
91+
"metadata": {},
92+
"source": [
93+
"## With Animation"
94+
]
95+
},
96+
{
97+
"cell_type": "code",
98+
"execution_count": null,
99+
"id": "8",
100+
"metadata": {},
101+
"outputs": [],
102+
"source": [
103+
"# Multi-year data for animation\n",
104+
"np.random.seed(123)\n",
105+
"da_anim = xr.DataArray(\n",
106+
" np.random.rand(4, 3, 5) * 100 + 20,\n",
107+
" dims=[\"quarter\", \"product\", \"year\"],\n",
108+
" coords={\n",
109+
" \"quarter\": [\"Q1\", \"Q2\", \"Q3\", \"Q4\"],\n",
110+
" \"product\": [\"Widgets\", \"Gadgets\", \"Gizmos\"],\n",
111+
" \"year\": [2020, 2021, 2022, 2023, 2024],\n",
112+
" },\n",
113+
" name=\"revenue\",\n",
114+
")\n",
115+
"\n",
116+
"xpx(da_anim).fast_bar(animation_frame=\"year\")"
117+
]
118+
},
119+
{
120+
"cell_type": "markdown",
121+
"id": "9",
122+
"metadata": {},
123+
"source": [
124+
"## Faceting + Animation"
125+
]
126+
},
127+
{
128+
"cell_type": "code",
129+
"execution_count": null,
130+
"id": "10",
131+
"metadata": {},
132+
"outputs": [],
133+
"source": [
134+
"# 4D data: quarter x product x region x year\n",
135+
"np.random.seed(456)\n",
136+
"da_4d = xr.DataArray(\n",
137+
" np.random.rand(4, 3, 2, 4) * 80 + 30,\n",
138+
" dims=[\"quarter\", \"product\", \"region\", \"year\"],\n",
139+
" coords={\n",
140+
" \"quarter\": [\"Q1\", \"Q2\", \"Q3\", \"Q4\"],\n",
141+
" \"product\": [\"Widgets\", \"Gadgets\", \"Gizmos\"],\n",
142+
" \"region\": [\"North\", \"South\"],\n",
143+
" \"year\": [2021, 2022, 2023, 2024],\n",
144+
" },\n",
145+
" name=\"revenue\",\n",
146+
")\n",
147+
"\n",
148+
"xpx(da_4d).fast_bar(facet_col=\"region\", animation_frame=\"year\")"
149+
]
150+
},
151+
{
152+
"cell_type": "markdown",
153+
"id": "11",
154+
"metadata": {},
155+
"source": [
156+
"## Positive and Negative Values\n",
157+
"\n",
158+
"`fast_bar()` classifies each trace by its values:\n",
159+
"- **Purely positive** → stacks upward\n",
160+
"- **Purely negative** → stacks downward\n",
161+
"- **Mixed signs** → warning + dashed line (use `bar()` instead)"
162+
]
163+
},
164+
{
165+
"cell_type": "code",
166+
"execution_count": null,
167+
"id": "12",
168+
"metadata": {},
169+
"outputs": [],
170+
"source": [
171+
"# Profit (positive) and Loss (negative) - stacks correctly\n",
172+
"np.random.seed(789)\n",
173+
"da_split = xr.DataArray(\n",
174+
" np.column_stack(\n",
175+
" [\n",
176+
" np.random.rand(6) * 80 + 20, # Revenue: positive\n",
177+
" -np.random.rand(6) * 50 - 10, # Costs: negative\n",
178+
" ]\n",
179+
" ),\n",
180+
" dims=[\"month\", \"category\"],\n",
181+
" coords={\n",
182+
" \"month\": [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\"],\n",
183+
" \"category\": [\"Revenue\", \"Costs\"],\n",
184+
" },\n",
185+
" name=\"financials\",\n",
186+
")\n",
187+
"\n",
188+
"xpx(da_split).fast_bar()"
189+
]
190+
},
191+
{
192+
"cell_type": "code",
193+
"execution_count": null,
194+
"id": "13",
195+
"metadata": {},
196+
"outputs": [],
197+
"source": [
198+
"# With animation - sign classification is consistent across frames\n",
199+
"np.random.seed(321)\n",
200+
"da_split_anim = xr.DataArray(\n",
201+
" np.stack(\n",
202+
" [\n",
203+
" np.column_stack([np.random.rand(6) * 80 + 20, -np.random.rand(6) * 50 - 10])\n",
204+
" for _ in range(4)\n",
205+
" ],\n",
206+
" axis=-1,\n",
207+
" ),\n",
208+
" dims=[\"month\", \"category\", \"year\"],\n",
209+
" coords={\n",
210+
" \"month\": [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\"],\n",
211+
" \"category\": [\"Revenue\", \"Costs\"],\n",
212+
" \"year\": [2021, 2022, 2023, 2024],\n",
213+
" },\n",
214+
" name=\"financials\",\n",
215+
")\n",
216+
"\n",
217+
"xpx(da_split_anim).fast_bar(animation_frame=\"year\")"
218+
]
219+
},
220+
{
221+
"cell_type": "markdown",
222+
"id": "14",
223+
"metadata": {},
224+
"source": [
225+
"## Mixed Sign Warning\n",
226+
"\n",
227+
"When a trace has both positive and negative values, `fast_bar()` shows a warning and displays it as a dashed line:"
228+
]
229+
},
230+
{
231+
"cell_type": "code",
232+
"execution_count": null,
233+
"id": "15",
234+
"metadata": {},
235+
"outputs": [],
236+
"source": [
237+
"# Both columns have mixed signs - triggers warning\n",
238+
"da_mixed = xr.DataArray(\n",
239+
" np.array(\n",
240+
" [\n",
241+
" [50, -30],\n",
242+
" [-40, 60],\n",
243+
" [30, -50],\n",
244+
" [-20, 40],\n",
245+
" ]\n",
246+
" ),\n",
247+
" dims=[\"month\", \"category\"],\n",
248+
" coords={\n",
249+
" \"month\": [\"Jan\", \"Feb\", \"Mar\", \"Apr\"],\n",
250+
" \"category\": [\"A\", \"B\"],\n",
251+
" },\n",
252+
")\n",
253+
"\n",
254+
"# This will show a warning\n",
255+
"xpx(da_mixed).fast_bar()"
256+
]
257+
},
258+
{
259+
"cell_type": "code",
260+
"execution_count": null,
261+
"id": "16",
262+
"metadata": {},
263+
"outputs": [],
264+
"source": [
265+
"# For mixed data, use bar() instead\n",
266+
"xpx(da_mixed).bar()"
267+
]
268+
},
269+
{
270+
"cell_type": "markdown",
271+
"id": "17",
272+
"metadata": {},
273+
"source": [
274+
"## When to Use\n",
275+
"\n",
276+
"| Method | Use when... |\n",
277+
"|--------|-------------|\n",
278+
"| `fast_bar()` | Large datasets, animations, performance matters, data is same-sign per trace |\n",
279+
"| `bar()` | Need grouped bars, pattern fills, or have mixed +/- values per trace |\n",
280+
"| `area()` | Want smooth continuous fills |"
281+
]
282+
}
283+
],
284+
"metadata": {
285+
"kernelspec": {
286+
"display_name": "Python 3",
287+
"language": "python",
288+
"name": "python3"
289+
},
290+
"language_info": {
291+
"name": "python",
292+
"version": "3.12.0"
293+
}
294+
},
295+
"nbformat": 4,
296+
"nbformat_minor": 5
297+
}

mkdocs.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,7 @@ nav:
7373
- Dimensions & Facets: examples/dimensions.ipynb
7474
- Plotly Express Options: examples/kwargs.ipynb
7575
- Figure Customization: examples/figure.ipynb
76+
- Combining Figures: examples/combining.ipynb
77+
- Figure Manipulation: examples/manipulation.ipynb
78+
- Fast Bar Charts: examples/fast_bar.ipynb
7679
- API Reference: api.md

tests/test_accessor.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,66 @@ def test_area_returns_figure(self) -> None:
146146
fig = self.da_2d.plotly.area()
147147
assert isinstance(fig, go.Figure)
148148

149+
def test_fast_bar_returns_figure(self) -> None:
150+
"""Test that fast_bar() returns a Plotly Figure."""
151+
fig = self.da_2d.plotly.fast_bar()
152+
assert isinstance(fig, go.Figure)
153+
154+
def test_fast_bar_trace_styling(self) -> None:
155+
"""Test that fast_bar applies correct trace styling."""
156+
fig = self.da_2d.plotly.fast_bar()
157+
for trace in fig.data:
158+
assert trace.line.width == 0
159+
assert trace.line.shape == "hv"
160+
assert trace.fillcolor is not None
161+
162+
def test_fast_bar_animation_frames(self) -> None:
163+
"""Test that fast_bar styling applies to animation frames."""
164+
da = xr.DataArray(
165+
np.random.rand(5, 3, 4),
166+
dims=["time", "city", "year"],
167+
)
168+
fig = da.plotly.fast_bar(animation_frame="year")
169+
assert len(fig.frames) > 0
170+
for frame in fig.frames:
171+
for trace in frame.data:
172+
assert trace.line.width == 0
173+
assert trace.line.shape == "hv"
174+
assert trace.fillcolor is not None
175+
176+
def test_fast_bar_mixed_signs_dashed(self) -> None:
177+
"""Test that fast_bar shows mixed-sign traces as dashed lines."""
178+
da = xr.DataArray(
179+
np.array([[50, -30], [-40, 60]]), # Both columns have mixed signs
180+
dims=["time", "category"],
181+
)
182+
fig = da.plotly.fast_bar()
183+
# Mixed traces should have no stacking and dashed lines
184+
for trace in fig.data:
185+
assert trace.stackgroup is None
186+
assert trace.line.dash == "dash"
187+
188+
def test_fast_bar_separate_sign_columns(self) -> None:
189+
"""Test that fast_bar uses separate stackgroups when columns have different signs."""
190+
da = xr.DataArray(
191+
np.array([[50, -30], [60, -40]]), # Column 0 positive, column 1 negative
192+
dims=["time", "category"],
193+
)
194+
fig = da.plotly.fast_bar()
195+
stackgroups = {trace.stackgroup for trace in fig.data}
196+
assert "positive" in stackgroups
197+
assert "negative" in stackgroups
198+
199+
def test_fast_bar_same_sign_stacks(self) -> None:
200+
"""Test that fast_bar uses stacking for same-sign data."""
201+
da = xr.DataArray(
202+
np.random.rand(5, 3) * 100,
203+
dims=["time", "category"],
204+
)
205+
fig = da.plotly.fast_bar()
206+
for trace in fig.data:
207+
assert trace.stackgroup is not None
208+
149209
def test_scatter_returns_figure(self) -> None:
150210
"""Test that scatter() returns a Plotly Figure."""
151211
fig = self.da_2d.plotly.scatter()

0 commit comments

Comments
 (0)