Skip to content

Commit 2814d2d

Browse files
committed
Merge notebook visual regression fixes
2 parents b23271d + 0ef549e commit 2814d2d

49 files changed

Lines changed: 2741 additions & 253 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 768 additions & 0 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ ggplot(df, aes(x='x', y='y', color='group')) + geom_point() + theme_minimal()
3030
ggplot(df, aes(x='x', y='y')) + geom_point() + facet_wrap('group')
3131
```
3232

33+
## Development Notes
34+
35+
### Notebook Image Review
36+
37+
When visually validating notebook output, render the notebooks to HTML and
38+
capture PNG screenshots with npm Playwright using its managed Chromium browser.
39+
Do not use Kaleido or the installed macOS Chrome app for batch notebook review;
40+
those paths can launch the user's desktop Chrome profile/updater and may hang or
41+
crash during large Plotly export runs.
42+
3343
## ggplot2 Function Coverage
3444

3545
### Geoms (46)

complex_plot.html

Lines changed: 7 additions & 0 deletions
Large diffs are not rendered by default.

coord_flip.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<html>
2+
<head><meta charset="utf-8" /></head>
3+
<body>
4+
<div> <script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.5/MathJax.js?config=TeX-AMS-MML_SVG"></script><script>if (window.MathJax && window.MathJax.Hub && window.MathJax.Hub.Config) {window.MathJax.Hub.Config({SVG: {font: "STIX-Web"}});}</script> <script>window.PlotlyConfig = {MathJaxConfig: 'local'};</script>
5+
<script charset="utf-8" src="https://cdn.plot.ly/plotly-3.5.0.min.js" integrity="sha256-fHbNLP+GlIXN+efbQec78UkemUz3NJp7UmfGxC1tNxs=" crossorigin="anonymous"></script> <div id="a7e31b9f-2b95-4d74-9b17-a1e4ecb359ef" class="plotly-graph-div" style="height:1000px; width:1000px;"></div> <script> window.PLOTLYENV=window.PLOTLYENV || {}; if (document.getElementById("a7e31b9f-2b95-4d74-9b17-a1e4ecb359ef")) { Plotly.newPlot( "a7e31b9f-2b95-4d74-9b17-a1e4ecb359ef", [{"legendgroup":"Point","marker":{"color":"#1f77b4","size":8,"symbol":"circle"},"mode":"markers","name":"Point","opacity":1.0,"showlegend":true,"x":{"dtype":"i1","bdata":"BAUG"},"y":{"dtype":"i1","bdata":"AQID"},"type":"scatter","xaxis":"x","yaxis":"y","orientation":"h"}], {"template":{"data":{"histogram2dcontour":[{"type":"histogram2dcontour","colorbar":{"outlinewidth":0,"ticks":""},"colorscale":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]]}],"choropleth":[{"type":"choropleth","colorbar":{"outlinewidth":0,"ticks":""}}],"histogram2d":[{"type":"histogram2d","colorbar":{"outlinewidth":0,"ticks":""},"colorscale":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]]}],"heatmap":[{"type":"heatmap","colorbar":{"outlinewidth":0,"ticks":""},"colorscale":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]]}],"contourcarpet":[{"type":"contourcarpet","colorbar":{"outlinewidth":0,"ticks":""}}],"contour":[{"type":"contour","colorbar":{"outlinewidth":0,"ticks":""},"colorscale":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]]}],"surface":[{"type":"surface","colorbar":{"outlinewidth":0,"ticks":""},"colorscale":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]]}],"mesh3d":[{"type":"mesh3d","colorbar":{"outlinewidth":0,"ticks":""}}],"scatter":[{"fillpattern":{"fillmode":"overlay","size":10,"solidity":0.2},"type":"scatter"}],"parcoords":[{"type":"parcoords","line":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scatterpolargl":[{"type":"scatterpolargl","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"bar":[{"error_x":{"color":"#2a3f5f"},"error_y":{"color":"#2a3f5f"},"marker":{"line":{"color":"#E5ECF6","width":0.5},"pattern":{"fillmode":"overlay","size":10,"solidity":0.2}},"type":"bar"}],"scattergeo":[{"type":"scattergeo","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scatterpolar":[{"type":"scatterpolar","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"histogram":[{"marker":{"pattern":{"fillmode":"overlay","size":10,"solidity":0.2}},"type":"histogram"}],"scattergl":[{"type":"scattergl","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scatter3d":[{"type":"scatter3d","line":{"colorbar":{"outlinewidth":0,"ticks":""}},"marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scattermap":[{"type":"scattermap","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scattermapbox":[{"type":"scattermapbox","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scatterternary":[{"type":"scatterternary","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scattercarpet":[{"type":"scattercarpet","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"carpet":[{"aaxis":{"endlinecolor":"#2a3f5f","gridcolor":"white","linecolor":"white","minorgridcolor":"white","startlinecolor":"#2a3f5f"},"baxis":{"endlinecolor":"#2a3f5f","gridcolor":"white","linecolor":"white","minorgridcolor":"white","startlinecolor":"#2a3f5f"},"type":"carpet"}],"table":[{"cells":{"fill":{"color":"#EBF0F8"},"line":{"color":"white"}},"header":{"fill":{"color":"#C8D4E3"},"line":{"color":"white"}},"type":"table"}],"barpolar":[{"marker":{"line":{"color":"#E5ECF6","width":0.5},"pattern":{"fillmode":"overlay","size":10,"solidity":0.2}},"type":"barpolar"}],"pie":[{"automargin":true,"type":"pie"}]},"layout":{"autotypenumbers":"strict","colorway":["#636efa","#EF553B","#00cc96","#ab63fa","#FFA15A","#19d3f3","#FF6692","#B6E880","#FF97FF","#FECB52"],"font":{"color":"#2a3f5f"},"hovermode":"closest","hoverlabel":{"align":"left"},"paper_bgcolor":"white","plot_bgcolor":"#E5ECF6","polar":{"bgcolor":"#E5ECF6","angularaxis":{"gridcolor":"white","linecolor":"white","ticks":""},"radialaxis":{"gridcolor":"white","linecolor":"white","ticks":""}},"ternary":{"bgcolor":"#E5ECF6","aaxis":{"gridcolor":"white","linecolor":"white","ticks":""},"baxis":{"gridcolor":"white","linecolor":"white","ticks":""},"caxis":{"gridcolor":"white","linecolor":"white","ticks":""}},"coloraxis":{"colorbar":{"outlinewidth":0,"ticks":""}},"colorscale":{"sequential":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]],"sequentialminus":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]],"diverging":[[0,"#8e0152"],[0.1,"#c51b7d"],[0.2,"#de77ae"],[0.3,"#f1b6da"],[0.4,"#fde0ef"],[0.5,"#f7f7f7"],[0.6,"#e6f5d0"],[0.7,"#b8e186"],[0.8,"#7fbc41"],[0.9,"#4d9221"],[1,"#276419"]]},"xaxis":{"gridcolor":"white","linecolor":"white","ticks":"","title":{"standoff":15},"zerolinecolor":"white","automargin":true,"zerolinewidth":2},"yaxis":{"gridcolor":"white","linecolor":"white","ticks":"","title":{"standoff":15},"zerolinecolor":"white","automargin":true,"zerolinewidth":2},"scene":{"xaxis":{"backgroundcolor":"#E5ECF6","gridcolor":"white","linecolor":"white","showbackground":true,"ticks":"","zerolinecolor":"white","gridwidth":2},"yaxis":{"backgroundcolor":"#E5ECF6","gridcolor":"white","linecolor":"white","showbackground":true,"ticks":"","zerolinecolor":"white","gridwidth":2},"zaxis":{"backgroundcolor":"#E5ECF6","gridcolor":"white","linecolor":"white","showbackground":true,"ticks":"","zerolinecolor":"white","gridwidth":2}},"shapedefaults":{"line":{"color":"#2a3f5f"}},"annotationdefaults":{"arrowcolor":"#2a3f5f","arrowhead":0,"arrowwidth":1},"geo":{"bgcolor":"white","landcolor":"#E5ECF6","subunitcolor":"white","showland":true,"showlakes":true,"lakecolor":"white"},"title":{"x":0.05},"mapbox":{"style":"light"}}},"xaxis":{"anchor":"y","domain":[0.0,1.0]},"yaxis":{"anchor":"x","domain":[0.0,1.0],"rangemode":"tozero"},"legend":{"x":1.02,"y":1,"xanchor":"left","yanchor":"top"},"showlegend":true,"width":1000,"height":1000}, {"responsive": true} ) }; </script> </div>
6+
</body>
7+
</html>

examples/complex_plot.html

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

examples/coord_flip.html

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

examples/plot.html

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

examples/plot_extended.html

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

ggplotly/aesthetic_mapper.py

Lines changed: 83 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,50 @@
1616
from .constants import get_color_palette as _get_color_palette
1717
from .exceptions import ColumnNotFoundError
1818

19+
LINETYPE_PALETTE = ("solid", "dash", "dot", "dashdot", "longdash", "longdashdot")
20+
LINETYPE_ALIASES = {
21+
"solid": "solid",
22+
"dashed": "dash",
23+
"dash": "dash",
24+
"dotted": "dot",
25+
"dot": "dot",
26+
"dotdash": "dashdot",
27+
"dashdot": "dashdot",
28+
"longdash": "longdash",
29+
"twodash": "longdashdot",
30+
"longdashdot": "longdashdot",
31+
}
32+
33+
34+
def map_continuous_colors(
35+
series: pd.Series,
36+
palette: str = "Viridis",
37+
default_color: str = "#1f77b4",
38+
) -> pd.Series:
39+
"""Map a continuous series to Plotly color strings without mutating data."""
40+
import plotly.colors as pc
41+
42+
numeric = pd.to_numeric(series, errors="coerce")
43+
valid = numeric.dropna()
44+
if valid.empty:
45+
return pd.Series([default_color] * len(series), index=series.index)
46+
47+
vmin = valid.min()
48+
vmax = valid.max()
49+
span = vmax - vmin
50+
51+
def sample(value):
52+
if pd.isna(value):
53+
return default_color
54+
if span == 0:
55+
position = 0.5
56+
else:
57+
position = (value - vmin) / span
58+
position = max(0.0, min(1.0, float(position)))
59+
return pc.sample_colorscale(palette, [position])[0]
60+
61+
return numeric.map(sample)
62+
1963

2064
# Module-level cache for color conversions (expensive Plotly operations)
2165
@lru_cache(maxsize=512)
@@ -189,8 +233,11 @@ def resolve_aesthetic(self, aesthetic: str) -> tuple[Any, pd.Series | None, dict
189233
Raises:
190234
ColumnNotFoundError: If value looks like a column name but doesn't exist
191235
"""
192-
# First check mapping (aes), then params
193-
value = self.mapping.get(aesthetic) or self.params.get(aesthetic)
236+
# First check mapping (aes), then params. Explicit falsey values are valid.
237+
if aesthetic in self.mapping:
238+
value = self.mapping[aesthetic]
239+
else:
240+
value = self.params.get(aesthetic)
194241

195242
if value is None:
196243
return None, None, None
@@ -289,6 +336,14 @@ def _create_shape_map(self, series: pd.Series) -> dict[Any, str]:
289336

290337
return shape_map
291338

339+
def _create_linetype_map(self, series: pd.Series) -> dict[Any, str]:
340+
"""Create a mapping from unique values to Plotly dash styles."""
341+
unique_values = series.dropna().unique()
342+
return {
343+
val: LINETYPE_ALIASES.get(str(val), LINETYPE_PALETTE[i % len(LINETYPE_PALETTE)])
344+
for i, val in enumerate(unique_values)
345+
}
346+
292347
def get_style_properties(self) -> dict[str, Any]:
293348
"""
294349
Extract all relevant style properties for a geom.
@@ -321,10 +376,25 @@ def get_style_properties(self) -> dict[str, Any]:
321376
fill, fill_series, fill_map = self.resolve_aesthetic('fill')
322377
size, size_series, _ = self.resolve_aesthetic('size')
323378
alpha = self.params.get('alpha', 1.0)
324-
linetype = self.params.get('linetype', 'solid')
379+
380+
if 'linetype' in self.mapping:
381+
linetype_value = self.mapping['linetype']
382+
else:
383+
linetype_value = self.params.get('linetype', 'solid')
384+
linetype_series = None
385+
linetype_map = None
386+
if linetype_value is not None and self.is_column_reference('linetype', linetype_value):
387+
linetype_series = self.data[linetype_value]
388+
linetype_map = self._create_linetype_map(linetype_series)
389+
linetype = linetype_value
390+
else:
391+
linetype = LINETYPE_ALIASES.get(str(linetype_value), linetype_value)
325392

326393
# Resolve shape aesthetic
327-
shape_value = self.mapping.get('shape') or self.params.get('shape')
394+
if 'shape' in self.mapping:
395+
shape_value = self.mapping['shape']
396+
else:
397+
shape_value = self.params.get('shape')
328398
shape_series = None
329399
shape_map = None
330400
if shape_value is not None and self.is_column_reference('shape', shape_value):
@@ -340,7 +410,7 @@ def get_style_properties(self) -> dict[str, Any]:
340410

341411
# Group is special - it's always a column reference if provided
342412
group = self.mapping.get('group')
343-
group_series = self.data[group] if group and group in self.data.columns else None
413+
group_series = self.data[group] if group is not None and group in self.data.columns else None
344414

345415
# Get default color if neither color nor fill is mapped
346416
default_color = self.params.get('color', '#1f77b4')
@@ -370,6 +440,8 @@ def get_style_properties(self) -> dict[str, Any]:
370440
'shape_map': shape_map,
371441
'alpha': alpha,
372442
'linetype': linetype,
443+
'linetype_series': linetype_series,
444+
'linetype_map': linetype_map,
373445
'group': group,
374446
'group_series': group_series,
375447
'default_color': default_color,
@@ -401,19 +473,19 @@ def get_color_for_value(self, value_key: Any, style_props: dict[str, Any] = None
401473
if prefer_fill:
402474
# Prefer fill for area/ribbon/bar geoms
403475
if style_props['fill_series'] is not None:
404-
return style_props['fill_map'].get(value_key, style_props['default_color'])
476+
return (style_props.get('fill_map') or {}).get(value_key, style_props['default_color'])
405477
elif style_props['color_series'] is not None:
406-
return style_props['color_map'].get(value_key, style_props['default_color'])
478+
return (style_props.get('color_map') or {}).get(value_key, style_props['default_color'])
407479
elif style_props['fill'] is not None:
408480
return style_props['fill']
409481
elif style_props['color'] is not None:
410482
return style_props['color']
411483
else:
412484
# Prefer color for line/point geoms
413485
if style_props['color_series'] is not None:
414-
return style_props['color_map'].get(value_key, style_props['default_color'])
486+
return (style_props.get('color_map') or {}).get(value_key, style_props['default_color'])
415487
elif style_props['fill_series'] is not None:
416-
return style_props['fill_map'].get(value_key, style_props['default_color'])
488+
return (style_props.get('fill_map') or {}).get(value_key, style_props['default_color'])
417489
elif style_props['color'] is not None:
418490
return style_props['color']
419491
elif style_props['fill'] is not None:
@@ -484,9 +556,9 @@ def apply_style_to_trace(self, trace_kwargs: dict, style_props: dict,
484556
# Determine which color/fill to use
485557
if value_key is not None:
486558
# We're drawing a specific group - look up its color
487-
if style_props['color_series'] is not None:
559+
if style_props['color_series'] is not None and style_props.get('color_map') is not None:
488560
color_value = style_props['color_map'].get(value_key)
489-
elif style_props['fill_series'] is not None:
561+
elif style_props['fill_series'] is not None and style_props.get('fill_map') is not None:
490562
color_value = style_props['fill_map'].get(value_key)
491563
else:
492564
color_value = style_props.get('color') or style_props.get('fill') or style_props['default_color']

ggplotly/coords/coord_polar.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ def apply(self, fig):
130130
else:
131131
theta_values = trace.y
132132
r_values = trace.x
133+
theta_values = self._to_plotly_degrees(theta_values)
133134

134135
scatterpolar_kwargs = dict(
135136
r=r_values,
@@ -173,3 +174,13 @@ def apply(self, fig):
173174
),
174175
)
175176
fig.update_layout(polar=polar_config)
177+
178+
def _to_plotly_degrees(self, values):
179+
try:
180+
numeric = np.asarray(values, dtype=float)
181+
except (TypeError, ValueError):
182+
return values
183+
finite = numeric[np.isfinite(numeric)]
184+
if finite.size and finite.min() >= -2 * np.pi and finite.max() <= 2 * np.pi:
185+
return np.degrees(numeric)
186+
return values

0 commit comments

Comments
 (0)