DanielRegaladoCardoso commited on
Commit
064c0d2
·
verified ·
1 Parent(s): 1996981

Pin kaleido==0.2.1 + native SVG fallback when kaleido fails

Browse files
Files changed (1) hide show
  1. src/visualization/plotly_fallback.py +121 -2
src/visualization/plotly_fallback.py CHANGED
@@ -26,6 +26,19 @@ FONT_FAMILY = (
26
  )
27
 
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  def _theme_layout(title: str = "") -> dict:
30
  return dict(
31
  title=dict(text=title, font=dict(family=FONT_FAMILY, size=15, color=INK)),
@@ -139,11 +152,117 @@ class PlotlyRenderer:
139
  ))
140
 
141
  def _to_svg(self, fig: go.Figure) -> str:
 
 
142
  try:
143
  return pio.to_image(fig, format="svg").decode("utf-8")
144
  except Exception as e:
145
- logger.warning(f"to_image SVG failed ({e}); returning HTML")
146
- return fig.to_html(include_plotlyjs="cdn", full_html=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
 
148
  def _empty(self, msg: str) -> str:
149
  return f'''<svg viewBox="0 0 600 200" preserveAspectRatio="xMidYMid meet"
 
26
  )
27
 
28
 
29
+ def _fmt(v: float) -> str:
30
+ """Compact number formatting for axis ticks."""
31
+ if abs(v) >= 1e9:
32
+ return f"{v/1e9:.1f}B"
33
+ if abs(v) >= 1e6:
34
+ return f"{v/1e6:.1f}M"
35
+ if abs(v) >= 1e3:
36
+ return f"{v/1e3:.1f}k"
37
+ if abs(v - int(v)) < 1e-9:
38
+ return str(int(v))
39
+ return f"{v:.2f}"
40
+
41
+
42
  def _theme_layout(title: str = "") -> dict:
43
  return dict(
44
  title=dict(text=title, font=dict(family=FONT_FAMILY, size=15, color=INK)),
 
152
  ))
153
 
154
  def _to_svg(self, fig: go.Figure) -> str:
155
+ """Try Plotly→SVG via kaleido; if that fails, hand-draw a simple SVG
156
+ from the figure's data so the user always gets a visual."""
157
  try:
158
  return pio.to_image(fig, format="svg").decode("utf-8")
159
  except Exception as e:
160
+ logger.warning(f"Plotly to_image failed ({e}); using native SVG fallback")
161
+ return self._native_svg(fig)
162
+
163
+ def _native_svg(self, fig: go.Figure) -> str:
164
+ """Hand-draw a simple SVG from the figure data — no kaleido needed."""
165
+ traces = fig.data
166
+ if not traces:
167
+ return self._empty("No data to plot.")
168
+
169
+ W, H = 600, 380
170
+ pad_l, pad_r, pad_t, pad_b = 60, 30, 40, 50
171
+
172
+ # Pull the first trace's data
173
+ t = traces[0]
174
+ xs = list(getattr(t, "x", []) or [])
175
+ ys = list(getattr(t, "y", []) or [])
176
+ # For pies / tables we don't have x/y; fall back to text
177
+ if not xs or not ys:
178
+ return self._empty("Unsupported chart shape; see Data section.")
179
+
180
+ try:
181
+ ys_num = [float(y) if y is not None else 0.0 for y in ys]
182
+ except (TypeError, ValueError):
183
+ return self._empty("Non-numeric values; see Data section.")
184
+
185
+ ymin, ymax = min(ys_num), max(ys_num)
186
+ if ymin == ymax:
187
+ ymin -= 1
188
+ ymax += 1
189
+ plot_w = W - pad_l - pad_r
190
+ plot_h = H - pad_t - pad_b
191
+ n = len(xs)
192
+
193
+ def y_to_px(v: float) -> float:
194
+ return pad_t + plot_h * (1 - (v - ymin) / (ymax - ymin))
195
+
196
+ title = (fig.layout.title.text or "").strip() if fig.layout.title else ""
197
+
198
+ kind = type(t).__name__.lower() # "Bar", "Scatter", etc.
199
+ bars: list[str] = []
200
+ path: list[str] = []
201
+
202
+ if "bar" in kind:
203
+ bw = max(2.0, plot_w / max(n, 1) * 0.8)
204
+ for i, v in enumerate(ys_num):
205
+ cx = pad_l + (i + 0.5) * (plot_w / max(n, 1))
206
+ top = y_to_px(v)
207
+ bottom = y_to_px(0)
208
+ y = min(top, bottom)
209
+ h = abs(bottom - top)
210
+ bars.append(
211
+ f'<rect x="{cx-bw/2:.1f}" y="{y:.1f}" '
212
+ f'width="{bw:.1f}" height="{h:.1f}" '
213
+ f'class="chart-accent" rx="2" />'
214
+ )
215
+ else: # treat as line
216
+ pts = [
217
+ f"{pad_l + i * (plot_w / max(n - 1, 1)):.1f},{y_to_px(v):.1f}"
218
+ for i, v in enumerate(ys_num)
219
+ ]
220
+ path.append(
221
+ f'<polyline points="{" ".join(pts)}" fill="none" '
222
+ f'class="chart-accent" stroke-width="2" />'
223
+ )
224
+ for p in pts:
225
+ x, y = p.split(",")
226
+ path.append(f'<circle cx="{x}" cy="{y}" r="3" class="chart-accent" />')
227
+
228
+ # Axes labels — show min, mid, max on Y
229
+ y_ticks = []
230
+ for v in (ymin, (ymin + ymax) / 2, ymax):
231
+ yp = y_to_px(v)
232
+ y_ticks.append(
233
+ f'<line x1="{pad_l}" y1="{yp:.1f}" x2="{W-pad_r}" y2="{yp:.1f}" class="chart-grid" />'
234
+ f'<text x="{pad_l-8:.1f}" y="{yp+3:.1f}" text-anchor="end" '
235
+ f'font-size="10" class="chart-muted">{_fmt(v)}</text>'
236
+ )
237
+
238
+ # X labels — every Nth so we don't overlap
239
+ step = max(1, n // 8)
240
+ x_labels = []
241
+ for i in range(0, n, step):
242
+ cx = pad_l + (i + 0.5) * (plot_w / max(n, 1))
243
+ label = str(xs[i])
244
+ if len(label) > 12:
245
+ label = label[:11] + "…"
246
+ x_labels.append(
247
+ f'<text x="{cx:.1f}" y="{H-pad_b+18}" text-anchor="middle" '
248
+ f'font-size="10" class="chart-muted">{label}</text>'
249
+ )
250
+
251
+ title_el = (
252
+ f'<text x="{W/2}" y="{20}" text-anchor="middle" '
253
+ f'font-size="13" font-weight="600" class="chart-ink">{title}</text>'
254
+ if title else ""
255
+ )
256
+
257
+ return (
258
+ f'<svg viewBox="0 0 {W} {H}" preserveAspectRatio="xMidYMid meet" '
259
+ f'style="width:100%;height:auto;display:block">'
260
+ f'{title_el}'
261
+ f'{"".join(y_ticks)}'
262
+ f'{"".join(bars)}{"".join(path)}'
263
+ f'{"".join(x_labels)}'
264
+ f'</svg>'
265
+ )
266
 
267
  def _empty(self, msg: str) -> str:
268
  return f'''<svg viewBox="0 0 600 200" preserveAspectRatio="xMidYMid meet"