@sdelsad/commodity-desk-daily 1.0.18 → 1.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/chart.py ADDED
@@ -0,0 +1,563 @@
1
+ #!/usr/bin/env python3
2
+ """Soft Commodity Trading - chart renderer.
3
+
4
+ One chart spec, two outputs:
5
+
6
+ * **SVG** for the episode page. It carries no fonts and no colours of its
7
+ own: it inherits the page's CSS variables, so it follows the light/dark
8
+ toggle automatically.
9
+ * **PNG** for the e-mail, because Gmail and Outlook do not render inline
10
+ SVG. Drawn with Pillow so nothing outside the standard toolkit is needed.
11
+
12
+ A spec is a small JSON object. Three shapes cover almost everything a
13
+ commodity episode needs:
14
+
15
+ ```json
16
+ {"type":"line","unit":"c/bu","x":["4 Aug","5 Aug","6 Aug"],
17
+ "series":[{"name":"Dec 26","values":[465,462,469]},
18
+ {"name":"Mar 27","values":[478,477,482]}]}
19
+ ```
20
+
21
+ ```json
22
+ {"type":"bar","unit":"$/t","x":["Freight","Insurance","Finance"],
23
+ "series":[{"name":"Cost","values":[38,4,9]}]}
24
+ ```
25
+
26
+ ```json
27
+ {"type":"waterfall","unit":"c/bu","steps":[
28
+ {"label":"Gross spread","value":100,"kind":"base"},
29
+ {"label":"Freight","value":-60},
30
+ {"label":"Costs","value":-10},
31
+ {"label":"Margin","kind":"total"}]}
32
+ ```
33
+
34
+ `null` inside `values` is a gap, not a zero: use it when a series genuinely
35
+ has no print for that day rather than inventing one.
36
+
37
+ CLI:
38
+
39
+ python3 chart.py spec.json --svg out.svg --png out.png
40
+ """
41
+
42
+ import argparse
43
+ import json
44
+ import math
45
+ import os
46
+ import sys
47
+
48
+ # ---------------------------------------------------------------- geometry --
49
+
50
+ W = 640
51
+ H = 330
52
+ PAD_L = 56
53
+ PAD_R = 18
54
+ PAD_T = 26
55
+ PAD_B = 44
56
+ LEGEND_H = 26
57
+
58
+ SERIES_VARS = ["--c-a", "--c-b", "--c-c"]
59
+ # fallbacks, also used for the PNG (light theme, which is what mail clients show)
60
+ SERIES_HEX = ["#1d4032", "#a8813c", "#496b86"]
61
+ INK = "#16110c"
62
+ INK_SOFT = "#4a4238"
63
+ LINE = "#ddd6c9"
64
+ PAPER = "#faf7f1"
65
+ POS = "#1d4032"
66
+ NEG = "#8a3b2f"
67
+
68
+
69
+ def nice_ticks(lo, hi, target=5):
70
+ """Round tick values that bracket [lo, hi]."""
71
+ if hi == lo:
72
+ hi = lo + (abs(lo) or 1) * 0.1
73
+ lo = lo - (abs(lo) or 1) * 0.1
74
+ raw = (hi - lo) / max(target, 2)
75
+ mag = 10 ** math.floor(math.log10(raw)) if raw > 0 else 1
76
+ for mult in (1, 2, 2.5, 5, 10):
77
+ if raw / mag <= mult:
78
+ step = mult * mag
79
+ break
80
+ else:
81
+ step = 10 * mag
82
+ start = math.floor(lo / step) * step
83
+ ticks, v = [], start
84
+ while v < hi + step * 0.5:
85
+ ticks.append(round(v, 10))
86
+ v += step
87
+ return ticks
88
+
89
+
90
+ def fmt_num(v):
91
+ if v is None:
92
+ return ""
93
+ a = abs(v)
94
+ if a >= 1000:
95
+ return f"{v:,.0f}"
96
+ if a >= 100:
97
+ return f"{v:.0f}"
98
+ if a >= 10:
99
+ return f"{v:.1f}".rstrip("0").rstrip(".")
100
+ return f"{v:.2f}".rstrip("0").rstrip(".")
101
+
102
+
103
+ def _rebase(values):
104
+ base = next((v for v in values if v not in (None, 0)), None)
105
+ if base is None:
106
+ return values
107
+ return [None if v is None else round(v / base * 100, 2) for v in values]
108
+
109
+
110
+ def edge_anchor(x, left, right, svg=True):
111
+ """Keep the first and last x labels inside the frame."""
112
+ if x - left < 26:
113
+ return ("start" if svg else "la")
114
+ if right - x < 26:
115
+ return ("end" if svg else "ra")
116
+ return ("middle" if svg else "ma")
117
+
118
+
119
+ def thin(labels, keep=8):
120
+ """Return the indices of at most `keep` labels, evenly spread, ends kept."""
121
+ n = len(labels)
122
+ if n <= keep:
123
+ return list(range(n))
124
+ step = (n - 1) / (keep - 1)
125
+ return sorted({int(round(i * step)) for i in range(keep)} | {0, n - 1})
126
+
127
+
128
+ class Frame:
129
+ """Shared layout maths, so the SVG and the PNG agree pixel for pixel."""
130
+
131
+ def __init__(self, spec):
132
+ self.spec = spec
133
+ self.kind = spec.get("type", "line")
134
+ self.unit = spec.get("unit", "")
135
+ if self.kind == "waterfall":
136
+ self.labels, self.bars, lo, hi = self._waterfall()
137
+ self.series = []
138
+ else:
139
+ self.labels = [str(x) for x in spec.get("x", [])]
140
+ self.series = spec.get("series", [])
141
+ if spec.get("mode") == "index":
142
+ # rebase every series to 100 at its first print, so two markets
143
+ # trading at different levels can be compared on one axis
144
+ self.series = [dict(s, values=_rebase(s.get("values", [])))
145
+ for s in self.series]
146
+ self.unit = self.unit or "index, first day = 100"
147
+ vals = [v for s in self.series for v in s.get("values", []) if v is not None]
148
+ if not vals:
149
+ vals = [0, 1]
150
+ lo, hi = min(vals), max(vals)
151
+ if self.kind == "bar":
152
+ lo = min(lo, 0)
153
+ hi = max(hi, 0)
154
+ else:
155
+ pad = (hi - lo) * 0.12 or abs(hi) * 0.1 or 1
156
+ lo, hi = lo - pad, hi + pad
157
+ self.ticks = nice_ticks(lo, hi)
158
+ self.lo, self.hi = min(self.ticks[0], lo), max(self.ticks[-1], hi)
159
+ self.legend = len([s for s in self.series if s.get("name")]) > 1
160
+ self.h = H + (LEGEND_H if self.legend else 0)
161
+ self.top = PAD_T + (LEGEND_H if self.legend else 0)
162
+ self.bottom = self.h - PAD_B
163
+ self.left = PAD_L
164
+ self.right = W - PAD_R
165
+
166
+ def _waterfall(self):
167
+ steps = self.spec.get("steps", [])
168
+ bars, running, labels = [], 0.0, []
169
+ lo = hi = 0.0
170
+ for st in steps:
171
+ label = st.get("label", "")
172
+ kind = st.get("kind", "delta")
173
+ if kind == "total":
174
+ bars.append((label, 0.0, running, "total"))
175
+ lo, hi = min(lo, 0, running), max(hi, 0, running)
176
+ elif kind == "base":
177
+ v = float(st.get("value", 0))
178
+ bars.append((label, 0.0, v, "base"))
179
+ running = v
180
+ lo, hi = min(lo, 0, v), max(hi, 0, v)
181
+ else:
182
+ v = float(st.get("value", 0))
183
+ start, end = running, running + v
184
+ bars.append((label, start, end, "up" if v >= 0 else "down"))
185
+ running = end
186
+ lo, hi = min(lo, start, end), max(hi, start, end)
187
+ labels.append(label)
188
+ span = (hi - lo) or 1
189
+ # never open a gap below zero when nothing in the chart is negative
190
+ lo_pad = lo if lo >= 0 else lo - span * 0.08
191
+ return labels, bars, lo_pad, hi + span * 0.08
192
+
193
+ # -- coordinate helpers ------------------------------------------------
194
+ def y(self, v):
195
+ f = (v - self.lo) / (self.hi - self.lo or 1)
196
+ return self.bottom - f * (self.bottom - self.top)
197
+
198
+ def xs(self):
199
+ n = max(len(self.labels), 1)
200
+ if self.kind in ("bar", "waterfall") or n == 1:
201
+ slot = (self.right - self.left) / n
202
+ return [self.left + slot * (i + 0.5) for i in range(n)]
203
+ step = (self.right - self.left) / (n - 1)
204
+ return [self.left + step * i for i in range(n)]
205
+
206
+ def band(self):
207
+ return (self.right - self.left) / max(len(self.labels), 1)
208
+
209
+
210
+ # --------------------------------------------------------------------- SVG --
211
+
212
+ def to_svg(spec):
213
+ f = Frame(spec)
214
+ xs = f.xs()
215
+ esc = lambda s: (str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
216
+ out = [
217
+ f'<svg class="chart" viewBox="0 0 {W} {f.h}" width="100%" '
218
+ f'preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" role="img">',
219
+ # Type is sized in viewBox units, so it shrinks with the chart. A phone
220
+ # renders these 640 units into ~360 CSS pixels, which turns 12px into 7px
221
+ # — unreadable. The sizes below are bumped back up under a media query,
222
+ # which works because an inline SVG's <style> is part of the document.
223
+ "<style>"
224
+ ".chart{--c-a:var(--accent,#1d4032);--c-b:var(--gold,#a8813c);--c-c:#4a6f8c;"
225
+ "font-family:inherit}"
226
+ "html[data-theme=\"dark\"] .chart{--c-c:#7ba3c4}"
227
+ ".chart .grid{stroke:var(--line,#ddd6c9);stroke-width:1}"
228
+ ".chart .axis{fill:var(--ink-soft,#4a4238);font-size:12px}"
229
+ ".chart .unit{fill:var(--ink-soft,#4a4238);font-size:11px;letter-spacing:.06em;"
230
+ "text-transform:uppercase}"
231
+ ".chart .ln{fill:none;stroke-width:2.25;stroke-linejoin:round;stroke-linecap:round}"
232
+ ".chart .lg{fill:var(--ink,#16110c);font-size:12.5px}"
233
+ ".chart .vlabel{fill:var(--ink,#16110c);font-size:11.5px;font-weight:600}"
234
+ "@media (max-width:900px){"
235
+ ".chart .axis{font-size:14px}.chart .unit{font-size:13px}"
236
+ ".chart .lg{font-size:14.5px}.chart .vlabel{font-size:13.5px}"
237
+ ".chart .ln{stroke-width:2.6}}"
238
+ "@media (max-width:640px){"
239
+ ".chart .axis{font-size:16px}.chart .unit{font-size:14px}"
240
+ ".chart .lg{font-size:16px}.chart .vlabel{font-size:15px}"
241
+ ".chart .ln{stroke-width:3.1}}"
242
+ "</style>",
243
+ ]
244
+
245
+ # horizontal grid + y labels
246
+ for t in f.ticks:
247
+ y = f.y(t)
248
+ faint = ' opacity=".45"' if t else ""
249
+ out.append(f'<line class="grid" x1="{f.left}" y1="{y:.1f}" x2="{f.right}" '
250
+ f'y2="{y:.1f}"{faint}/>')
251
+ out.append(f'<text class="axis" x="{f.left - 10}" y="{y + 4:.1f}" '
252
+ f'text-anchor="end">{esc(fmt_num(t))}</text>')
253
+ if f.unit:
254
+ long_unit = len(f.unit) > 8
255
+ ux, uanc = (f.right, "end") if long_unit else (f.left - 10, "end")
256
+ out.append(f'<text class="unit" x="{ux}" y="{f.top - 10}" '
257
+ f'text-anchor="{uanc}">{esc(f.unit)}</text>')
258
+
259
+ # x labels
260
+ for i in thin(f.labels):
261
+ if i < len(xs):
262
+ anc = edge_anchor(xs[i], 0, W)
263
+ out.append(f'<text class="axis" x="{xs[i]:.1f}" y="{f.bottom + 20}" '
264
+ f'text-anchor="{anc}">{esc(f.labels[i])}</text>')
265
+
266
+ if f.kind == "waterfall":
267
+ bw = min(f.band() * 0.62, 74)
268
+ for i, (label, a, b, kind) in enumerate(f.bars):
269
+ y0, y1 = f.y(a), f.y(b)
270
+ top, height = min(y0, y1), max(abs(y1 - y0), 2)
271
+ fill = {"up": "var(--c-a)", "down": NEG, "base": "var(--c-a)",
272
+ "total": "var(--c-b)"}[kind]
273
+ op = ".92" if kind in ("base", "total") else ".78"
274
+ out.append(f'<rect x="{xs[i] - bw / 2:.1f}" y="{top:.1f}" width="{bw:.1f}" '
275
+ f'height="{height:.1f}" rx="2" fill="{fill}" opacity="{op}"/>')
276
+ val = b if kind in ("base", "total") else b - a
277
+ out.append(f'<text class="vlabel" x="{xs[i]:.1f}" y="{top - 7:.1f}" '
278
+ f'text-anchor="middle">{esc(("+" if val > 0 and kind == "delta" else "") + fmt_num(val))}</text>')
279
+ if i + 1 < len(f.bars) and kind != "total":
280
+ out.append(f'<line class="grid" x1="{xs[i] + bw / 2:.1f}" y1="{f.y(b):.1f}" '
281
+ f'x2="{xs[i + 1] - bw / 2:.1f}" y2="{f.y(b):.1f}" '
282
+ f'stroke-dasharray="3 3" opacity=".5"/>')
283
+
284
+ elif f.kind == "bar":
285
+ n = max(len(f.series), 1)
286
+ bw = min(f.band() * 0.7 / n, 46)
287
+ zero = f.y(0)
288
+ for si, s in enumerate(f.series):
289
+ col = f"var({SERIES_VARS[si % 3]})"
290
+ for i, v in enumerate(s.get("values", [])):
291
+ if v is None or i >= len(xs):
292
+ continue
293
+ cx = xs[i] + (si - (n - 1) / 2) * bw
294
+ y = f.y(v)
295
+ out.append(f'<rect x="{cx - bw * 0.44:.1f}" y="{min(y, zero):.1f}" '
296
+ f'width="{bw * 0.88:.1f}" height="{max(abs(zero - y), 1.5):.1f}" '
297
+ f'rx="2" fill="{col}" opacity=".85"/>')
298
+ out.append(f'<line class="grid" x1="{f.left}" y1="{zero:.1f}" x2="{f.right}" '
299
+ f'y2="{zero:.1f}"/>')
300
+
301
+ else: # line
302
+ for si, s in enumerate(f.series):
303
+ col = f"var({SERIES_VARS[si % 3]})"
304
+ seg, path = [], []
305
+ for i, v in enumerate(s.get("values", [])):
306
+ if v is None or i >= len(xs):
307
+ if seg:
308
+ path.append(seg)
309
+ seg = []
310
+ continue
311
+ seg.append((xs[i], f.y(v)))
312
+ if seg:
313
+ path.append(seg)
314
+ for run in path:
315
+ d = "M" + " L".join(f"{x:.1f} {y:.1f}" for x, y in run)
316
+ out.append(f'<path class="ln" d="{d}" stroke="{col}"/>')
317
+ if len(run) == 1:
318
+ out.append(f'<circle cx="{run[0][0]:.1f}" cy="{run[0][1]:.1f}" r="3" '
319
+ f'fill="{col}"/>')
320
+ vals = [v for v in s.get("values", []) if v is not None]
321
+ if vals and path:
322
+ lx, ly = path[-1][-1]
323
+ out.append(f'<circle cx="{lx:.1f}" cy="{ly:.1f}" r="3.4" fill="{col}"/>')
324
+
325
+ # legend
326
+ if f.legend:
327
+ x = f.left
328
+ for si, s in enumerate(f.series):
329
+ name = s.get("name") or f"Series {si + 1}"
330
+ col = f"var({SERIES_VARS[si % 3]})"
331
+ out.append(f'<rect x="{x}" y="{PAD_T - 4}" width="18" height="3" rx="1.5" fill="{col}"/>')
332
+ out.append(f'<text class="lg" x="{x + 25}" y="{PAD_T + 3}">{esc(name)}</text>')
333
+ x += 40 + 7.2 * len(name)
334
+ out.append("</svg>")
335
+ return "\n".join(out)
336
+
337
+
338
+ def figure_html(spec):
339
+ """The SVG wrapped in a captioned <figure>, ready to drop into a page."""
340
+ esc = lambda s: (str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
341
+ title = spec.get("title", "")
342
+ caption = spec.get("caption", "")
343
+ source = spec.get("source", "")
344
+ bits = [f'<figure class="chartfig">']
345
+ if title:
346
+ bits.append(f'<figcaption class="charttitle">{esc(title)}</figcaption>')
347
+ bits.append(to_svg(spec))
348
+ tail = []
349
+ if caption:
350
+ tail.append(esc(caption))
351
+ if source:
352
+ tail.append(f'<span class="chartsrc">{esc(source)}</span>')
353
+ if tail:
354
+ bits.append(f'<figcaption class="chartcap">{" ".join(tail)}</figcaption>')
355
+ bits.append("</figure>")
356
+ return "\n".join(bits)
357
+
358
+
359
+ CHART_CSS = """
360
+ .chartfig{margin:36px 0;padding:22px 22px 16px;border:1px solid var(--line);
361
+ border-radius:10px;background:var(--tint-2,var(--tint))}
362
+ .charttitle{font:600 17.5px/1.3 var(--serif,Georgia,serif);color:var(--ink);
363
+ margin:0 0 16px;letter-spacing:-.012em}
364
+ .chartcap{font:italic 15.5px/1.55 var(--serif,Georgia,serif);color:var(--ink-soft);
365
+ margin:14px 2px 0;text-wrap:pretty}
366
+ .chartsrc{display:block;font:500 11.5px/1.5 var(--sans,ui-sans-serif,system-ui,sans-serif);
367
+ font-style:normal;letter-spacing:.05em;opacity:.8;margin-top:7px}
368
+ .chart{display:block;overflow:visible;font-variant-numeric:tabular-nums}
369
+ @media (max-width:640px){
370
+ .charttitle{font-size:16.5px;margin-bottom:12px}
371
+ .chartcap{font-size:15px}
372
+ }
373
+ """
374
+
375
+ # --------------------------------------------------------------------- PNG --
376
+
377
+ def to_png(spec, path, scale=2):
378
+ """Render the same chart to a PNG for e-mail. Returns the path, or None."""
379
+ try:
380
+ from PIL import Image, ImageDraw, ImageFont
381
+ except ImportError:
382
+ return None
383
+
384
+ f = Frame(spec)
385
+ xs = f.xs()
386
+ title = spec.get("title", "")
387
+ caption = spec.get("caption", "")
388
+ source = spec.get("source", "")
389
+
390
+ def fnt(size, bold=False):
391
+ """A scalable font, wherever this happens to be running.
392
+
393
+ The old list only knew the container's Linux paths; anywhere else it
394
+ fell through to load_default(), which used to ignore the size and drew
395
+ every label at the same unreadable 11px. The last resort now asks for
396
+ a size too, so a chart built on a laptop matches one built in CI.
397
+ """
398
+ for p in ("/usr/share/fonts/truetype/google-fonts/Poppins-%s.ttf"
399
+ % ("Medium" if bold else "Regular"),
400
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans%s.ttf"
401
+ % ("-Bold" if bold else ""),
402
+ "C:/Windows/Fonts/%s.ttf" % ("segoeuib" if bold else "segoeui"),
403
+ "C:/Windows/Fonts/%s.ttf" % ("arialbd" if bold else "arial"),
404
+ "/System/Library/Fonts/Supplemental/%s.ttf"
405
+ % ("Arial Bold" if bold else "Arial"),
406
+ "/Library/Fonts/Arial.ttf"):
407
+ if os.path.exists(p):
408
+ return ImageFont.truetype(p, int(size * scale))
409
+ try: # Pillow >= 10.1 scales this one
410
+ return ImageFont.load_default(size=int(size * scale))
411
+ except TypeError:
412
+ return ImageFont.load_default()
413
+
414
+ # Titles, captions and sources are prose, and prose does not fit on one
415
+ # line. Measure and wrap them first, on a scratch canvas, so the header and
416
+ # footer are tall enough for however many lines it turns out to be.
417
+ TEXT_L = 14
418
+ TEXT_W = W - TEXT_L - 12
419
+ _scratch = ImageDraw.Draw(Image.new("RGB", (1, 1)))
420
+
421
+ def wrap(s, size, bold=False):
422
+ if not s:
423
+ return []
424
+ font = fnt(size, bold)
425
+ lines, cur = [], ""
426
+ for word in str(s).split():
427
+ trial = (cur + " " + word).strip()
428
+ if not cur or _scratch.textlength(trial, font=font) / scale <= TEXT_W:
429
+ cur = trial
430
+ else:
431
+ lines.append(cur)
432
+ cur = word
433
+ if cur:
434
+ lines.append(cur)
435
+ return lines
436
+
437
+ title_lines = wrap(title, 19, bold=True)
438
+ cap_lines = wrap(caption, 15)
439
+ src_lines = wrap(source, 12)
440
+ head = (8 + 26 * len(title_lines)) if title_lines else 8
441
+ foot = 8 + 22 * len(cap_lines) + 17 * len(src_lines) + 8
442
+ w, h = W, f.h + head + foot
443
+
444
+ im = Image.new("RGB", (w * scale, h * scale), PAPER)
445
+ d = ImageDraw.Draw(im)
446
+ S = lambda v: v * scale
447
+ off = head
448
+
449
+ def text(x, y, s, size=12, fill=INK_SOFT, anchor="la", bold=False):
450
+ d.text((S(x), S(y)), str(s), font=fnt(size, bold), fill=fill, anchor=anchor)
451
+
452
+ for n, line in enumerate(title_lines):
453
+ text(TEXT_L, 6 + 26 * n, line, 19, INK, bold=True)
454
+
455
+ for t in f.ticks:
456
+ y = f.y(t) + off
457
+ d.line([(S(f.left), S(y)), (S(f.right), S(y))], fill=LINE, width=max(1, scale // 2))
458
+ text(f.left - 10, y, fmt_num(t), 15, INK_SOFT, anchor="rm")
459
+ if f.unit:
460
+ if len(f.unit) > 8:
461
+ text(f.right, f.top + off - 12, f.unit.upper(), 12, INK_SOFT, anchor="rm")
462
+ else:
463
+ text(f.left - 10, f.top + off - 12, f.unit.upper(), 12, INK_SOFT, anchor="rm")
464
+ for i in thin(f.labels):
465
+ if i < len(xs):
466
+ text(xs[i], f.bottom + off + 16, f.labels[i], 15, INK_SOFT,
467
+ anchor=edge_anchor(xs[i], 0, W, svg=False))
468
+
469
+ if f.kind == "waterfall":
470
+ bw = min(f.band() * 0.62, 74)
471
+ for i, (label, a, b, kind) in enumerate(f.bars):
472
+ y0, y1 = f.y(a) + off, f.y(b) + off
473
+ top, bot = min(y0, y1), max(y0, y1)
474
+ fill = {"up": POS, "down": NEG, "base": POS, "total": SERIES_HEX[1]}[kind]
475
+ d.rectangle([S(xs[i] - bw / 2), S(top), S(xs[i] + bw / 2), S(max(bot, top + 2))],
476
+ fill=fill)
477
+ val = b if kind in ("base", "total") else b - a
478
+ lbl = ("+" if val > 0 and kind == "delta" else "") + fmt_num(val)
479
+ text(xs[i], top - 7, lbl, 14, INK, anchor="md", bold=True)
480
+ if i + 1 < len(f.bars) and kind != "total":
481
+ d.line([(S(xs[i] + bw / 2), S(y1)), (S(xs[i + 1] - bw / 2), S(y1))],
482
+ fill=LINE, width=max(1, scale))
483
+ elif f.kind == "bar":
484
+ n = max(len(f.series), 1)
485
+ bw = min(f.band() * 0.7 / n, 46)
486
+ zero = f.y(0) + off
487
+ for si, s in enumerate(f.series):
488
+ col = SERIES_HEX[si % 3]
489
+ for i, v in enumerate(s.get("values", [])):
490
+ if v is None or i >= len(xs):
491
+ continue
492
+ cx = xs[i] + (si - (n - 1) / 2) * bw
493
+ y = f.y(v) + off
494
+ d.rectangle([S(cx - bw * 0.44), S(min(y, zero)),
495
+ S(cx + bw * 0.44), S(max(y, zero))], fill=col)
496
+ d.line([(S(f.left), S(zero)), (S(f.right), S(zero))], fill=LINE, width=scale)
497
+ else:
498
+ for si, s in enumerate(f.series):
499
+ col = SERIES_HEX[si % 3]
500
+ run = []
501
+ for i, v in enumerate(s.get("values", [])):
502
+ if v is None or i >= len(xs):
503
+ if len(run) > 1:
504
+ d.line([(S(x), S(y)) for x, y in run], fill=col,
505
+ width=max(2, int(2.2 * scale)), joint="curve")
506
+ run = []
507
+ continue
508
+ run.append((xs[i], f.y(v) + off))
509
+ if len(run) > 1:
510
+ d.line([(S(x), S(y)) for x, y in run], fill=col,
511
+ width=max(2, int(2.2 * scale)), joint="curve")
512
+ if run:
513
+ x, y = run[-1]
514
+ r = 3.4 * scale
515
+ d.ellipse([S(x) - r, S(y) - r, S(x) + r, S(y) + r], fill=col)
516
+
517
+ if f.legend:
518
+ x = f.left
519
+ for si, s in enumerate(f.series):
520
+ name = s.get("name") or f"Series {si + 1}"
521
+ col = SERIES_HEX[si % 3]
522
+ d.rectangle([S(x), S(PAD_T + off - 6), S(x + 18), S(PAD_T + off - 3)], fill=col)
523
+ text(x + 27, PAD_T + off - 4, name, 15, INK, anchor="lm")
524
+ x += 44 + 8.6 * len(name)
525
+
526
+ y = f.h + off + 8
527
+ for line in cap_lines:
528
+ text(TEXT_L, y, line, 15, INK_SOFT)
529
+ y += 22
530
+ for line in src_lines:
531
+ text(TEXT_L, y, line, 12, INK_SOFT)
532
+ y += 17
533
+
534
+ im.save(path, "PNG", optimize=True)
535
+ return path
536
+
537
+
538
+ # --------------------------------------------------------------------- CLI --
539
+
540
+ def main():
541
+ ap = argparse.ArgumentParser(description="Render a chart spec to SVG and/or PNG.")
542
+ ap.add_argument("spec", help="JSON file, or '-' for stdin")
543
+ ap.add_argument("--svg")
544
+ ap.add_argument("--png")
545
+ ap.add_argument("--scale", type=int, default=2)
546
+ a = ap.parse_args()
547
+
548
+ raw = sys.stdin.read() if a.spec == "-" else open(a.spec, encoding="utf-8").read()
549
+ spec = json.loads(raw)
550
+
551
+ if a.svg:
552
+ open(a.svg, "w", encoding="utf-8").write(figure_html(spec))
553
+ print(f"[chart] {a.svg}")
554
+ if a.png:
555
+ got = to_png(spec, a.png, a.scale)
556
+ print(f"[chart] {got}" if got else "[chart] PNG skipped (Pillow missing)")
557
+ if not a.svg and not a.png:
558
+ print(figure_html(spec))
559
+ return 0
560
+
561
+
562
+ if __name__ == "__main__":
563
+ sys.exit(main())