@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/STYLE.md +439 -0
- package/__pycache__/build_page.cpython-314.pyc +0 -0
- package/__pycache__/chart.cpython-314.pyc +0 -0
- package/build_email.py +773 -0
- package/build_page.py +1237 -0
- package/build_post.py +291 -0
- package/chart.py +563 -0
- package/conversions.md +156 -0
- package/curriculum.md +294 -0
- package/ep01.md +144 -0
- package/ep01.mp3 +0 -0
- package/ep01.script.txt +71 -0
- package/ep02.md +141 -0
- package/ep02.script.txt +70 -0
- package/ep03.md +163 -0
- package/ep03.script.txt +61 -0
- package/ep04.md +192 -0
- package/ep04.mp3 +0 -0
- package/ep04.script.txt +69 -0
- package/feed.xml +3 -15
- package/fetch_context.py +123 -0
- package/generate_audio.py +120 -0
- package/package.json +1 -1
- package/publish_episode.py +367 -0
- package/setup.sh +23 -0
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("&", "&").replace("<", "<").replace(">", ">"))
|
|
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("&", "&").replace("<", "<").replace(">", ">"))
|
|
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())
|