@topmindspace/tms-skills 0.1.9 → 0.1.11

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.
@@ -0,0 +1,341 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Fill placeholder chart SVGs from REPORT_MODEL (core types for showcase).
4
+
5
+ Used after render_from_model.py — follows extract_snippet chart patterns
6
+ (bar / hbar / line / area / donut / waterfall / stack). Not a public CLI.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ MODEL_RE = re.compile(r'window\.REPORT_MODEL = (\{[\s\S]*?\});')
16
+ PLACEHOLDER_SVG_RE = re.compile(
17
+ r'<svg class="chart" data-chart="([^"]+)" viewBox="0 0 560 220">\s*'
18
+ r'<!--[\s\S]*?-->\s*'
19
+ r'<text[^>]*>[^<]*见模型数据</text>\s*'
20
+ r'</svg>',
21
+ re.M,
22
+ )
23
+
24
+
25
+ def esc(s) -> str:
26
+ return (str(s)
27
+ .replace('&', '&amp;')
28
+ .replace('<', '&lt;')
29
+ .replace('>', '&gt;')
30
+ .replace('"', '&quot;'))
31
+
32
+
33
+ def svg_bar(labels, values, max_v=None, unit='') -> str:
34
+ max_v = float(max_v or max(values) * 1.15 or 1)
35
+ n = len(labels)
36
+ gap, left, right, base, top = 24, 60, 540, 170, 30
37
+ usable = right - left
38
+ bw = max(28, (usable - gap * (n - 1)) / n * 0.55)
39
+ step = usable / n
40
+ parts = [f'<line x1="46" y1="{base}" x2="540" y2="{base}" class="s-bds" stroke-width="1"/>']
41
+ for i, (lb, v) in enumerate(zip(labels, values)):
42
+ v = float(v)
43
+ h = (v / max_v) * (base - top)
44
+ x = left + i * step + (step - bw) / 2
45
+ y = base - h
46
+ cls = 'f-acc' if i >= n - 2 else 'f-s3'
47
+ cx = x + bw / 2
48
+ parts.append(
49
+ f'<rect data-anim="grow" x="{x:.1f}" y="{y:.1f}" width="{bw:.1f}" '
50
+ f'height="{h:.1f}" rx="6" class="{cls}"><title>{esc(lb)} {v:g}{esc(unit)}</title></rect>'
51
+ )
52
+ parts.append(
53
+ f'<text data-anim="fade" x="{cx:.1f}" y="{base + 22}" text-anchor="middle" '
54
+ f'class="f-txt3" font-size="11">{esc(lb)}</text>'
55
+ )
56
+ parts.append(
57
+ f'<text data-anim="fade" x="{cx:.1f}" y="{y - 8:.1f}" text-anchor="middle" '
58
+ f'class="f-txt2" font-size="12" font-weight="600">{v:g}{esc(unit)}</text>'
59
+ )
60
+ return ('<svg class="chart" data-chart="bar" viewBox="0 0 560 220">\n '
61
+ + '\n '.join(parts) + '\n </svg>')
62
+
63
+
64
+ def svg_hbar(labels, values, max_v=None, unit='') -> str:
65
+ max_v = float(max_v or max(values) or 1)
66
+ n = len(labels)
67
+ row_h = min(36, 180 / max(n, 1))
68
+ y0 = 16
69
+ slot_x, slot_w = 130, 340
70
+ parts = []
71
+ for i, (lb, v) in enumerate(zip(labels, values)):
72
+ v = float(v)
73
+ y = y0 + i * row_h
74
+ w = (v / max_v) * slot_w
75
+ parts.append(f'<text x="0" y="{y + 14:.1f}" class="f-txt2" font-size="12">{esc(lb)}</text>')
76
+ parts.append(
77
+ f'<rect x="{slot_x}" y="{y + 4:.1f}" width="{slot_w}" height="15" rx="7.5" class="f-s2"/>'
78
+ )
79
+ parts.append(
80
+ f'<rect data-anim="grow" x="{slot_x}" y="{y + 4:.1f}" width="{w:.1f}" height="15" '
81
+ f'rx="7.5" class="f-acc"><title>{esc(lb)} {v:g}{esc(unit)}</title></rect>'
82
+ )
83
+ parts.append(
84
+ f'<text x="{slot_x + slot_w + 12}" y="{y + 16:.1f}" class="f-txt" '
85
+ f'font-size="12" font-weight="600">{v:g}{esc(unit)}</text>'
86
+ )
87
+ return ('<svg class="chart" data-chart="hbar" viewBox="0 0 560 220">\n '
88
+ + '\n '.join(parts) + '\n </svg>')
89
+
90
+
91
+ def svg_line(labels, values, max_v=None, unit='', area=False) -> str:
92
+ max_v = float(max_v or max(values) * 1.2 or 1)
93
+ n = len(labels)
94
+ left, right, base, top = 60, 520, 180, 40
95
+ xs = [left + i * (right - left) / max(n - 1, 1) for i in range(n)]
96
+ ys = [base - (float(v) / max_v) * (base - top) for v in values]
97
+ pts = ' '.join(f'{x:.1f},{y:.1f}' for x, y in zip(xs, ys))
98
+ path = 'L'.join(f'{x:.1f},{y:.1f}' for x, y in zip(xs, ys))
99
+ path = 'M' + path[1:] if path.startswith('L') else 'M' + path
100
+ # fix: build properly
101
+ path = 'M' + ' L'.join(f'{x:.1f},{y:.1f}' for x, y in zip(xs, ys))
102
+ ct = 'area' if area else 'line'
103
+ parts = [
104
+ f'<line x1="46" y1="{base}" x2="540" y2="{base}" class="s-bds" stroke-width="1"/>',
105
+ f'<line x1="46" y1="130" x2="540" y2="130" class="s-bds" stroke-width="1" stroke-dasharray="3 5"/>',
106
+ f'<line x1="46" y1="80" x2="540" y2="80" class="s-bds" stroke-width="1" stroke-dasharray="3 5"/>',
107
+ ]
108
+ if area:
109
+ area_path = path + f' L{xs[-1]:.1f},{base} L{xs[0]:.1f},{base} Z'
110
+ parts.append(f'<path d="{area_path}" class="f-acc" fill-opacity=".12"/>')
111
+ parts.append(
112
+ f'<path data-draw d="{path}" class="f-none s-acc" stroke-width="2.5" '
113
+ f'stroke-linecap="round" stroke-linejoin="round"/>'
114
+ )
115
+ for i, (x, y, lb, v) in enumerate(zip(xs, ys, labels, values)):
116
+ parts.append(
117
+ f'<circle cx="{x:.1f}" cy="{y:.1f}" r="4" class="f-acc">'
118
+ f'<title>{esc(lb)} · {float(v):g}{esc(unit)}</title></circle>'
119
+ )
120
+ parts.append(
121
+ f'<text x="{x:.1f}" y="{base + 20}" text-anchor="middle" '
122
+ f'class="f-txt3" font-size="11">{esc(lb)}</text>'
123
+ )
124
+ return (f'<svg class="chart" data-chart="{ct}" viewBox="0 0 560 220">\n '
125
+ + '\n '.join(parts) + '\n </svg>')
126
+
127
+
128
+ def svg_donut(labels, values, center_label='合计', unit='') -> str:
129
+ total = sum(float(v) for v in values) or 1
130
+ # multi-segment donut: layered sweeps (simplified visual with chips)
131
+ # Use concentric accent arc for first + legend chips for all (presentation craft)
132
+ r, cx, cy, circ = 54, 70, 70, 339.3
133
+ parts = [
134
+ f'<circle cx="{cx}" cy="{cy}" r="{r}" class="f-none s-bds" stroke-width="15"/>'
135
+ ]
136
+ # Draw up to 3 sweeps stacked by offset — for clarity use primary accent for largest
137
+ ordered = sorted(zip(labels, values), key=lambda x: -float(x[1]))
138
+ offset = 0.0
139
+ colors = ['s-acc', 's-txt2', 's-txt3', 's-bds']
140
+ for i, (lb, v) in enumerate(ordered):
141
+ p = float(v) / total
142
+ dash = circ * p
143
+ gap = circ - dash
144
+ rot = -90 + offset * 360
145
+ cls = colors[i % len(colors)]
146
+ parts.append(
147
+ f'<circle data-sweep data-circ="{circ}" data-p="{p:.4f}" cx="{cx}" cy="{cy}" r="{r}" '
148
+ f'class="f-none {cls}" stroke-width="15" stroke-dasharray="{dash:.1f} {gap:.1f}" '
149
+ f'stroke-linecap="butt" transform="rotate({rot:.1f} {cx} {cy})">'
150
+ f'<title>{esc(lb)} {float(v):g}{esc(unit)}</title></circle>'
151
+ )
152
+ offset += p
153
+ top = ordered[0]
154
+ pct = float(top[1]) / total * 100
155
+ parts.append(
156
+ f'<text x="{cx}" y="68" text-anchor="middle" class="f-txt" font-size="22" '
157
+ f'font-weight="600" data-count="{pct:.0f}" data-suffix="%">{pct:.0f}%</text>'
158
+ )
159
+ parts.append(
160
+ f'<text x="{cx}" y="88" text-anchor="middle" class="f-txt3" font-size="11">'
161
+ f'{esc(center_label)}</text>'
162
+ )
163
+ # viewBox larger with legend beside — keep 560x220 and center the donut
164
+ inner = '\n '.join(parts)
165
+ return (
166
+ f'<svg class="chart" data-chart="donut" viewBox="0 0 560 220">\n'
167
+ f' <g transform="translate(210,40)">\n {inner}\n </g>\n'
168
+ f' </svg>'
169
+ )
170
+
171
+
172
+ def svg_waterfall(labels, values, unit='') -> str:
173
+ # values: start, deltas..., end (end should equal start+sum(deltas))
174
+ n = len(labels)
175
+ if n < 2:
176
+ return svg_bar(labels, values, unit=unit)
177
+ start = float(values[0])
178
+ end = float(values[-1])
179
+ mids = [float(v) for v in values[1:-1]]
180
+ # running level after each step
181
+ levels = [start]
182
+ cur = start
183
+ for d in mids:
184
+ cur += d
185
+ levels.append(cur)
186
+ levels.append(end)
187
+ max_v = max(levels) * 1.15 or 1
188
+ min_v = min(0, min(levels))
189
+ span = max_v - min_v or 1
190
+ left, right, base, top = 50, 540, 190, 30
191
+ usable = right - left
192
+ bw = min(70, usable / n * 0.55)
193
+ step = usable / n
194
+
195
+ def y_of(val):
196
+ return base - ((val - min_v) / span) * (base - top)
197
+
198
+ parts = [f'<line x1="40" y1="{base}" x2="540" y2="{base}" class="s-bds" stroke-width="1"/>']
199
+ # start bar
200
+ y1, y0 = y_of(start), base
201
+ h = abs(y0 - y1)
202
+ x = left + (step - bw) / 2
203
+ parts.append(
204
+ f'<rect data-anim="grow" x="{x:.1f}" y="{min(y0,y1):.1f}" width="{bw:.1f}" '
205
+ f'height="{h:.1f}" rx="5" class="f-s3"/>'
206
+ )
207
+ parts.append(
208
+ f'<text x="{x + bw/2:.1f}" y="{min(y0,y1) - 8:.1f}" text-anchor="middle" '
209
+ f'class="f-txt2" font-size="12" font-weight="600">{start:g}{esc(unit)}</text>'
210
+ )
211
+ parts.append(
212
+ f'<text x="{x + bw/2:.1f}" y="{base + 16}" text-anchor="middle" '
213
+ f'class="f-txt3" font-size="11">{esc(labels[0])}</text>'
214
+ )
215
+ prev = start
216
+ for i, d in enumerate(mids):
217
+ idx = i + 1
218
+ nxt = prev + d
219
+ ya, yb = y_of(prev), y_of(nxt)
220
+ x = left + idx * step + (step - bw) / 2
221
+ top_y, bot_y = min(ya, yb), max(ya, yb)
222
+ cls = 'f-acc' if d >= 0 else 'f-txt3'
223
+ sign = f'+{d:g}' if d >= 0 else f'{d:g}'
224
+ parts.append(
225
+ f'<rect data-anim="grow" x="{x:.1f}" y="{top_y:.1f}" width="{bw:.1f}" '
226
+ f'height="{max(bot_y - top_y, 2):.1f}" rx="5" class="{cls}"/>'
227
+ )
228
+ parts.append(
229
+ f'<text x="{x + bw/2:.1f}" y="{top_y - 8:.1f}" text-anchor="middle" '
230
+ f'class="{cls}" font-size="12" font-weight="600">{sign}{esc(unit)}</text>'
231
+ )
232
+ parts.append(
233
+ f'<text x="{x + bw/2:.1f}" y="{base + 16}" text-anchor="middle" '
234
+ f'class="f-txt3" font-size="11">{esc(labels[idx])}</text>'
235
+ )
236
+ # connector
237
+ if i < len(mids):
238
+ parts.append(
239
+ f'<line x1="{x - (step - bw)/2:.1f}" y1="{ya:.1f}" '
240
+ f'x2="{x:.1f}" y2="{ya:.1f}" class="s-bds" stroke-width="1" stroke-dasharray="3 3"/>'
241
+ )
242
+ prev = nxt
243
+ # end bar
244
+ x = left + (n - 1) * step + (step - bw) / 2
245
+ ye = y_of(end)
246
+ parts.append(
247
+ f'<rect data-anim="grow" x="{x:.1f}" y="{ye:.1f}" width="{bw:.1f}" '
248
+ f'height="{base - ye:.1f}" rx="5" class="f-acc"/>'
249
+ )
250
+ parts.append(
251
+ f'<text x="{x + bw/2:.1f}" y="{ye - 8:.1f}" text-anchor="middle" '
252
+ f'class="f-txt2" font-size="12" font-weight="600">{end:g}{esc(unit)}</text>'
253
+ )
254
+ parts.append(
255
+ f'<text x="{x + bw/2:.1f}" y="{base + 16}" text-anchor="middle" '
256
+ f'class="f-txt3" font-size="11">{esc(labels[-1])}</text>'
257
+ )
258
+ return ('<svg class="chart" data-chart="waterfall" viewBox="0 0 560 220">\n '
259
+ + '\n '.join(parts) + '\n </svg>')
260
+
261
+
262
+ def build_svg(chart: dict) -> str:
263
+ ct = (chart.get('type') or 'bar').lower()
264
+ labels = chart.get('labels') or []
265
+ values = chart.get('values') or []
266
+ unit = chart.get('unit') or ''
267
+ max_v = chart.get('max')
268
+ if ct == 'hbar':
269
+ return svg_hbar(labels, values, max_v, unit)
270
+ if ct == 'line':
271
+ return svg_line(labels, values, max_v, unit, area=False)
272
+ if ct == 'area':
273
+ return svg_line(labels, values, max_v, unit, area=True)
274
+ if ct == 'donut' or ct == 'pie':
275
+ return svg_donut(labels, values, chart.get('centerLabel') or '合计', unit)
276
+ if ct == 'waterfall':
277
+ return svg_waterfall(labels, values, unit)
278
+ return svg_bar(labels, values, max_v, unit)
279
+
280
+
281
+ def iter_chart_payloads(model: dict):
282
+ """Yield chart dicts in document order (sections + split sides)."""
283
+ for sec in model.get('sections') or []:
284
+ st = (sec.get('type') or '').lower()
285
+ if st in ('bar', 'donut', 'exhibit', 'halftable') and sec.get('chart'):
286
+ ch = dict(sec['chart'])
287
+ if st == 'donut':
288
+ ch.setdefault('type', 'donut')
289
+ elif not ch.get('type'):
290
+ ch['type'] = 'bar'
291
+ yield ch
292
+ for side in ('left', 'right'):
293
+ el = sec.get(side)
294
+ if isinstance(el, dict) and el.get('labels') and el.get('values') is not None:
295
+ ch = {
296
+ 'type': el.get('type') or 'bar',
297
+ 'labels': el.get('labels'),
298
+ 'values': el.get('values'),
299
+ 'unit': el.get('unit') or '',
300
+ 'max': el.get('max'),
301
+ 'centerLabel': el.get('centerLabel'),
302
+ }
303
+ yield ch
304
+
305
+
306
+ def hydrate(html: str, model: dict) -> str:
307
+ payloads = list(iter_chart_payloads(model))
308
+ idx = 0
309
+
310
+ def repl(m):
311
+ nonlocal idx
312
+ if idx >= len(payloads):
313
+ return m.group(0)
314
+ svg = build_svg(payloads[idx])
315
+ idx += 1
316
+ return svg
317
+
318
+ new_html, n = PLACEHOLDER_SVG_RE.subn(repl, html)
319
+ return new_html, n, idx, len(payloads)
320
+
321
+
322
+ def main() -> int:
323
+ if len(sys.argv) < 2:
324
+ print('usage: _hydrate_charts.py <report.html>')
325
+ return 2
326
+ path = Path(sys.argv[1])
327
+ html = path.read_text(encoding='utf-8')
328
+ m = MODEL_RE.search(html)
329
+ if not m:
330
+ print('FAIL: no REPORT_MODEL')
331
+ return 1
332
+ model = json.loads(m.group(1))
333
+ new_html, n_ph, used, total = hydrate(html, model)
334
+ path.write_text(new_html, encoding='utf-8')
335
+ left = new_html.count('见模型数据')
336
+ print(f'hydrated placeholders={n_ph} used_payloads={used}/{total} remaining_placeholder={left}')
337
+ return 0 if left == 0 else 1
338
+
339
+
340
+ if __name__ == '__main__':
341
+ raise SystemExit(main())