base-paint 0.1.0 → 0.2.0

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.
Files changed (60) hide show
  1. package/dist/charts.d.ts +62 -0
  2. package/dist/charts.d.ts.map +1 -0
  3. package/dist/charts.js +1677 -0
  4. package/dist/charts.js.map +1 -0
  5. package/dist/contract.d.ts.map +1 -1
  6. package/dist/contract.js +7 -1
  7. package/dist/contract.js.map +1 -1
  8. package/dist/controls.d.ts +114 -0
  9. package/dist/controls.d.ts.map +1 -0
  10. package/dist/controls.js +724 -0
  11. package/dist/controls.js.map +1 -0
  12. package/dist/help.d.ts +24 -0
  13. package/dist/help.d.ts.map +1 -0
  14. package/dist/help.js +604 -0
  15. package/dist/help.js.map +1 -0
  16. package/dist/index.d.ts +21 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +21 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/spec/charts.d.ts +222 -0
  21. package/dist/spec/charts.d.ts.map +1 -0
  22. package/dist/spec/charts.js +35 -0
  23. package/dist/spec/charts.js.map +1 -0
  24. package/dist/spec/controls.d.ts +329 -0
  25. package/dist/spec/controls.d.ts.map +1 -0
  26. package/dist/spec/controls.js +119 -0
  27. package/dist/spec/controls.js.map +1 -0
  28. package/dist/spec/help.d.ts +353 -0
  29. package/dist/spec/help.d.ts.map +1 -0
  30. package/dist/spec/help.js +167 -0
  31. package/dist/spec/help.js.map +1 -0
  32. package/dist/spec/index.d.ts +40 -0
  33. package/dist/spec/index.d.ts.map +1 -0
  34. package/dist/spec/index.js +164 -0
  35. package/dist/spec/index.js.map +1 -0
  36. package/dist/spec/style.d.ts +46 -0
  37. package/dist/spec/style.d.ts.map +1 -0
  38. package/dist/spec/style.js +39 -0
  39. package/dist/spec/style.js.map +1 -0
  40. package/dist/spec/template.d.ts +363 -0
  41. package/dist/spec/template.d.ts.map +1 -0
  42. package/dist/spec/template.js +252 -0
  43. package/dist/spec/template.js.map +1 -0
  44. package/dist/spec/text.d.ts +157 -0
  45. package/dist/spec/text.d.ts.map +1 -0
  46. package/dist/spec/text.js +83 -0
  47. package/dist/spec/text.js.map +1 -0
  48. package/dist/style.d.ts +19 -0
  49. package/dist/style.d.ts.map +1 -1
  50. package/dist/style.js +923 -0
  51. package/dist/style.js.map +1 -1
  52. package/dist/template.d.ts +27 -0
  53. package/dist/template.d.ts.map +1 -0
  54. package/dist/template.js +362 -0
  55. package/dist/template.js.map +1 -0
  56. package/dist/text.d.ts +19 -0
  57. package/dist/text.d.ts.map +1 -0
  58. package/dist/text.js +448 -0
  59. package/dist/text.js.map +1 -0
  60. package/package.json +2 -2
package/dist/charts.js ADDED
@@ -0,0 +1,1677 @@
1
+ /** base-paint/charts:#78 图表层运行时(契约 §3.5/§6.5)。
2
+ *
3
+ * 落地冻结面里 #78 的三条运行时出口:`charts`(8 方法)/`buildChartsHelpersJs`;
4
+ * `ChartError` 与本文件**不**从 `src/index.ts` 导出(冻结面 44 条内无该运行时条目,
5
+ * 与 `TemplateError`/`ControlsError` 同口径:调用方按 `name === 'ChartError'` + `code` 判定)。
6
+ *
7
+ * 三条硬边界(逐条对齐 §3.5/§6.5,违反即红):
8
+ * 1. **纯 CSS + SVG 字符串**(§3.5.1「工程约束」):无 `<canvas>`、无产出内联 `<script>`/`onclick=`、
9
+ * 无 `node:`、零第三方;交互一律走 `actionId`(渲染期写入 `ACTION_ID_ATTR`)。
10
+ * 2. **模块自身零 DOM**(R8):本文件代码里不出现 `document.`/`window.`/`globalThis.`——
11
+ * DOM 只允许出现在 `buildChartsHelpersJs` **产出的 JS 文本**里(`SHARED_HELPERS_JS_RULE.domAllowed`)。
12
+ * 3. **坐标唯一**(`CHART_COORD_RULE = 'viewBox-only'`):容器零 padding(CSS 里 `.…-charts{padding:0}`),
13
+ * 留白全部进 viewBox;描边元素带 `vector-effect="non-scaling-stroke"`。
14
+ * 旧版「SVG viewBox + HTML 百分比覆盖层」双层体系(`charts.js:560,572,583,604,618,645,649,870`)
15
+ * 与 DOM 校准(`charts.js:779-795`)**不复刻**(R19)——新实现把数据点/数值/刻度/标注
16
+ * 全部放进同一 viewBox,物理对齐由同一映射函数保证。
17
+ *
18
+ * 选项级语义(契约登记为「不冻结也不排除」,owner = #78)以 `docs/research/t92-old-v130-signatures.md:162-178`
19
+ * 与 `.scratch/t78/old-baseline.md` §2 旧行为为准,逐字段在 `.scratch/t78/a1-evidence.md` 对照。
20
+ *
21
+ * 文档未规定处的取值(本文显式记账,不留暗猜):
22
+ * - 各 kind 缺省 viewBox 尺寸(旧版桌面缺省高度:柱/组合 170、折线 210、donut 150、gauge 170×105、
23
+ * sparkline 90×30;`old-baseline.md` §3.2)与折线点直径 7、scatter 点直径 9(旧 `dotSize||9`);
24
+ * - 空态默认文案 = 冻结 `STATUS_DEFAULT_TEXT.empty`(旧版「暂无数据」;不新造第二份文案常量),
25
+ * 图标 📊 与提示「有记录后自动生成图表」沿用旧基线(纯 UI 文案,非契约常量);
26
+ * - `dotStyle`/`line.dotSize`/`combo.tooltip` 旧版是死参数,按 R21 实现为**新能力**:
27
+ * `dotStyle` = 数据点内联 `style` 文本(逐点样式覆盖),`dotSize` = 点直径,`tooltip` = `data-tip` 数据属性;
28
+ * - `avgLine(n)` = 旧语义的**均线序列**(窗口收敛 `3..items.length`,`old-baseline.md` §2.2),
29
+ * 不是水平线;`emptyText`/`compact`/`bar.grid` 旧版是死参数,按冻结类型补齐为有效选项;
30
+ * - `prefix` 覆盖只影响 `buildChartsHelpersJs` **产出的 CSS 文本**;`charts.*` 产出的 HTML 恒用
31
+ * `STYLE_PREFIX` 命名空间(与 `SharedHelpersInput.dataAttr` 覆盖口径同构,不一致由调用方自负)。
32
+ */
33
+ import { escapeHtml } from './contract.js';
34
+ import { renderEmptyState } from './controls.js';
35
+ import { STYLE_PREFIX } from './style.js';
36
+ import { ACTION_ID_ATTR, CHART_BREAKPOINTS, CHART_PALETTE, CHARTS_STYLE_ID, STATUS_DEFAULT_TEXT } from './spec/index.js';
37
+ const LF = String.fromCharCode(10);
38
+ /* ── 错误形态(§3.5:与 `TemplateError`/`ControlsError` 并列、互不继承、一律抛出、不返空) ── */
39
+ /** 图表层错误。**不**从 `src/index.ts` 导出:调用方按 `name === 'ChartError'` + `code` 判定。 */
40
+ export class ChartError extends Error {
41
+ code;
42
+ constructor(code, message) {
43
+ super(message);
44
+ this.name = 'ChartError';
45
+ this.code = code;
46
+ }
47
+ }
48
+ function badStructure(message) {
49
+ throw new ChartError('structure-invalid', message);
50
+ }
51
+ function badPct(message) {
52
+ throw new ChartError('pct-invalid', message);
53
+ }
54
+ function badKind(kind) {
55
+ throw new ChartError('kind-unknown', 'charts: 未知图表 kind:' + kind);
56
+ }
57
+ /* ── 小件 ─────────────────────────────────────────────────────────────── */
58
+ function esc(value) {
59
+ return escapeHtml(value);
60
+ }
61
+ function jsStr(value) {
62
+ return JSON.stringify(value);
63
+ }
64
+ function isPlainObject(value) {
65
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
66
+ }
67
+ function isNum(value) {
68
+ return typeof value === 'number' && isFinite(value);
69
+ }
70
+ function numOr(value, fallback) {
71
+ return isNum(value) ? value : fallback;
72
+ }
73
+ function round2(value) {
74
+ return Math.round(value * 100) / 100;
75
+ }
76
+ /** 数值文本(旧 `_fmt`:函数格式化器优先,否则 `String(值)`)。 */
77
+ function fmtValue(value, format) {
78
+ if (typeof format === 'function')
79
+ return String(format(value));
80
+ return String(round2(value));
81
+ }
82
+ /** 坐标文本(旧版 `toFixed(1)` 口径)。 */
83
+ function n1(value) {
84
+ return value.toFixed(1);
85
+ }
86
+ /** 缺省单序列色(旧版 `var(--blue,#007aff)` 带 fallback,§3.5 语义色)。 */
87
+ const DEFAULT_SERIES_COLOR = 'var(--blue,#007aff)';
88
+ const DEFAULT_MARK_COLOR = '#ff9500';
89
+ const DEFAULT_REGRESSION_COLOR = '#ff3b30';
90
+ const ANOMALY_COLOR = CHART_PALETTE[3];
91
+ const UP_COLOR = 'var(--ok,#34c759)';
92
+ const DOWN_COLOR = CHART_PALETTE[3];
93
+ const GRID_COLOR = 'var(--line,#d2d2d7)';
94
+ const MUTED_COLOR = 'var(--fg3,#86868b)';
95
+ const DOT_FACE_COLOR = 'var(--card,#ffffff)';
96
+ const DOT_DEFAULT_PX = 7;
97
+ const SCATTER_DOT_DEFAULT_PX = 9;
98
+ /** scatter 缺省 Y 轴刻度条数(旧 `charts.js:854` `yTicks:4`;冻结 `ScatterChartOptions` 无该字段,
99
+ * 故按**缺省行为**渲染 4 条、不新增可关闭开关——登记为契约缺口,归后续票,R2-G1 裁定)。 */
100
+ const SCATTER_Y_TICKS = 4;
101
+ const EMPTY_ICON = '📊';
102
+ const EMPTY_HINT = '有记录后自动生成图表';
103
+ const DONUT_ZERO_HINT = '合计为零, 无环形数据';
104
+ const OWN_SCALE_NOTE = '各指标独立刻度';
105
+ const GRID_LINES = 3;
106
+ /** 稳定 32 位散列(FNV-1a)——渐变 `<linearGradient id>` 由**渐变参数**派生,
107
+ * 同一输入恒得同一 id、不同参数得不同 id,且**不跨调用共享可变状态**(R1-A1/FX-78-A1b-11)。 */
108
+ function hash32(text) {
109
+ let h = 0x811c9dc5;
110
+ for (let i = 0; i < text.length; i += 1) {
111
+ h ^= text.charCodeAt(i);
112
+ h = Math.imul(h, 0x01000193) >>> 0;
113
+ }
114
+ return h.toString(36);
115
+ }
116
+ /* ── 输入校验(`CHART_STRUCTURE_RULE = 'throw'`) ──────────────────────── */
117
+ function requireObject(value, field) {
118
+ if (!isPlainObject(value))
119
+ badStructure(field + ' 必须是对象');
120
+ return value;
121
+ }
122
+ /** 统一数据形状校验:`items` 非数组/缺 `label`/`value` 非法 → `structure-invalid`。
123
+ * `mode = 'values'` 用于 bar 的 stacked/grouped 多值路径:`values` 是多值真相源,
124
+ * 但 `value` **不豁免**——冻结类型 `ChartItem.value` 必填,且 `docs/base-paint-contract.md:799`
125
+ * 要求「`value` 非法(非数且非 null)→ 抛 `structure-invalid`」;旧 `_barMulti` 的宽松行为
126
+ * (静默置 0)不复刻(R1-A2/FX-78-A1b-12,记账见 a1-evidence.md)。 */
127
+ function normalizeItems(raw, kind, allowNull, mode = 'value') {
128
+ if (!Array.isArray(raw)) {
129
+ badStructure('charts.' + kind + ': items 必须是数组, 收到 ' + (raw === null ? 'null' : typeof raw));
130
+ }
131
+ const list = raw;
132
+ return list.map((entry, i) => {
133
+ const field = 'charts.' + kind + ': items[' + i + ']';
134
+ const item = requireObject(entry, field);
135
+ if (typeof item.label !== 'string' || item.label.trim() === '')
136
+ badStructure(field + '.label 缺失');
137
+ const value = item.value;
138
+ let values;
139
+ if (mode === 'values') {
140
+ if (!isNum(value)) {
141
+ badStructure(field + '.value 无效(缺省/非数字): ' + JSON.stringify(value === undefined ? null : value));
142
+ }
143
+ if (!Array.isArray(item.values) || item.values.length === 0)
144
+ badStructure(field + '.values 必须是非空数组');
145
+ values = item.values.map((v, j) => {
146
+ if (!isNum(v))
147
+ badStructure(field + '.values[' + j + '] 无效(缺省/非数字)');
148
+ return v;
149
+ });
150
+ }
151
+ else {
152
+ if (!(value === null && allowNull) && !isNum(value)) {
153
+ badStructure(field + '.value 无效(缺省/非数字): ' + JSON.stringify(value === undefined ? null : value));
154
+ }
155
+ if (item.values !== undefined) {
156
+ if (!Array.isArray(item.values) || item.values.length === 0)
157
+ badStructure(field + '.values 必须是非空数组');
158
+ values = item.values.map((v, j) => {
159
+ if (!isNum(v))
160
+ badStructure(field + '.values[' + j + '] 无效(缺省/非数字)');
161
+ return v;
162
+ });
163
+ }
164
+ }
165
+ const color = typeof item.color === 'string' && item.color !== '' ? item.color : undefined;
166
+ const out = {
167
+ label: item.label,
168
+ value: mode === 'values' ? value : value === null ? null : value,
169
+ ...(color === undefined ? {} : { color }),
170
+ ...(values === undefined ? {} : { values }),
171
+ ...(item.anomaly === true ? { anomaly: true } : {}),
172
+ };
173
+ return out;
174
+ });
175
+ }
176
+ /** `pct` 非数 → `pct-invalid`;超界收敛 0~100(旧 §6.5 逐字行为)。 */
177
+ function normalizePct(raw, kind) {
178
+ if (!isNum(raw))
179
+ badPct('charts.' + kind + ': pct 无效: ' + String(raw));
180
+ return Math.max(0, Math.min(100, raw));
181
+ }
182
+ function resolveCommon(raw, def) {
183
+ const o = isPlainObject(raw) ? raw : undefined;
184
+ const labels = o === undefined ? undefined : o.labels;
185
+ const showValues = o === undefined ? undefined : o.showValues;
186
+ return {
187
+ width: numOr(o === undefined ? undefined : o.width, def.width),
188
+ height: numOr(o === undefined ? undefined : o.height, def.height),
189
+ compact: o !== undefined && o.compact === true,
190
+ animation: o === undefined || o.animation !== false,
191
+ color: o !== undefined && typeof o.color === 'string' && o.color !== '' ? o.color : undefined,
192
+ colors: o !== undefined && Array.isArray(o.colors)
193
+ ? o.colors.filter((c) => typeof c === 'string' && c !== '')
194
+ : undefined,
195
+ format: o !== undefined && typeof o.format === 'function' ? o.format : undefined,
196
+ labels: labels === 'all' || labels === 'none' || labels === 'select' || labels === 'edge' ? labels : def.labels,
197
+ showValues: showValues === true || showValues === false || showValues === 'edge' ? showValues : def.showValues,
198
+ labelRotate: numOr(o === undefined ? undefined : o.labelRotate, 0),
199
+ yMin: o !== undefined && isNum(o.yMin) ? o.yMin : undefined,
200
+ yMax: o !== undefined && isNum(o.yMax) ? o.yMax : undefined,
201
+ grid: o === undefined || o.grid !== false,
202
+ actionId: o !== undefined && typeof o.actionId === 'string' && o.actionId !== '' ? o.actionId : undefined,
203
+ tooltip: o !== undefined && o.tooltip === true,
204
+ };
205
+ }
206
+ function commonClasses(common, extra) {
207
+ const cls = [STYLE_PREFIX + 'charts'];
208
+ if (extra !== undefined)
209
+ cls.push(extra);
210
+ if (common.animation)
211
+ cls.push(STYLE_PREFIX + 'charts-anim');
212
+ return cls.join(' ');
213
+ }
214
+ function containerOpen(kind, common, extraClass) {
215
+ let out = '<div class="' + commonClasses(common, extraClass) + '" data-chart-kind="' + kind + '"';
216
+ if (common.actionId !== undefined)
217
+ out += ' ' + ACTION_ID_ATTR + '="' + esc(common.actionId) + '"';
218
+ return out + '>';
219
+ }
220
+ function actionAttrs(common, index) {
221
+ let out = '';
222
+ if (common.actionId !== undefined)
223
+ out += ' ' + ACTION_ID_ATTR + '="' + esc(common.actionId) + '"';
224
+ if (index !== undefined)
225
+ out += ' data-i="' + index + '"';
226
+ return out;
227
+ }
228
+ /** `tooltip: true` 时写入 `data-tip`;文本**惰性**求值(未开 tooltip 不得白调 `format`)。 */
229
+ function tipAttrs(common, makeText) {
230
+ return common.tooltip ? ' data-tip="' + esc(makeText()) + '"' : '';
231
+ }
232
+ /** `aspect='none'` 用于折线/组合/散点:旧版三者都是 `preserveAspectRatio="none"` 满宽拉伸
233
+ * (`charts.js:661,774,899`)——移动端 CSS 把 svg 高度钉成 `lineHeightMobilePx` 后,
234
+ * `meet` 会等比缩到 228.6px 宽并左右留白(R2-N9),`none` 才是满宽(描边有 `vector-effect` 防变形)。 */
235
+ function svgOpen(common, extra, aspect = 'meet') {
236
+ return '<svg class="' + STYLE_PREFIX + 'charts-svg' + (extra === undefined ? '' : ' ' + extra) + '"'
237
+ + ' viewBox="0 0 ' + n1(common.width) + ' ' + n1(common.height) + '"'
238
+ + ' preserveAspectRatio="' + (aspect === 'none' ? 'none' : 'xMidYMid meet') + '" role="img" focusable="false">';
239
+ }
240
+ /** 空态联动(`CHART_EMPTY_RULE = 'emptyState'`):`empty:true`/`points:0`,不抛错。 */
241
+ function emptyOutput(kind, raw, hint) {
242
+ const o = isPlainObject(raw) ? raw : undefined;
243
+ const text = o !== undefined && typeof o.emptyText === 'string' && o.emptyText !== ''
244
+ ? o.emptyText
245
+ : STATUS_DEFAULT_TEXT.empty;
246
+ const common = resolveCommon(raw, { width: 1, height: 1, labels: 'none', showValues: false });
247
+ const html = '<div class="' + commonClasses(common, STYLE_PREFIX + 'charts-' + kind + ' ' + STYLE_PREFIX + 'charts-empty')
248
+ + '" data-chart-kind="' + kind + '" data-chart-empty="1">'
249
+ + renderEmptyState({ icon: EMPTY_ICON, text, hint: hint === undefined ? EMPTY_HINT : hint })
250
+ + '</div>';
251
+ return { kind, html, empty: true, points: 0 };
252
+ }
253
+ function makeFrame(common, insets) {
254
+ const x0 = insets.left;
255
+ const x1 = Math.max(x0 + 1, common.width - insets.right);
256
+ const y0 = insets.top;
257
+ const y1 = Math.max(y0 + 1, common.height - insets.bottom);
258
+ return { x0, x1, y0, y1, w: x1 - x0, h: y1 - y0 };
259
+ }
260
+ /** 留白进 viewBox(容器零 padding):左留白含刻度文字宽度。 */
261
+ function insetsFor(common, opts) {
262
+ const padX = common.compact ? 6 : 14;
263
+ const padY = common.compact ? 4 : 8;
264
+ return {
265
+ left: padX + opts.tickWidth,
266
+ right: padX,
267
+ top: padY + (common.showValues === false ? 0 : opts.valueHeight),
268
+ bottom: padY + opts.labelHeight,
269
+ };
270
+ }
271
+ function xAt(frame, index, count) {
272
+ return count <= 1 ? frame.x0 + frame.w / 2 : frame.x0 + (frame.w * index) / (count - 1);
273
+ }
274
+ function yAt(frame, value, lo, hi) {
275
+ const span = hi - lo === 0 ? 1 : hi - lo;
276
+ return frame.y1 - ((value - lo) / span) * frame.h;
277
+ }
278
+ /** 共享 Y 域:显式 `yMin`/`yMax` 优先,否则数据域各外扩 `padRatio`。
279
+ * 折线/散点旧版外扩 6%(`charts.js:428-432`/`856-859`);**柱族与 combo 旧版无外扩**
280
+ * (bar 域 = `min(v,0)..max(v)`,`charts.js:371-373`;combo 域 = `0..max(柱,线)`,`charts.js:742-753`)
281
+ * → 这两族传 `0`,否则零基线悬空、最高柱不满高(R2-N1)。 */
282
+ function domainOf(values, yMin, yMax, includeZero, padRatio = 0.06) {
283
+ let lo = Infinity;
284
+ let hi = -Infinity;
285
+ for (const v of values) {
286
+ if (v < lo)
287
+ lo = v;
288
+ if (v > hi)
289
+ hi = v;
290
+ }
291
+ if (lo === Infinity) {
292
+ lo = 0;
293
+ hi = 1;
294
+ }
295
+ if (includeZero) {
296
+ if (lo > 0)
297
+ lo = 0;
298
+ if (hi < 0)
299
+ hi = 0;
300
+ }
301
+ if (hi === lo)
302
+ hi = lo + 1;
303
+ const pad = (hi - lo) * padRatio;
304
+ if (yMin === undefined)
305
+ lo -= pad;
306
+ else
307
+ lo = yMin;
308
+ if (yMax === undefined)
309
+ hi += pad;
310
+ else
311
+ hi = yMax;
312
+ if (hi === lo)
313
+ hi = lo + 1;
314
+ return [lo, hi];
315
+ }
316
+ /** `yTicks` 收敛 2-6(旧 `charts.js:498`);`false`/非数 → 0 条。 */
317
+ function tickCount(value) {
318
+ if (!isNum(value))
319
+ return 0;
320
+ return Math.max(2, Math.min(6, Math.round(value)));
321
+ }
322
+ /* ── 共享 SVG 片段 ────────────────────────────────────────────────────── */
323
+ function gridSvg(frame) {
324
+ let out = '';
325
+ for (let g = 0; g < GRID_LINES; g += 1) {
326
+ const gy = frame.y1 - (frame.h * g) / (GRID_LINES - 1);
327
+ out += '<line class="' + STYLE_PREFIX + 'charts-grid" x1="' + n1(frame.x0) + '" y1="' + n1(gy)
328
+ + '" x2="' + n1(frame.x1) + '" y2="' + n1(gy) + '" stroke="' + GRID_COLOR
329
+ + '" stroke-width="1" vector-effect="non-scaling-stroke"/>';
330
+ }
331
+ return out;
332
+ }
333
+ function ticksSvg(frame, lo, hi, count, format) {
334
+ if (count === 0)
335
+ return '';
336
+ let out = '';
337
+ for (let i = 0; i < count; i += 1) {
338
+ const tv = lo + ((hi - lo) * i) / (count - 1);
339
+ const ty = yAt(frame, tv, lo, hi);
340
+ out += '<line class="' + STYLE_PREFIX + 'charts-ytick" x1="' + n1(frame.x0 - 5) + '" y1="' + n1(ty)
341
+ + '" x2="' + n1(frame.x0) + '" y2="' + n1(ty) + '" stroke="' + GRID_COLOR
342
+ + '" stroke-width="1" vector-effect="non-scaling-stroke"/>'
343
+ + '<text class="' + STYLE_PREFIX + 'charts-tick" x="' + n1(frame.x0 - 7) + '" y="' + n1(ty + 3)
344
+ + '" text-anchor="end" fill="' + MUTED_COLOR + '">' + esc(fmtValue(round2(tv), format)) + '</text>';
345
+ }
346
+ return out;
347
+ }
348
+ /** X 轴标签集合:`'all'` 全量/`'edge'` 首尾/`'select'` 首+峰值+尾(line 口径)
349
+ * /`'none'` 无。`select='edge'` 时 `'select'` 退化为首尾——**bar 口径**:
350
+ * 旧 bar 的 `'select'` 只显示首尾(`charts.js:385-388`),与 line 的「首+峰值+尾」不同(R2-N7)。 */
351
+ function labelIndexes(mode, items, select = 'peak') {
352
+ const n = items.length;
353
+ if (mode === 'none' || n === 0)
354
+ return [];
355
+ if (mode === 'all')
356
+ return items.map((_, i) => i);
357
+ if (mode === 'edge' || (mode === 'select' && select === 'edge'))
358
+ return n === 1 ? [0] : [0, n - 1];
359
+ let peak = 0;
360
+ let peakValue = -Infinity;
361
+ items.forEach((item, i) => {
362
+ if (item.value !== null && item.value > peakValue) {
363
+ peakValue = item.value;
364
+ peak = i;
365
+ }
366
+ });
367
+ return Array.from(new Set([0, peak, n - 1])).sort((a, b) => a - b);
368
+ }
369
+ /* t-chartfix:X 标签的 x 标度缺省与折线点同一 `xAt` 点标度;柱族(bar/combo)
370
+ * 的柱列中心走 band 标度 `x0 + slot*(i+0.5)`——点标度会把首尾标签钉在绘图区
371
+ * 左右边缘(3 柱时偏差 48.7 单位),与柱错位。调用方按自家几何传入。 */
372
+ function xLabelsSvg(common, frame, items, select = 'peak', xOf = (i, n) => xAt(frame, i, n)) {
373
+ if (common.labels === 'none')
374
+ return '';
375
+ const baseY = frame.y1 + (common.compact ? 9 : 12);
376
+ return labelIndexes(common.labels, items, select).map((i) => {
377
+ const x = xOf(i, items.length);
378
+ const rotate = common.labelRotate === 0 ? '' : ' transform="rotate(' + common.labelRotate + ' ' + n1(x) + ' ' + n1(baseY) + ')"';
379
+ return '<text class="' + STYLE_PREFIX + 'charts-xlabel" x="' + n1(x) + '" y="' + n1(baseY)
380
+ + '" text-anchor="middle"' + rotate + ' fill="' + MUTED_COLOR + '">' + esc(items[i].label) + '</text>';
381
+ }).join('');
382
+ }
383
+ /** `showValues` 标签集合:`true` 全量(相邻中心距 < 26 单位跳过)/`'edge'` 首尾有效点/`false` 无。 */
384
+ function valueIndexes(mode, frame, items) {
385
+ if (mode === false)
386
+ return [];
387
+ const valid = [];
388
+ items.forEach((item, i) => {
389
+ if (item.value !== null)
390
+ valid.push(i);
391
+ });
392
+ if (valid.length === 0)
393
+ return [];
394
+ if (mode === 'edge')
395
+ return valid.length === 1 ? [valid[0]] : [valid[0], valid[valid.length - 1]];
396
+ const out = [];
397
+ let lastX = -Infinity;
398
+ for (const i of valid) {
399
+ const x = xAt(frame, i, items.length);
400
+ if (x - lastX < 26)
401
+ continue;
402
+ out.push(i);
403
+ lastX = x;
404
+ }
405
+ return out;
406
+ }
407
+ function legendHtml(entries, placement) {
408
+ if (entries.length === 0)
409
+ return '';
410
+ const cls = STYLE_PREFIX + 'charts-legend' + (placement === undefined ? '' : ' ' + STYLE_PREFIX + 'charts-legend-' + placement);
411
+ return '<div class="' + cls + '">' + entries.map((entry) => {
412
+ const swatch = entry.note === undefined
413
+ ? '<span class="' + STYLE_PREFIX + 'charts-legend-swatch" style="background:' + esc(entry.color) + '"></span>'
414
+ : '<span class="' + STYLE_PREFIX + 'charts-legend-swatch ' + STYLE_PREFIX + 'charts-legend-swatch-dashed"></span>';
415
+ return '<span class="' + STYLE_PREFIX + 'charts-legend-item">' + swatch + esc(entry.name) + '</span>';
416
+ }).join('') + '</div>';
417
+ }
418
+ function resolveLineSeries(raw, mainItems, line) {
419
+ const list = line.series;
420
+ if (list === undefined || list.length === 0) {
421
+ return [{
422
+ name: '',
423
+ items: mainItems,
424
+ color: undefined,
425
+ dashed: line.dashed,
426
+ smooth: line.smooth,
427
+ area: line.area,
428
+ ownScale: false,
429
+ }];
430
+ }
431
+ return list.map((entry, i) => {
432
+ const series = requireObject(entry, 'charts.line: series[' + i + ']');
433
+ if (typeof series.name !== 'string')
434
+ badStructure('charts.line: series[' + i + '].name 必须是字符串');
435
+ const items = normalizeItems(series.items, 'line', true);
436
+ return {
437
+ name: series.name,
438
+ items,
439
+ color: typeof series.color === 'string' && series.color !== '' ? series.color : undefined,
440
+ dashed: series.dashed === true,
441
+ smooth: series.smooth === undefined ? line.smooth : series.smooth === true,
442
+ area: series.area === true,
443
+ ownScale: series.ownScale === true,
444
+ };
445
+ });
446
+ }
447
+ function polyPath(pts, connect) {
448
+ if (connect) {
449
+ const valid = pts.filter((p) => !p[2]);
450
+ if (valid.length === 0)
451
+ return '';
452
+ return valid.map((p, i) => (i === 0 ? 'M' : 'L') + n1(p[0]) + ' ' + n1(p[1])).join('');
453
+ }
454
+ const segments = [];
455
+ let cur = [];
456
+ for (const p of pts) {
457
+ if (p[2]) {
458
+ if (cur.length > 0)
459
+ segments.push(cur);
460
+ cur = [];
461
+ }
462
+ else {
463
+ cur.push(p);
464
+ }
465
+ }
466
+ if (cur.length > 0)
467
+ segments.push(cur);
468
+ return segments.map((seg) => seg.map((p, i) => (i === 0 ? 'M' : 'L') + n1(p[0]) + ' ' + n1(p[1])).join('')).join('');
469
+ }
470
+ /** Catmull-Rom → 三次贝塞尔(数据点严格落在曲线上,旧 `_smoothPath` 口径)。 */
471
+ function smoothPath(pts, connect) {
472
+ const segments = [];
473
+ if (connect) {
474
+ segments.push(pts.filter((p) => !p[2]));
475
+ }
476
+ else {
477
+ let cur = [];
478
+ for (const p of pts) {
479
+ if (p[2]) {
480
+ if (cur.length > 0)
481
+ segments.push(cur);
482
+ cur = [];
483
+ }
484
+ else {
485
+ cur.push(p);
486
+ }
487
+ }
488
+ if (cur.length > 0)
489
+ segments.push(cur);
490
+ }
491
+ return segments.map((seg) => {
492
+ if (seg.length < 3)
493
+ return seg.map((p, i) => (i === 0 ? 'M' : 'L') + n1(p[0]) + ' ' + n1(p[1])).join('');
494
+ let d = 'M' + n1(seg[0][0]) + ' ' + n1(seg[0][1]);
495
+ for (let i = 0; i < seg.length - 1; i += 1) {
496
+ const p0 = seg[i - 1] ?? seg[i];
497
+ const p1 = seg[i];
498
+ const p2 = seg[i + 1];
499
+ const p3 = seg[i + 2] ?? seg[i + 1];
500
+ const c1x = p1[0] + (p2[0] - p0[0]) / 6;
501
+ const c1y = p1[1] + (p2[1] - p0[1]) / 6;
502
+ const c2x = p2[0] - (p3[0] - p1[0]) / 6;
503
+ const c2y = p2[1] - (p3[1] - p1[1]) / 6;
504
+ d += ' C' + n1(c1x) + ' ' + n1(c1y) + ' ' + n1(c2x) + ' ' + n1(c2y) + ' ' + n1(p2[0]) + ' ' + n1(p2[1]);
505
+ }
506
+ return d;
507
+ }).join('');
508
+ }
509
+ /** 阶梯路径(水平段 + 垂直段,旧 `step` 口径)。 */
510
+ function stepPath(pts, connect) {
511
+ const valid = pts.filter((p) => !p[2]);
512
+ if (valid.length === 0)
513
+ return '';
514
+ if (!connect) {
515
+ const segments = [];
516
+ let cur = [];
517
+ for (const p of pts) {
518
+ if (p[2]) {
519
+ if (cur.length > 0)
520
+ segments.push(cur);
521
+ cur = [];
522
+ }
523
+ else {
524
+ cur.push(p);
525
+ }
526
+ }
527
+ if (cur.length > 0)
528
+ segments.push(cur);
529
+ return segments.map((seg) => {
530
+ let d = 'M' + n1(seg[0][0]) + ' ' + n1(seg[0][1]);
531
+ for (let i = 1; i < seg.length; i += 1) {
532
+ d += ' L' + n1(seg[i][0]) + ' ' + n1(seg[i - 1][1]) + ' L' + n1(seg[i][0]) + ' ' + n1(seg[i][1]);
533
+ }
534
+ return d;
535
+ }).join('');
536
+ }
537
+ let d = 'M' + n1(valid[0][0]) + ' ' + n1(valid[0][1]);
538
+ for (let i = 1; i < valid.length; i += 1) {
539
+ d += ' L' + n1(valid[i][0]) + ' ' + n1(valid[i - 1][1]) + ' L' + n1(valid[i][0]) + ' ' + n1(valid[i][1]);
540
+ }
541
+ return d;
542
+ }
543
+ function areaPath(pts, connect, baseY, smooth, step) {
544
+ const valid = pts.filter((p) => !p[2]);
545
+ if (valid.length === 0)
546
+ return '';
547
+ const body = smooth && !step ? smoothPath(pts, connect) : step ? stepPath(pts, connect) : polyPath(pts, connect);
548
+ if (body === '')
549
+ return '';
550
+ const firstX = valid[0][0];
551
+ const lastX = valid[valid.length - 1][0];
552
+ return body + ' L' + n1(lastX) + ' ' + n1(baseY) + ' L' + n1(firstX) + ' ' + n1(baseY) + ' Z';
553
+ }
554
+ /** 两条序列之间的填充路径(null 断开,不随 `connectNulls` 跨空)。 */
555
+ function betweenPath(a, b) {
556
+ const segments = [];
557
+ let cur = [];
558
+ const n = Math.min(a.length, b.length);
559
+ for (let i = 0; i < n; i += 1) {
560
+ if (a[i][2] || b[i][2]) {
561
+ if (cur.length > 0)
562
+ segments.push(cur);
563
+ cur = [];
564
+ }
565
+ else {
566
+ cur.push({ top: a[i], bottom: b[i] });
567
+ }
568
+ }
569
+ if (cur.length > 0)
570
+ segments.push(cur);
571
+ return segments.map((seg) => {
572
+ let d = seg.map((p, i) => (i === 0 ? 'M' : 'L') + n1(p.top[0]) + ' ' + n1(p.top[1])).join('');
573
+ for (let i = seg.length - 1; i >= 0; i -= 1)
574
+ d += ' L' + n1(seg[i].bottom[0]) + ' ' + n1(seg[i].bottom[1]);
575
+ return d + ' Z';
576
+ }).join('');
577
+ }
578
+ function resolveMarkPoint(raw) {
579
+ if (raw === true)
580
+ return true;
581
+ if (isPlainObject(raw))
582
+ return raw;
583
+ return undefined;
584
+ }
585
+ function renderLine(raw) {
586
+ const input = requireObject(raw, 'charts.line: input');
587
+ const common = resolveCommon(input.options, { width: 320, height: 210, labels: 'edge', showValues: false });
588
+ const opts = isPlainObject(input.options) ? input.options : undefined;
589
+ const line = {
590
+ ...common,
591
+ lineWidth: numOr(opts === undefined ? undefined : opts.lineWidth, 2.2),
592
+ dashed: opts !== undefined && opts.dashed === true,
593
+ smooth: opts !== undefined && opts.smooth === true,
594
+ step: opts !== undefined && opts.step === true,
595
+ showDots: opts === undefined || opts.showDots !== false,
596
+ dotSize: numOr(opts === undefined ? undefined : opts.dotSize, DOT_DEFAULT_PX),
597
+ dotStyle: opts !== undefined && typeof opts.dotStyle === 'string' && opts.dotStyle !== '' ? opts.dotStyle : undefined,
598
+ area: opts !== undefined && opts.area === true,
599
+ areaOpacity: numOr(opts === undefined ? undefined : opts.areaOpacity, 0.12),
600
+ yTicks: opts === undefined || opts.yTicks === undefined ? false : opts.yTicks,
601
+ connectNulls: opts !== undefined && opts.connectNulls === true,
602
+ legend: opts !== undefined && opts.legend === true,
603
+ highlightLast: opts !== undefined && opts.highlightLast === true,
604
+ avgLine: opts !== undefined && isNum(opts.avgLine) ? opts.avgLine : undefined,
605
+ markLine: opts !== undefined && isPlainObject(opts.markLine) ? opts.markLine : undefined,
606
+ markPoint: opts === undefined ? undefined : resolveMarkPoint(opts.markPoint),
607
+ band: opts !== undefined && isPlainObject(opts.band) ? opts.band : undefined,
608
+ fillBetween: opts !== undefined && isPlainObject(opts.fillBetween) ? opts.fillBetween : undefined,
609
+ highlightPoints: opts !== undefined && (opts.highlightPoints === 'turns' || opts.highlightPoints === 'crossings')
610
+ ? opts.highlightPoints
611
+ : undefined,
612
+ series: opts !== undefined && Array.isArray(opts.series) ? opts.series : undefined,
613
+ };
614
+ const items = normalizeItems(input.items, 'line', true);
615
+ if (items.length === 0)
616
+ return emptyOutput('line', input.options);
617
+ const series = resolveLineSeries(input.options, items, line);
618
+ const main = series[0];
619
+ /* band:与主序列 items 等长,否则抛错;hi/lo 值并入共享域 */
620
+ let band;
621
+ if (line.band !== undefined) {
622
+ const hi = line.band.hi;
623
+ const lo = line.band.lo;
624
+ if (!Array.isArray(hi) || !Array.isArray(lo))
625
+ badStructure('charts.line: band.hi/lo 必须是数组');
626
+ if (hi.length !== main.items.length || lo.length !== main.items.length) {
627
+ badStructure('charts.line: band.hi/lo 长度必须与 items 等长 (' + main.items.length + '), 收到 hi='
628
+ + hi.length + ' lo=' + lo.length);
629
+ }
630
+ band = line.band;
631
+ }
632
+ /* 共享 Y 域(非 ownScale 序列 + band;ownScale 序列独立归一化,不参与共享域) */
633
+ const sharedValues = [];
634
+ for (const s of series) {
635
+ if (s.ownScale)
636
+ continue;
637
+ for (const item of s.items)
638
+ if (item.value !== null)
639
+ sharedValues.push(item.value);
640
+ }
641
+ if (band !== undefined) {
642
+ for (const v of band.hi)
643
+ if (v !== null)
644
+ sharedValues.push(v);
645
+ for (const v of band.lo)
646
+ if (v !== null)
647
+ sharedValues.push(v);
648
+ }
649
+ const [lo, hi] = domainOf(sharedValues, line.yMin, line.yMax, false);
650
+ const tickN = tickCount(line.yTicks);
651
+ const frame = makeFrame(line, insetsFor(line, {
652
+ tickWidth: tickN === 0 ? 0 : (line.compact ? 16 : 22),
653
+ labelHeight: line.labels === 'none' ? 0 : (line.compact ? 10 : 14),
654
+ valueHeight: line.compact ? 9 : 12,
655
+ }));
656
+ const ownDoms = series.map((s) => {
657
+ if (!s.ownScale)
658
+ return undefined;
659
+ const values = [];
660
+ for (const item of s.items)
661
+ if (item.value !== null)
662
+ values.push(item.value);
663
+ return domainOf(values, undefined, undefined, false);
664
+ });
665
+ const ptsOf = (s, si) => {
666
+ const dom = ownDoms[si] ?? [lo, hi];
667
+ return s.items.map((item, i) => [
668
+ xAt(frame, i, s.items.length),
669
+ item.value === null ? 0 : yAt(frame, item.value, dom[0], dom[1]),
670
+ item.value === null,
671
+ ]);
672
+ };
673
+ const seriesPts = series.map((s, si) => ptsOf(s, si));
674
+ /* avgLine:均线序列(窗口收敛 3..items.length);**仅当调用方未传 `series`** 时叠加
675
+ * (旧 `charts.js:414` `if(opt.avgLine&&!opt.series)`;传了 `series` 就由调用方自己管序列,R2-N6)。 */
676
+ const avgWindow = line.avgLine === undefined || line.series !== undefined
677
+ ? undefined
678
+ : Math.max(3, Math.min(items.length, Math.round(line.avgLine || 7)));
679
+ if (avgWindow !== undefined && items.length >= 2) {
680
+ const avgItems = items.map((item, i) => {
681
+ if (item.value === null)
682
+ return { label: item.label, value: null };
683
+ let sum = 0;
684
+ let n = 0;
685
+ for (let j = Math.max(0, i - avgWindow + 1); j <= i; j += 1) {
686
+ const v = items[j].value;
687
+ if (v !== null) {
688
+ sum += v;
689
+ n += 1;
690
+ }
691
+ }
692
+ return { label: item.label, value: n === 0 ? null : round2(sum / n) };
693
+ });
694
+ series.push({ name: '均线', items: avgItems, color: MUTED_COLOR, dashed: true, smooth: line.smooth, area: false, ownScale: false });
695
+ seriesPts.push(avgItems.map((item, i) => [
696
+ xAt(frame, i, avgItems.length),
697
+ item.value === null ? 0 : yAt(frame, item.value, lo, hi),
698
+ item.value === null,
699
+ ]));
700
+ }
701
+ /* fillBetween:a/b = 系列索引(越界/相同/非数/长度不一致 → 抛错) */
702
+ let fillSvg = '';
703
+ if (line.fillBetween !== undefined) {
704
+ const fb = line.fillBetween;
705
+ if (!isNum(fb.a) || !isNum(fb.b))
706
+ badStructure('charts.line: fillBetween.a/b 必须为系列索引数字');
707
+ const ia = Math.round(fb.a);
708
+ const ib = Math.round(fb.b);
709
+ if (ia < 0 || ia >= seriesPts.length || ib < 0 || ib >= seriesPts.length) {
710
+ badStructure('charts.line: fillBetween.a/b 越界 (系列数 ' + seriesPts.length + ', a=' + ia + ' b=' + ib + ')');
711
+ }
712
+ if (ia === ib)
713
+ badStructure('charts.line: fillBetween.a/b 不能相同');
714
+ if (seriesPts[ia].length !== seriesPts[ib].length)
715
+ badStructure('charts.line: fillBetween 两系列 items 长度不一致');
716
+ const d = betweenPath(seriesPts[ia], seriesPts[ib]);
717
+ if (d !== '') {
718
+ const color = typeof fb.color === 'string' && fb.color !== '' ? fb.color : DEFAULT_SERIES_COLOR;
719
+ fillSvg = '<path class="' + STYLE_PREFIX + 'charts-fill" d="' + d + '" fill="' + esc(color)
720
+ + '" fill-opacity="' + line.areaOpacity + '" stroke="none"/>';
721
+ }
722
+ }
723
+ /* band:绘制在折线之前,fill-opacity 0.15 */
724
+ let bandSvg = '';
725
+ if (band !== undefined) {
726
+ const dom = ownDoms[0] ?? [lo, hi];
727
+ const n = main.items.length;
728
+ const hiPts = [];
729
+ const loPts = [];
730
+ for (let i = 0; i < n; i += 1) {
731
+ const x = xAt(frame, i, n);
732
+ const hv = band.hi[i];
733
+ const lv = band.lo[i];
734
+ hiPts.push([x, hv === null || hv === undefined ? 0 : yAt(frame, hv, dom[0], dom[1]), hv === null || hv === undefined]);
735
+ loPts.push([x, lv === null || lv === undefined ? 0 : yAt(frame, lv, dom[0], dom[1]), lv === null || lv === undefined]);
736
+ }
737
+ const d = betweenPath(hiPts, loPts);
738
+ if (d !== '') {
739
+ bandSvg = '<path class="' + STYLE_PREFIX + 'charts-band" d="' + d + '" fill="'
740
+ + esc(main.color ?? line.color ?? DEFAULT_SERIES_COLOR) + '" fill-opacity="0.15" stroke="none"/>';
741
+ }
742
+ }
743
+ /* 各序列路径 + 数据点 + 数值标签 */
744
+ let pathsSvg = '';
745
+ let marksSvg = '';
746
+ let valuesSvg = '';
747
+ const legendEntries = [];
748
+ let anyOwnScale = false;
749
+ series.forEach((s, si) => {
750
+ if (s.ownScale)
751
+ anyOwnScale = true;
752
+ const pts = seriesPts[si];
753
+ const isAvg = s.name === '均线' && avgWindow !== undefined && si === series.length - 1;
754
+ const color = s.color ?? line.color ?? CHART_PALETTE[si % CHART_PALETTE.length];
755
+ if (s.area && !isAvg) {
756
+ const d = areaPath(pts, line.connectNulls, frame.y1, s.smooth && !line.step, line.step);
757
+ if (d !== '') {
758
+ pathsSvg += '<path class="' + STYLE_PREFIX + 'charts-area" d="' + d + '" fill="' + esc(color)
759
+ + '" fill-opacity="' + line.areaOpacity + '" stroke="none"/>';
760
+ }
761
+ }
762
+ const d = line.step ? stepPath(pts, line.connectNulls) : s.smooth ? smoothPath(pts, line.connectNulls) : polyPath(pts, line.connectNulls);
763
+ if (d !== '') {
764
+ pathsSvg += '<path class="' + STYLE_PREFIX + 'charts-line' + (isAvg ? ' ' + STYLE_PREFIX + 'charts-avg' : '')
765
+ + '" d="' + d + '" fill="none" stroke="' + esc(color)
766
+ + '" stroke-width="' + line.lineWidth + '" stroke-linejoin="round" stroke-linecap="round"'
767
+ + (s.dashed ? ' stroke-dasharray="6 5"' : '') + ' vector-effect="non-scaling-stroke"/>';
768
+ }
769
+ if (line.legend && !isAvg && s.name !== '')
770
+ legendEntries.push({ name: s.name, color });
771
+ if (isAvg)
772
+ return;
773
+ if (line.showDots) {
774
+ pts.forEach((p, i) => {
775
+ if (p[2])
776
+ return;
777
+ const item = s.items[i];
778
+ const anomaly = item.anomaly === true;
779
+ const dotClass = STYLE_PREFIX + 'charts-dot' + (anomaly ? ' ' + STYLE_PREFIX + 'charts-dot-anomaly' : '')
780
+ + (line.dotStyle === undefined ? '' : ' ' + STYLE_PREFIX + 'charts-dot-custom');
781
+ marksSvg += '<circle class="' + dotClass + '" data-s="' + si + '" data-i="' + i + '" cx="' + n1(p[0])
782
+ + '" cy="' + n1(p[1]) + '" r="' + line.dotSize / 2 + '" fill="'
783
+ + (anomaly ? ANOMALY_COLOR : DOT_FACE_COLOR) + '" stroke="' + esc(anomaly ? ANOMALY_COLOR : color)
784
+ + '" stroke-width="1.5" vector-effect="non-scaling-stroke"'
785
+ + (line.dotStyle === undefined ? '' : ' style="' + esc(line.dotStyle) + '"')
786
+ + tipAttrs(line, () => item.label + ': ' + fmtValue(item.value, line.format))
787
+ + actionAttrs(line, i) + '/>';
788
+ });
789
+ }
790
+ if (line.showValues !== false) {
791
+ for (const i of valueIndexes(line.showValues, frame, s.items)) {
792
+ const item = s.items[i];
793
+ const p = pts[i];
794
+ valuesSvg += '<text class="' + STYLE_PREFIX + 'charts-value" x="' + n1(p[0]) + '" y="' + n1(p[1] - 4)
795
+ + '" text-anchor="middle" fill="' + MUTED_COLOR + '">' + esc(fmtValue(item.value, line.format)) + '</text>';
796
+ }
797
+ }
798
+ /* highlightPoints:拐点(方向反转,两端点不判)/交点(多序列相交段右端点) */
799
+ if (line.highlightPoints === 'turns') {
800
+ for (let i = 1; i < pts.length - 1; i += 1) {
801
+ if (pts[i][2] || pts[i - 1][2] || pts[i + 1][2])
802
+ continue;
803
+ const dy1 = pts[i][1] - pts[i - 1][1];
804
+ const dy2 = pts[i + 1][1] - pts[i][1];
805
+ if (dy1 * dy2 < 0) {
806
+ marksSvg += '<circle class="' + STYLE_PREFIX + 'charts-dot-hl" data-s="' + si + '" data-i="' + i
807
+ + '" cx="' + n1(pts[i][0]) + '" cy="' + n1(pts[i][1]) + '" r="' + (line.dotSize / 2 + 2.5)
808
+ + '" fill="none" stroke="' + esc(color) + '" stroke-width="2" vector-effect="non-scaling-stroke"/>';
809
+ }
810
+ }
811
+ }
812
+ });
813
+ if (line.highlightPoints === 'crossings') {
814
+ for (let a = 0; a < seriesPts.length; a += 1) {
815
+ for (let b = a + 1; b < seriesPts.length; b += 1) {
816
+ const pa = seriesPts[a];
817
+ const pb = seriesPts[b];
818
+ const n = Math.min(pa.length, pb.length);
819
+ for (let k = 0; k < n - 1; k += 1) {
820
+ if (pa[k][2] || pb[k][2] || pa[k + 1][2] || pb[k + 1][2])
821
+ continue;
822
+ const d0 = pa[k][1] - pb[k][1];
823
+ const d1 = pa[k + 1][1] - pb[k + 1][1];
824
+ const hit = d0 === 0 ? k : d0 * d1 < 0 ? k + 1 : -1;
825
+ if (hit < 0)
826
+ continue;
827
+ const color = series[a].color ?? line.color ?? CHART_PALETTE[a % CHART_PALETTE.length];
828
+ marksSvg += '<circle class="' + STYLE_PREFIX + 'charts-dot-hl" data-s="' + a + '" data-i="' + hit
829
+ + '" cx="' + n1(pa[hit][0]) + '" cy="' + n1(pa[hit][1]) + '" r="' + (line.dotSize / 2 + 2.5)
830
+ + '" fill="none" stroke="' + esc(color) + '" stroke-width="2" vector-effect="non-scaling-stroke"/>';
831
+ }
832
+ }
833
+ }
834
+ }
835
+ if (line.legend && anyOwnScale)
836
+ legendEntries.push({ name: OWN_SCALE_NOTE, color: MUTED_COLOR, note: 'dashed' });
837
+ /* highlightLast:最后一个有效点高亮 + 数值标签 */
838
+ if (line.highlightLast) {
839
+ let last = -1;
840
+ for (let i = main.items.length - 1; i >= 0; i -= 1) {
841
+ if (main.items[i].value !== null) {
842
+ last = i;
843
+ break;
844
+ }
845
+ }
846
+ if (last >= 0) {
847
+ const p = seriesPts[0][last];
848
+ const color = line.color ?? main.color ?? DEFAULT_SERIES_COLOR;
849
+ marksSvg += '<circle class="' + STYLE_PREFIX + 'charts-dot-last" data-i="' + last + '" cx="' + n1(p[0])
850
+ + '" cy="' + n1(p[1]) + '" r="' + (line.dotSize / 2 + 1) + '" fill="' + esc(color) + '" stroke="' + esc(color)
851
+ + '" stroke-width="1.5" vector-effect="non-scaling-stroke"/>';
852
+ /* 末值文本受 `showValues`/`labels:'select'` 门控(旧 `charts.js:619-621`);高亮圈无条件。 */
853
+ if (line.showValues !== false || line.labels === 'select') {
854
+ valuesSvg += '<text class="' + STYLE_PREFIX + 'charts-value ' + STYLE_PREFIX + 'charts-value-last" x="' + n1(p[0])
855
+ + '" y="' + n1(p[1] - 6) + '" text-anchor="middle" fill="var(--fg,#1d1d1f)">'
856
+ + esc(fmtValue(main.items[last].value, line.format)) + '</text>';
857
+ }
858
+ }
859
+ }
860
+ /* markLine:水平阈值({value})/垂直里程碑({xValue}),按字段区分可同传 */
861
+ let markSvg = '';
862
+ if (line.markLine !== undefined) {
863
+ const mark = line.markLine;
864
+ if (isNum(mark.value)) {
865
+ const my = yAt(frame, mark.value, lo, hi);
866
+ const color = typeof mark.color === 'string' && mark.color !== '' ? mark.color : DEFAULT_MARK_COLOR;
867
+ markSvg += '<line class="' + STYLE_PREFIX + 'charts-markline" x1="' + n1(frame.x0) + '" y1="' + n1(my)
868
+ + '" x2="' + n1(frame.x1) + '" y2="' + n1(my) + '" stroke="' + esc(color)
869
+ + '" stroke-width="1.5" stroke-dasharray="5 4" vector-effect="non-scaling-stroke"/>'
870
+ + '<text class="' + STYLE_PREFIX + 'charts-marktext" x="' + n1(frame.x1 - 2) + '" y="' + n1(my - 4)
871
+ + '" text-anchor="end" fill="' + esc(color) + '">'
872
+ + esc(mark.label !== undefined && mark.label !== null ? String(mark.label) : String(mark.value)) + '</text>';
873
+ }
874
+ if (mark.xValue !== undefined && mark.xValue !== null) {
875
+ let idx = -1;
876
+ if (isNum(mark.xValue)) {
877
+ const candidate = Math.round(mark.xValue);
878
+ if (candidate >= 0 && candidate < items.length)
879
+ idx = candidate;
880
+ }
881
+ else {
882
+ const wanted = String(mark.xValue);
883
+ for (let i = 0; i < items.length; i += 1) {
884
+ if (items[i].label === wanted) {
885
+ idx = i;
886
+ break;
887
+ }
888
+ }
889
+ }
890
+ if (idx >= 0) {
891
+ const mx = xAt(frame, idx, items.length);
892
+ const color = typeof mark.color === 'string' && mark.color !== '' ? mark.color : DEFAULT_MARK_COLOR;
893
+ const ratio = (mx - frame.x0) / frame.w;
894
+ const anchor = ratio < 0.18 ? 'start' : ratio > 0.82 ? 'end' : 'middle';
895
+ const labelX = anchor === 'start' ? mx + 2 : anchor === 'end' ? mx - 2 : mx;
896
+ markSvg += '<line class="' + STYLE_PREFIX + 'charts-markline-v" x1="' + n1(mx) + '" y1="' + n1(frame.y0 - 2)
897
+ + '" x2="' + n1(mx) + '" y2="' + n1(frame.y1) + '" stroke="' + esc(color)
898
+ + '" stroke-width="1.5" stroke-dasharray="5 4" vector-effect="non-scaling-stroke"/>'
899
+ + '<text class="' + STYLE_PREFIX + 'charts-marktext-v" x="' + n1(labelX) + '" y="' + n1(frame.y0 - 4)
900
+ + '" text-anchor="' + anchor + '" fill="' + esc(color) + '">'
901
+ + esc(mark.label !== undefined && mark.label !== null ? String(mark.label) : items[idx].label) + '</text>';
902
+ }
903
+ }
904
+ }
905
+ /* markPoint:true = 主序列最大值点;{index} 越界忽略;{value} 首个匹配 */
906
+ let markPointSvg = '';
907
+ if (line.markPoint !== undefined) {
908
+ const mp = line.markPoint === true ? {} : line.markPoint;
909
+ let idx = -1;
910
+ if (mp.index !== undefined && mp.index !== null && isNum(mp.index)) {
911
+ const candidate = Math.round(mp.index);
912
+ if (candidate >= 0 && candidate < main.items.length)
913
+ idx = candidate;
914
+ }
915
+ else if (mp.value !== undefined && mp.value !== null && isNum(mp.value)) {
916
+ for (let i = 0; i < main.items.length; i += 1) {
917
+ if (main.items[i].value === mp.value) {
918
+ idx = i;
919
+ break;
920
+ }
921
+ }
922
+ }
923
+ else {
924
+ let best = null;
925
+ main.items.forEach((item, i) => {
926
+ if (item.value !== null && (best === null || item.value > best)) {
927
+ best = item.value;
928
+ idx = i;
929
+ }
930
+ });
931
+ }
932
+ if (idx >= 0 && !seriesPts[0][idx][2]) {
933
+ const p = seriesPts[0][idx];
934
+ const color = typeof mp.color === 'string' && mp.color !== '' ? mp.color : (main.color ?? line.color ?? DEFAULT_SERIES_COLOR);
935
+ const ratio = (p[0] - frame.x0) / frame.w;
936
+ const anchor = ratio < 0.18 ? 'start' : ratio > 0.82 ? 'end' : 'middle';
937
+ const labelX = anchor === 'start' ? p[0] + 4 : anchor === 'end' ? p[0] - 4 : p[0];
938
+ markPointSvg += '<circle class="' + STYLE_PREFIX + 'charts-markpoint" cx="' + n1(p[0]) + '" cy="' + n1(p[1])
939
+ + '" r="4" fill="' + esc(color) + '" stroke="' + DOT_FACE_COLOR + '" stroke-width="2" vector-effect="non-scaling-stroke"/>'
940
+ + '<text class="' + STYLE_PREFIX + 'charts-mptext" x="' + n1(labelX) + '" y="' + n1(Math.max(6, p[1] - 8))
941
+ + '" text-anchor="' + anchor + '" fill="' + esc(color) + '">'
942
+ + esc(mp.label !== undefined && mp.label !== null ? String(mp.label) : fmtValue(main.items[idx].value, line.format))
943
+ + '</text>';
944
+ }
945
+ }
946
+ const points = series.reduce((sum, s, si) => {
947
+ if (avgWindow !== undefined && si === series.length - 1)
948
+ return sum;
949
+ return sum + s.items.filter((item) => item.value !== null).length;
950
+ }, 0);
951
+ const html = containerOpen('line', line, STYLE_PREFIX + 'charts-line')
952
+ + legendHtml(legendEntries)
953
+ + svgOpen(line, undefined, 'none')
954
+ + (line.grid ? gridSvg(frame) : '')
955
+ + bandSvg
956
+ + fillSvg
957
+ + ticksSvg(frame, lo, hi, tickN, line.format)
958
+ + markSvg
959
+ + pathsSvg
960
+ + marksSvg
961
+ + valuesSvg
962
+ + markPointSvg
963
+ + xLabelsSvg(line, frame, items)
964
+ + '</svg></div>';
965
+ return { kind: 'line', html, empty: points === 0, points };
966
+ }
967
+ /* ── bar ──────────────────────────────────────────────────────────────── */
968
+ function barColorFor(index, item, opts, common) {
969
+ if (common.color !== undefined)
970
+ return common.color;
971
+ if (item.color !== undefined)
972
+ return item.color;
973
+ if (opts.singleColor === true)
974
+ return common.colors?.[0] ?? CHART_PALETTE[0];
975
+ if (common.colors !== undefined && common.colors.length > 0)
976
+ return common.colors[index % common.colors.length];
977
+ return DEFAULT_SERIES_COLOR;
978
+ }
979
+ function segColorFor(index, item, opts, common) {
980
+ if (item.color !== undefined)
981
+ return item.color;
982
+ if (common.colors !== undefined && common.colors.length > 0)
983
+ return common.colors[index % common.colors.length];
984
+ return CHART_PALETTE[index % CHART_PALETTE.length];
985
+ }
986
+ function renderBar(raw) {
987
+ const input = requireObject(raw, 'charts.bar: input');
988
+ const common = resolveCommon(input.options, { width: 320, height: 170, labels: 'all', showValues: true });
989
+ const opts = isPlainObject(input.options) ? input.options : undefined;
990
+ const stacked = opts !== undefined && opts.stacked === true;
991
+ const grouped = !stacked && opts !== undefined && opts.grouped === true;
992
+ const multi = stacked || grouped;
993
+ const items = normalizeItems(input.items, 'bar', false, multi ? 'values' : 'value');
994
+ if (items.length === 0)
995
+ return emptyOutput('bar', input.options);
996
+ const stackMode = opts !== undefined && opts.stackMode === 'absolute' ? 'absolute' : 'percent';
997
+ const gap = CHART_BREAKPOINTS.stackedGapPx;
998
+ let segCount = 0;
999
+ if (multi) {
1000
+ segCount = items[0].values.length;
1001
+ items.forEach((item, i) => {
1002
+ if (item.values === undefined)
1003
+ badStructure('charts.bar: stacked/grouped 模式下 items[' + i + '] 必须含 values 数组');
1004
+ if (item.values.length !== segCount) {
1005
+ badStructure('charts.bar: 各 item values 长度必须一致 (' + segCount + '), items[' + i + ']=' + item.values.length);
1006
+ }
1007
+ });
1008
+ }
1009
+ const segNames = opts !== undefined && Array.isArray(opts.segNames)
1010
+ ? opts.segNames.map((name, i) => (typeof name === 'string' && name !== '' ? name : '段' + (i + 1)))
1011
+ : Array.from({ length: segCount }, (_, i) => '段' + (i + 1));
1012
+ /** 全局最大合计(`stackMode: 'absolute'` 的分母);仅多值模式读 `values`。 */
1013
+ const maxTotal = multi
1014
+ ? items.reduce((best, item) => {
1015
+ const total = item.values.reduce((a, b) => a + b, 0);
1016
+ return total > best ? total : best;
1017
+ }, 0)
1018
+ : 0;
1019
+ const tickN = 0;
1020
+ const frame = makeFrame(common, insetsFor(common, {
1021
+ tickWidth: 0,
1022
+ labelHeight: common.labels === 'none' ? 0 : (common.compact ? 10 : 14),
1023
+ valueHeight: common.compact ? 9 : 12,
1024
+ }));
1025
+ let lo = 0;
1026
+ let hi = 1;
1027
+ if (stacked && stackMode === 'percent') {
1028
+ lo = 0;
1029
+ hi = 100;
1030
+ }
1031
+ else if (stacked) {
1032
+ const totals = items.map((item) => item.values.reduce((a, b) => a + b, 0));
1033
+ const [dlo, dhi] = domainOf(totals, common.yMin, common.yMax, true, 0);
1034
+ lo = dlo;
1035
+ hi = dhi;
1036
+ }
1037
+ else if (grouped) {
1038
+ const values = [];
1039
+ for (const item of items)
1040
+ for (const v of item.values)
1041
+ values.push(v);
1042
+ const [dlo, dhi] = domainOf(values, common.yMin, common.yMax, true, 0);
1043
+ lo = dlo;
1044
+ hi = dhi;
1045
+ }
1046
+ else {
1047
+ const values = items.map((item) => item.value);
1048
+ const [dlo, dhi] = domainOf(values, common.yMin, common.yMax, true, 0);
1049
+ lo = dlo;
1050
+ hi = dhi;
1051
+ }
1052
+ const yZero = yAt(frame, 0, lo, hi);
1053
+ const slot = frame.w / items.length;
1054
+ const barW = Math.min(slot * (common.compact ? 0.82 : 0.62), 34);
1055
+ let barsSvg = '';
1056
+ items.forEach((item, i) => {
1057
+ const cx = frame.x0 + slot * (i + 0.5);
1058
+ const tip = tipAttrs(common, () => item.label + ': ' + fmtValue(item.value, common.format));
1059
+ if (!multi) {
1060
+ const value = item.value;
1061
+ const yv = yAt(frame, value, lo, hi);
1062
+ const top = Math.min(yZero, yv);
1063
+ const height = Math.max(2, Math.abs(yZero - yv));
1064
+ barsSvg += '<rect class="' + STYLE_PREFIX + 'charts-bar" data-i="' + i + '" x="' + n1(cx - barW / 2)
1065
+ + '" y="' + n1(top) + '" width="' + n1(barW) + '" height="' + n1(height) + '" rx="2" fill="'
1066
+ + esc(barColorFor(i, item, opts ?? {}, common)) + '"' + tip + actionAttrs(common, i) + '/>';
1067
+ return;
1068
+ }
1069
+ const values = item.values;
1070
+ const total = values.reduce((a, b) => a + b, 0);
1071
+ if (stacked) {
1072
+ /* 段纵向堆叠:percent = 柱内合计 100%(恒 0-100,不参与 yMin/yMax);absolute = 相对全局最大合计。
1073
+ * 段间距取 `CHART_BREAKPOINTS.stackedGapPx`(R20:子元素间距唯一常量,stacked 段与 grouped 子柱同用)。 */
1074
+ const unit = stackMode === 'percent'
1075
+ ? (total > 0 ? frame.h / total : 0)
1076
+ : (maxTotal > 0 ? frame.h / maxTotal : 0);
1077
+ let cursor = frame.y1;
1078
+ values.forEach((value, j) => {
1079
+ const segH = value * unit;
1080
+ const carve = j === 0 ? 0 : gap;
1081
+ barsSvg += '<rect class="' + STYLE_PREFIX + 'charts-seg" data-i="' + i + '" data-seg="' + j + '" x="'
1082
+ + n1(cx - barW / 2) + '" y="' + n1(cursor - segH + carve) + '" width="' + n1(barW)
1083
+ + '" height="' + n1(Math.max(1, segH - carve)) + '" rx="2" fill="'
1084
+ + esc(segColorFor(j, item, opts ?? {}, common)) + '"'
1085
+ + tipAttrs(common, () => segNames[j] + ': ' + fmtValue(value, common.format)) + actionAttrs(common, i) + '/>';
1086
+ cursor -= segH;
1087
+ });
1088
+ return;
1089
+ }
1090
+ /* grouped:每列 N 根并排子柱(宽度均分,间距 = CHART_BREAKPOINTS.stackedGapPx) */
1091
+ const subW = (barW - gap * (segCount - 1)) / segCount;
1092
+ let group = '<g class="' + STYLE_PREFIX + 'charts-group" data-i="' + i + '" data-gap="' + gap + '">';
1093
+ values.forEach((value, j) => {
1094
+ const x = cx - barW / 2 + j * (subW + gap);
1095
+ const yv = yAt(frame, value, lo, hi);
1096
+ const top = Math.min(yZero, yv);
1097
+ const height = Math.max(2, Math.abs(yZero - yv));
1098
+ group += '<rect class="' + STYLE_PREFIX + 'charts-bar" data-i="' + i + '" data-seg="' + j + '" x="' + n1(x)
1099
+ + '" y="' + n1(top) + '" width="' + n1(subW) + '" height="' + n1(height) + '" rx="2" fill="'
1100
+ + esc(segColorFor(j, item, opts ?? {}, common)) + '"'
1101
+ + tipAttrs(common, () => segNames[j] + ': ' + fmtValue(value, common.format))
1102
+ + actionAttrs(common, i) + '/>';
1103
+ });
1104
+ barsSvg += group + '</g>';
1105
+ });
1106
+ /* 数值标签:单柱 = 值;stacked = 柱顶合计;grouped = 每子柱顶部 */
1107
+ let valuesSvg = '';
1108
+ if (common.showValues !== false) {
1109
+ items.forEach((item, i) => {
1110
+ const cx = frame.x0 + slot * (i + 0.5);
1111
+ if (!multi) {
1112
+ const value = item.value;
1113
+ const top = Math.min(yZero, yAt(frame, value, lo, hi));
1114
+ valuesSvg += '<text class="' + STYLE_PREFIX + 'charts-value" x="' + n1(cx) + '" y="' + n1(top - 3)
1115
+ + '" text-anchor="middle" fill="' + MUTED_COLOR + '">' + esc(fmtValue(value, common.format)) + '</text>';
1116
+ return;
1117
+ }
1118
+ if (stacked) {
1119
+ const total = item.values.reduce((a, b) => a + b, 0);
1120
+ /* 合计标签与段高**同尺度**:段高单位 = frame.h / (percent ? 柱内合计 : 全局最大合计),
1121
+ * 故柱顶 = frame.y1 - total*unit(percent 恒 frame.y0)。旧版标签贴柱顶(`charts.js:277`),
1122
+ * 用带 padding 的 `yAt(domain)` 会落到柱顶下方约 5%(R2-N2)。 */
1123
+ const unit = stackMode === 'percent'
1124
+ ? (total > 0 ? frame.h / total : 0)
1125
+ : (maxTotal > 0 ? frame.h / maxTotal : 0);
1126
+ const top = stackMode === 'percent' ? frame.y0 : frame.y1 - total * unit;
1127
+ valuesSvg += '<text class="' + STYLE_PREFIX + 'charts-value ' + STYLE_PREFIX + 'charts-value-total" x="' + n1(cx)
1128
+ + '" y="' + n1(top - 3) + '" text-anchor="middle" fill="' + MUTED_COLOR + '">'
1129
+ + esc(fmtValue(total, common.format)) + '</text>';
1130
+ return;
1131
+ }
1132
+ const subW = (barW - gap * (segCount - 1)) / segCount;
1133
+ item.values.forEach((value, j) => {
1134
+ const x = cx - barW / 2 + j * (subW + gap) + subW / 2;
1135
+ const top = Math.min(yZero, yAt(frame, value, lo, hi));
1136
+ valuesSvg += '<text class="' + STYLE_PREFIX + 'charts-value" x="' + n1(x) + '" y="' + n1(top - 3)
1137
+ + '" text-anchor="middle" fill="' + MUTED_COLOR + '">' + esc(fmtValue(value, common.format)) + '</text>';
1138
+ });
1139
+ });
1140
+ }
1141
+ const legendEntries = multi ? segNames.map((name, j) => ({ name, color: segColorFor(j, items[0], opts ?? {}, common) })) : [];
1142
+ const points = items.length;
1143
+ const html = containerOpen('bar', common, STYLE_PREFIX + 'charts-bar')
1144
+ + legendHtml(legendEntries)
1145
+ + svgOpen(common)
1146
+ + (common.grid ? gridSvg(frame) : '')
1147
+ + barsSvg
1148
+ + valuesSvg
1149
+ /* t-chartfix:柱标签取柱列中心(与 `barsSvg` 同一 band 标度),不得用点标度。 */
1150
+ + xLabelsSvg(common, frame, items, 'edge', (i, n) => frame.x0 + (frame.w / n) * (i + 0.5))
1151
+ + '</svg></div>';
1152
+ return { kind: 'bar', html, empty: points === 0, points };
1153
+ }
1154
+ /* ── donut ────────────────────────────────────────────────────────────── */
1155
+ function renderDonut(raw) {
1156
+ const input = requireObject(raw, 'charts.donut: input');
1157
+ const common = resolveCommon(input.options, { width: 150, height: 150, labels: 'none', showValues: false });
1158
+ const opts = isPlainObject(input.options) ? input.options : undefined;
1159
+ const items = normalizeItems(input.items, 'donut', false);
1160
+ if (items.length === 0)
1161
+ return emptyOutput('donut', input.options);
1162
+ const total = items.reduce((sum, item) => sum + Math.max(0, item.value), 0);
1163
+ if (total <= 0)
1164
+ return emptyOutput('donut', input.options, DONUT_ZERO_HINT);
1165
+ const size = numOr(opts === undefined ? undefined : opts.size, 150);
1166
+ const ringWidth = numOr(opts === undefined ? undefined : opts.ringWidth, 16);
1167
+ const legendMode = opts !== undefined && (opts.legend === 'bottom' || opts.legend === 'none') ? opts.legend : 'right';
1168
+ const showPercent = opts === undefined || opts.showPercent !== false;
1169
+ const cx = size / 2;
1170
+ const r = Math.max(20, size / 2 - ringWidth / 2 - 2);
1171
+ const circumference = 2 * Math.PI * r;
1172
+ let offset = 0;
1173
+ let arcs = '';
1174
+ items.forEach((item, i) => {
1175
+ const value = Math.max(0, item.value);
1176
+ if (value <= 0)
1177
+ return;
1178
+ const share = value / total;
1179
+ const color = common.color ?? (item.color ?? (common.colors !== undefined && common.colors.length > 0
1180
+ ? common.colors[i % common.colors.length]
1181
+ : CHART_PALETTE[i % CHART_PALETTE.length]));
1182
+ const dash = round2(share * circumference);
1183
+ arcs += '<circle class="' + STYLE_PREFIX + 'charts-arc" data-i="' + i + '" cx="' + n1(cx) + '" cy="' + n1(cx)
1184
+ + '" r="' + n1(r) + '" fill="none" stroke="' + esc(color) + '" stroke-width="' + ringWidth
1185
+ + '" stroke-dasharray="' + dash + ' ' + round2(circumference - dash) + '" stroke-dashoffset="' + round2(-offset)
1186
+ + '" transform="rotate(-90 ' + n1(cx) + ' ' + n1(cx) + ')" vector-effect="non-scaling-stroke"'
1187
+ + tipAttrs(common, () => item.label + ': ' + fmtValue(value, common.format))
1188
+ + actionAttrs(common, i) + '/>';
1189
+ offset += share * circumference;
1190
+ });
1191
+ const centerLabel = opts !== undefined && typeof opts.centerLabel === 'string' ? opts.centerLabel : '';
1192
+ const centerValue = opts !== undefined && typeof opts.centerValue === 'string' && opts.centerValue !== ''
1193
+ ? opts.centerValue
1194
+ : fmtValue(total, common.format);
1195
+ let center = '';
1196
+ if (centerLabel !== '') {
1197
+ center += '<text class="' + STYLE_PREFIX + 'charts-center-label" x="' + n1(cx) + '" y="' + n1(cx - 2)
1198
+ + '" text-anchor="middle" fill="' + MUTED_COLOR + '">' + esc(centerLabel) + '</text>';
1199
+ }
1200
+ if (centerValue !== '') {
1201
+ center += '<text class="' + STYLE_PREFIX + 'charts-center-value" x="' + n1(cx) + '" y="' + n1(cx + 12)
1202
+ + '" text-anchor="middle" fill="var(--fg,#1d1d1f)">' + esc(centerValue) + '</text>';
1203
+ }
1204
+ let legend = '';
1205
+ if (legendMode !== 'none') {
1206
+ legend = '<div class="' + STYLE_PREFIX + 'charts-legend ' + STYLE_PREFIX + 'charts-legend-' + legendMode + '">'
1207
+ + items.map((item, i) => {
1208
+ const value = Math.max(0, item.value);
1209
+ if (value <= 0)
1210
+ return '';
1211
+ const color = item.color ?? (common.colors !== undefined && common.colors.length > 0
1212
+ ? common.colors[i % common.colors.length]
1213
+ : CHART_PALETTE[i % CHART_PALETTE.length]);
1214
+ return '<span class="' + STYLE_PREFIX + 'charts-legend-item"><span class="' + STYLE_PREFIX
1215
+ + 'charts-legend-swatch" style="background:' + esc(color) + '"></span>' + esc(item.label)
1216
+ + '<span class="' + STYLE_PREFIX + 'charts-legend-value">' + esc(fmtValue(value, common.format)) + '</span>'
1217
+ + (showPercent ? '<span class="' + STYLE_PREFIX + 'charts-legend-pct">'
1218
+ + Math.round((value / total) * 100) + '%</span>' : '') + '</span>';
1219
+ }).join('') + '</div>';
1220
+ }
1221
+ const points = items.filter((item) => item.value > 0).length;
1222
+ const html = containerOpen('donut', common, STYLE_PREFIX + 'charts-donut')
1223
+ + '<svg class="' + STYLE_PREFIX + 'charts-svg" viewBox="0 0 ' + n1(size) + ' ' + n1(size)
1224
+ + '" preserveAspectRatio="xMidYMid meet" role="img" focusable="false">'
1225
+ + '<circle class="' + STYLE_PREFIX + 'charts-ring" cx="' + n1(cx) + '" cy="' + n1(cx) + '" r="' + n1(r)
1226
+ + '" fill="none" stroke="' + GRID_COLOR + '" stroke-width="' + ringWidth + '" vector-effect="non-scaling-stroke"/>'
1227
+ + arcs + center + '</svg>'
1228
+ + legend
1229
+ + '</div>';
1230
+ return { kind: 'donut', html, empty: points === 0, points };
1231
+ }
1232
+ /* ── progress ─────────────────────────────────────────────────────────── */
1233
+ function renderProgress(raw) {
1234
+ const input = requireObject(raw, 'charts.progress: input');
1235
+ const common = resolveCommon(input.options, { width: 100, height: 8, labels: 'none', showValues: false });
1236
+ const opts = isPlainObject(input.options) ? input.options : undefined;
1237
+ const pct = normalizePct(input.pct, 'progress');
1238
+ const color = common.color ?? DEFAULT_SERIES_COLOR;
1239
+ const fillW = (common.width * pct) / 100;
1240
+ const radius = common.height / 2;
1241
+ let fill = esc(color);
1242
+ let defs = '';
1243
+ if (opts !== undefined && opts.gradient === true) {
1244
+ const id = STYLE_PREFIX + 'charts-grad-' + hash32(color + '|' + CHART_PALETTE[5]);
1245
+ defs = '<defs><linearGradient id="' + id + '" x1="0" y1="0" x2="1" y2="0">'
1246
+ + '<stop offset="0" stop-color="' + esc(color) + '"/>'
1247
+ + '<stop offset="1" stop-color="' + CHART_PALETTE[5] + '"/>'
1248
+ + '</linearGradient></defs>';
1249
+ fill = 'url(#' + id + ')';
1250
+ }
1251
+ const showPct = opts === undefined || opts.showPct !== false;
1252
+ /* `height` 的旧契约语义 = **轨道 px**(`docs/research/t92-old-v130-signatures.md:180`
1253
+ * 「`height(轨道px)`」;旧 `charts.js:370-372` 写成内联 `height:Npx`)——只进 viewBox 会让
1254
+ * 缺省 8 在 320px 容器渲染约 25.6px 高(3 倍厚,R2-N11),故 svg 内联 `style="height:Npx"`。 */
1255
+ const html = containerOpen('progress', common, STYLE_PREFIX + 'charts-progress')
1256
+ + '<svg class="' + STYLE_PREFIX + 'charts-svg" viewBox="0 0 ' + n1(common.width) + ' ' + n1(common.height)
1257
+ + '" style="height:' + common.height + 'px"'
1258
+ + ' preserveAspectRatio="none" role="img" focusable="false" data-pct="' + pct + '">'
1259
+ + defs
1260
+ + '<rect class="' + STYLE_PREFIX + 'charts-track" x="0" y="0" width="' + n1(common.width) + '" height="'
1261
+ + n1(common.height) + '" rx="' + n1(radius) + '" fill="' + GRID_COLOR + '"/>'
1262
+ + '<rect class="' + STYLE_PREFIX + 'charts-fillbar" x="0" y="0" width="' + n1(fillW) + '" height="'
1263
+ + n1(common.height) + '" rx="' + n1(radius) + '" fill="' + fill + '"/>'
1264
+ + '</svg>'
1265
+ + (showPct ? '<span class="' + STYLE_PREFIX + 'charts-pct">' + Math.round(pct) + '%</span>' : '')
1266
+ + '</div>';
1267
+ return { kind: 'progress', html, empty: false, points: 1 };
1268
+ }
1269
+ /* ── combo ────────────────────────────────────────────────────────────── */
1270
+ function renderCombo(raw) {
1271
+ const input = requireObject(raw, 'charts.combo: input');
1272
+ const common = resolveCommon(input.options, { width: 320, height: 170, labels: 'all', showValues: true });
1273
+ const opts = isPlainObject(input.options) ? input.options : undefined;
1274
+ const bars = normalizeItems(input.bars, 'combo', false);
1275
+ const lines = normalizeItems(input.lines, 'combo', false);
1276
+ if (bars.length === 0 && lines.length === 0)
1277
+ return emptyOutput('combo', input.options);
1278
+ if (bars.length > 0 && lines.length > 0) {
1279
+ if (bars.length !== lines.length) {
1280
+ badStructure('charts.combo: bars 与 lines 长度不一致 (' + bars.length + ' vs ' + lines.length + ')');
1281
+ }
1282
+ bars.forEach((item, i) => {
1283
+ if (item.label !== lines[i].label) {
1284
+ badStructure('charts.combo: bars 与 lines 的 label 必须一致 (items[' + i + ']: ' + item.label + ' vs ' + lines[i].label + ')');
1285
+ }
1286
+ });
1287
+ }
1288
+ const n = bars.length > 0 ? bars.length : lines.length;
1289
+ const barColor = opts !== undefined && typeof opts.barColor === 'string' && opts.barColor !== ''
1290
+ ? opts.barColor
1291
+ : (common.color ?? DEFAULT_SERIES_COLOR);
1292
+ const lineColor = opts !== undefined && typeof opts.lineColor === 'string' && opts.lineColor !== ''
1293
+ ? opts.lineColor
1294
+ : DEFAULT_MARK_COLOR;
1295
+ const values = [];
1296
+ for (const item of bars)
1297
+ values.push(item.value);
1298
+ for (const item of lines)
1299
+ values.push(item.value);
1300
+ /* combo 共享域 = 0..max(柱值, 线值),**无外扩**(旧 `charts.js:742-753`:柱高 = 值/maxV,柱顶即域顶)。 */
1301
+ const [lo, hi] = domainOf(values, common.yMin, common.yMax, true, 0);
1302
+ const frame = makeFrame(common, insetsFor(common, {
1303
+ tickWidth: 0,
1304
+ labelHeight: common.labels === 'none' ? 0 : (common.compact ? 10 : 14),
1305
+ valueHeight: common.compact ? 9 : 12,
1306
+ }));
1307
+ const yZero = yAt(frame, 0, lo, hi);
1308
+ const slot = frame.w / n;
1309
+ const barW = Math.min(slot * (common.compact ? 0.82 : 0.62), 34);
1310
+ let barsSvg = '';
1311
+ bars.forEach((item, i) => {
1312
+ const cx = frame.x0 + slot * (i + 0.5);
1313
+ const value = item.value;
1314
+ const top = Math.min(yZero, yAt(frame, value, lo, hi));
1315
+ const height = Math.max(2, Math.abs(yZero - yAt(frame, value, lo, hi)));
1316
+ barsSvg += '<rect class="' + STYLE_PREFIX + 'charts-bar" data-i="' + i + '" x="' + n1(cx - barW / 2) + '" y="'
1317
+ + n1(top) + '" width="' + n1(barW) + '" height="' + n1(height) + '" rx="2" fill="' + esc(barColor) + '"'
1318
+ + tipAttrs(common, () => item.label + ': ' + fmtValue(value, common.format)) + actionAttrs(common, i) + '/>';
1319
+ });
1320
+ let lineSvg = '';
1321
+ if (lines.length > 0) {
1322
+ /* FX-A1c-01(编排者改判:缺陷):线点 x 取**柱列中心**,与柱共用同一 band 标度
1323
+ * `frame.x0 + slot*(i+0.5)`(`slot = frame.w / n`,`n = bars.length || lines.length`)。
1324
+ * 旧版把线点 `<i>` 放进柱列容器内 `left:50%`(`old-baseline.md:442`/`charts.js:429`),
1325
+ * 再由 DOM 校准对齐列心(`charts.js:779-795`);R19 豁免的只是 `getBoundingClientRect` 测量,
1326
+ * **不含 x 对齐**——同一 band 标度就是「同一坐标系」的可断言等价物(R9「线点=柱顶」)。 */
1327
+ const pts = lines.map((item, i) => [frame.x0 + slot * (i + 0.5), yAt(frame, item.value, lo, hi), false]);
1328
+ lineSvg += '<path class="' + STYLE_PREFIX + 'charts-line ' + STYLE_PREFIX + 'charts-combo-line" d="'
1329
+ + polyPath(pts, true) + '" fill="none" stroke="' + esc(lineColor)
1330
+ + '" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>';
1331
+ pts.forEach((p, i) => {
1332
+ lineSvg += '<circle class="' + STYLE_PREFIX + 'charts-dot" data-i="' + i + '" cx="' + n1(p[0]) + '" cy="'
1333
+ + n1(p[1]) + '" r="3" fill="' + DOT_FACE_COLOR + '" stroke="' + esc(lineColor)
1334
+ + '" stroke-width="1.5" vector-effect="non-scaling-stroke"'
1335
+ + tipAttrs(common, () => lines[i].label + ': ' + fmtValue(lines[i].value, common.format))
1336
+ + actionAttrs(common, i) + '/>';
1337
+ });
1338
+ }
1339
+ let valuesSvg = '';
1340
+ if (common.showValues !== false) {
1341
+ bars.forEach((item, i) => {
1342
+ const cx = frame.x0 + slot * (i + 0.5);
1343
+ const top = Math.min(yZero, yAt(frame, item.value, lo, hi));
1344
+ valuesSvg += '<text class="' + STYLE_PREFIX + 'charts-value" x="' + n1(cx) + '" y="' + n1(top - 3)
1345
+ + '" text-anchor="middle" fill="' + MUTED_COLOR + '">' + esc(fmtValue(item.value, common.format)) + '</text>';
1346
+ });
1347
+ }
1348
+ const legend = opts !== undefined && opts.legend === true
1349
+ ? legendHtml([{ name: '量', color: barColor }, { name: '趋势', color: lineColor }])
1350
+ : '';
1351
+ const points = bars.length + lines.length;
1352
+ const html = containerOpen('combo', common, STYLE_PREFIX + 'charts-combo')
1353
+ + legend
1354
+ + svgOpen(common, undefined, 'none')
1355
+ + (common.grid ? gridSvg(frame) : '')
1356
+ + barsSvg
1357
+ + lineSvg
1358
+ + valuesSvg
1359
+ /* t-chartfix:与 bar 同因——combo 柱列/线点同走 band 标度,标签亦然。 */
1360
+ + xLabelsSvg(common, frame, bars.length > 0 ? bars : lines, 'peak', (i, n) => frame.x0 + (frame.w / n) * (i + 0.5))
1361
+ + '</svg></div>';
1362
+ return { kind: 'combo', html, empty: points === 0, points };
1363
+ }
1364
+ /* ── sparkline ────────────────────────────────────────────────────────── */
1365
+ function renderSparkline(raw) {
1366
+ const input = requireObject(raw, 'charts.sparkline: input');
1367
+ const common = resolveCommon(input.options, { width: 90, height: 30, labels: 'none', showValues: false });
1368
+ const opts = isPlainObject(input.options) ? input.options : undefined;
1369
+ const items = normalizeItems(input.items, 'sparkline', false);
1370
+ if (items.length === 0)
1371
+ return emptyOutput('sparkline', input.options);
1372
+ const values = items.map((item) => item.value);
1373
+ const min = Math.min(...values);
1374
+ const max = Math.max(...values);
1375
+ const span = max === min ? 1 : max - min;
1376
+ const pad = 2;
1377
+ const frame = makeFrame(common, { left: pad, right: pad, top: pad, bottom: pad });
1378
+ const pts = items.map((item, i) => [
1379
+ xAt(frame, i, items.length),
1380
+ frame.y1 - ((item.value - min) / span) * frame.h,
1381
+ false,
1382
+ ]);
1383
+ const rising = values[values.length - 1] >= values[0];
1384
+ const color = common.color ?? (rising ? UP_COLOR : DOWN_COLOR);
1385
+ const showValue = opts === undefined || opts.showValue !== false;
1386
+ let valueSvg = '';
1387
+ if (showValue) {
1388
+ valueSvg = '<text class="' + STYLE_PREFIX + 'charts-spark-value ' + STYLE_PREFIX + 'charts-' + (rising ? 'up' : 'down')
1389
+ + '" x="' + n1(frame.x1) + '" y="' + n1(frame.y0 + 4) + '" text-anchor="end" fill="' + esc(color) + '">'
1390
+ + esc(fmtValue(values[values.length - 1], common.format)) + '</text>';
1391
+ }
1392
+ const html = containerOpen('sparkline', common, STYLE_PREFIX + 'charts-spark')
1393
+ + svgOpen(common)
1394
+ + '<polyline class="' + STYLE_PREFIX + 'charts-sparkline" points="'
1395
+ + pts.map((p) => n1(p[0]) + ',' + n1(p[1])).join(' ')
1396
+ + '" fill="none" stroke="' + esc(color)
1397
+ + '" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>'
1398
+ + valueSvg
1399
+ + '</svg></div>';
1400
+ return { kind: 'sparkline', html, empty: false, points: items.length };
1401
+ }
1402
+ /* ── gauge ────────────────────────────────────────────────────────────── */
1403
+ function renderGauge(raw) {
1404
+ const input = requireObject(raw, 'charts.gauge: input');
1405
+ const common = resolveCommon(input.options, { width: 170, height: 105, labels: 'none', showValues: false });
1406
+ const opts = isPlainObject(input.options) ? input.options : undefined;
1407
+ const pct = normalizePct(input.pct, 'gauge');
1408
+ const size = numOr(opts === undefined ? undefined : opts.size, 170);
1409
+ const height = Math.round(size * 0.62);
1410
+ const cx = size / 2;
1411
+ const r = Math.min(size / 2 - 4, height - 8);
1412
+ const cy = height - 4;
1413
+ const color = common.color ?? DEFAULT_SERIES_COLOR;
1414
+ const fraction = pct / 100;
1415
+ const endAngle = Math.PI * (1 - fraction);
1416
+ const x1 = cx + r * Math.cos(endAngle);
1417
+ const y1 = cy - r * Math.sin(endAngle);
1418
+ const startX = cx - r;
1419
+ const label = opts !== undefined && typeof opts.label === 'string' ? opts.label : '';
1420
+ const valueText = common.format !== undefined
1421
+ ? String(common.format(Math.round(pct)))
1422
+ : Math.round(pct) + '%';
1423
+ const html = containerOpen('gauge', common, STYLE_PREFIX + 'charts-gauge')
1424
+ + '<svg class="' + STYLE_PREFIX + 'charts-svg" viewBox="0 0 ' + n1(size) + ' ' + n1(height)
1425
+ + '" preserveAspectRatio="xMidYMid meet" role="img" focusable="false" data-pct="' + pct + '">'
1426
+ + '<path class="' + STYLE_PREFIX + 'charts-gauge-bg" d="M' + n1(startX) + ' ' + n1(cy) + ' A' + n1(r) + ' '
1427
+ + n1(r) + ' 0 0 1 ' + n1(cx + r) + ' ' + n1(cy) + '" fill="none" stroke="' + GRID_COLOR
1428
+ + '" stroke-width="12" stroke-linecap="round" vector-effect="non-scaling-stroke"/>'
1429
+ + '<path class="' + STYLE_PREFIX + 'charts-gauge-fg" d="M' + n1(startX) + ' ' + n1(cy) + ' A' + n1(r) + ' '
1430
+ + n1(r) + ' 0 0 1 ' + n1(x1) + ' ' + n1(y1) + '" fill="none" stroke="' + esc(color)
1431
+ + '" stroke-width="12" stroke-linecap="round" vector-effect="non-scaling-stroke"/>'
1432
+ + '<text class="' + STYLE_PREFIX + 'charts-gauge-value" x="' + n1(cx) + '" y="' + n1(cy - 14)
1433
+ + '" text-anchor="middle" fill="var(--fg,#1d1d1f)">' + esc(valueText) + '</text>'
1434
+ + (label === '' ? '' : '<text class="' + STYLE_PREFIX + 'charts-gauge-label" x="' + n1(cx) + '" y="'
1435
+ + n1(cy - 2) + '" text-anchor="middle" fill="' + MUTED_COLOR + '">' + esc(label) + '</text>')
1436
+ + '</svg></div>';
1437
+ return { kind: 'gauge', html, empty: false, points: 1 };
1438
+ }
1439
+ /* ── scatter ──────────────────────────────────────────────────────────── */
1440
+ function normalizeScatterItems(raw) {
1441
+ if (!Array.isArray(raw)) {
1442
+ badStructure('charts.scatter: items 必须是数组, 收到 ' + (raw === null ? 'null' : typeof raw));
1443
+ }
1444
+ return raw.map((entry, i) => {
1445
+ const field = 'charts.scatter: items[' + i + ']';
1446
+ const item = requireObject(entry, field);
1447
+ if (!isNum(item.x) || !isNum(item.y)) {
1448
+ badStructure(field + '.x/y 无效(缺省/非数字): ' + JSON.stringify({ x: item.x === undefined ? null : item.x, y: item.y === undefined ? null : item.y }));
1449
+ }
1450
+ return {
1451
+ x: item.x,
1452
+ y: item.y,
1453
+ ...(typeof item.label === 'string' && item.label !== '' ? { label: item.label } : {}),
1454
+ };
1455
+ });
1456
+ }
1457
+ function renderScatter(raw) {
1458
+ const input = requireObject(raw, 'charts.scatter: input');
1459
+ const common = resolveCommon(input.options, { width: 320, height: 180, labels: 'edge', showValues: false });
1460
+ const opts = isPlainObject(input.options) ? input.options : undefined;
1461
+ const items = normalizeScatterItems(input.items);
1462
+ if (items.length === 0)
1463
+ return emptyOutput('scatter', input.options);
1464
+ const xs = items.map((item) => item.x);
1465
+ const ys = items.map((item) => item.y);
1466
+ const [xlo, xhi] = domainOf(xs, undefined, undefined, false);
1467
+ const [ylo, yhi] = domainOf(ys, common.yMin, common.yMax, false);
1468
+ const frame = makeFrame(common, insetsFor(common, {
1469
+ tickWidth: common.compact ? 16 : 22,
1470
+ labelHeight: common.labels === 'none' ? 0 : (common.compact ? 10 : 14),
1471
+ valueHeight: common.compact ? 9 : 12,
1472
+ }));
1473
+ /* 显式 `dotSize` 必须走**内联样式**:CSS 几何属性 `r`(`.…-scatter .…-dot{r:calc(var(--…-dot)/2)}`)
1474
+ * 优先于 SVG 呈现属性,只写 `r="…"` 会被 CSS 覆盖成缺省半径(R2-N10/FX-78-A1b-02)。 */
1475
+ const dotSize = numOr(opts === undefined ? undefined : opts.dotSize, SCATTER_DOT_DEFAULT_PX);
1476
+ const dotSizeExplicit = opts !== undefined && isNum(opts.dotSize);
1477
+ const color = common.color ?? DEFAULT_SERIES_COLOR;
1478
+ let dotsSvg = '';
1479
+ items.forEach((item, i) => {
1480
+ const x = frame.x0 + ((item.x - xlo) / (xhi - xlo)) * frame.w;
1481
+ const y = yAt(frame, item.y, ylo, yhi);
1482
+ dotsSvg += '<circle class="' + STYLE_PREFIX + 'charts-dot" data-i="' + i + '" cx="' + n1(x) + '" cy="' + n1(y)
1483
+ + '" r="' + dotSize / 2 + '"' + (dotSizeExplicit ? ' style="r:' + dotSize / 2 + '"' : '')
1484
+ + ' fill="' + DOT_FACE_COLOR + '" stroke="' + esc(color)
1485
+ + '" stroke-width="1.5" vector-effect="non-scaling-stroke"'
1486
+ + tipAttrs(common, () => (item.label === undefined ? String(item.x) : item.label) + ': ' + fmtValue(item.y, common.format))
1487
+ + actionAttrs(common, i) + '/>';
1488
+ });
1489
+ /* 最小二乘回归线(`regression !== false` 且 n >= 2) */
1490
+ let regressionSvg = '';
1491
+ if ((opts === undefined || opts.regression !== false) && items.length >= 2) {
1492
+ const n = items.length;
1493
+ let sx = 0;
1494
+ let sy = 0;
1495
+ let sxy = 0;
1496
+ let sxx = 0;
1497
+ for (const item of items) {
1498
+ sx += item.x;
1499
+ sy += item.y;
1500
+ sxy += item.x * item.y;
1501
+ sxx += item.x * item.x;
1502
+ }
1503
+ const denom = n * sxx - sx * sx;
1504
+ const slope = denom === 0 ? 0 : (n * sxy - sx * sy) / denom;
1505
+ const intercept = (sy - slope * sx) / n;
1506
+ const yOf = (x) => slope * x + intercept;
1507
+ const rx0 = frame.x0;
1508
+ const rx1 = frame.x1;
1509
+ const regressionColor = opts !== undefined && typeof opts.regressionColor === 'string' && opts.regressionColor !== ''
1510
+ ? opts.regressionColor
1511
+ : DEFAULT_REGRESSION_COLOR;
1512
+ regressionSvg = '<line class="' + STYLE_PREFIX + 'charts-regression" x1="' + n1(rx0) + '" y1="'
1513
+ + n1(yAt(frame, yOf(xlo), ylo, yhi)) + '" x2="' + n1(rx1) + '" y2="' + n1(yAt(frame, yOf(xhi), ylo, yhi))
1514
+ + '" stroke="' + esc(regressionColor) + '" stroke-width="1.5" stroke-dasharray="5 4" vector-effect="non-scaling-stroke"/>';
1515
+ }
1516
+ const labels = common.labels;
1517
+ let xLabels = '';
1518
+ if (labels !== 'none') {
1519
+ const baseY = frame.y1 + (common.compact ? 9 : 12);
1520
+ const pick = labels === 'all' ? items.map((_, i) => i) : items.length === 1 ? [0] : [0, items.length - 1];
1521
+ xLabels = pick.map((i) => {
1522
+ const x = frame.x0 + ((items[i].x - xlo) / (xhi - xlo)) * frame.w;
1523
+ const rotate = common.labelRotate === 0 ? '' : ' transform="rotate(' + common.labelRotate + ' ' + n1(x) + ' ' + n1(baseY) + ')"';
1524
+ const text = items[i].label === undefined ? fmtValue(items[i].x, common.format) : items[i].label;
1525
+ return '<text class="' + STYLE_PREFIX + 'charts-xlabel" x="' + n1(x) + '" y="' + n1(baseY)
1526
+ + '" text-anchor="middle"' + rotate + ' fill="' + MUTED_COLOR + '">' + esc(text) + '</text>';
1527
+ }).join('');
1528
+ }
1529
+ const points = items.length;
1530
+ const html = containerOpen('scatter', common, STYLE_PREFIX + 'charts-scatter')
1531
+ + svgOpen(common, undefined, 'none')
1532
+ + (common.grid ? gridSvg(frame) : '')
1533
+ + ticksSvg(frame, ylo, yhi, SCATTER_Y_TICKS, common.format)
1534
+ + regressionSvg
1535
+ + dotsSvg
1536
+ + xLabels
1537
+ + '</svg></div>';
1538
+ return { kind: 'scatter', html, empty: points === 0, points };
1539
+ }
1540
+ const CHART_DISPATCH = {
1541
+ bar: renderBar,
1542
+ line: renderLine,
1543
+ donut: renderDonut,
1544
+ progress: renderProgress,
1545
+ combo: renderCombo,
1546
+ sparkline: renderSparkline,
1547
+ gauge: renderGauge,
1548
+ scatter: renderScatter,
1549
+ };
1550
+ const PROXY_SAFE_KEYS = ['then', 'toJSON', 'inspect', 'constructor', 'toString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable', 'toLocaleString'];
1551
+ /** 冻结签名:`ChartsApi`(8 方法,去掉旧 `el` 参数,DOM 由调用方挂载)。
1552
+ * 派表按 `ChartKind` 键入(`satisfies ChartDispatch` 已在编译期校验 8 个键与签名);
1553
+ * **JS 调用方**访问表外 key(如 `charts['pie']`)→ 调用即抛 `ChartError` code `kind-unknown`
1554
+ * (类型层不可达,R15)。导出类型 = 冻结 `ChartsApi`,**不用 `as` 断言**(FX-78-A1b-15)。 */
1555
+ export const charts = new Proxy(CHART_DISPATCH, {
1556
+ get(target, prop, receiver) {
1557
+ if (typeof prop === 'string' && !Object.prototype.hasOwnProperty.call(target, prop)
1558
+ && !PROXY_SAFE_KEYS.includes(prop)) {
1559
+ return () => badKind(prop);
1560
+ }
1561
+ return Reflect.get(target, prop, receiver);
1562
+ },
1563
+ });
1564
+ /* ── 图表 CSS 文本(R12:唯一产出者 = 本文件内部常量/函数;#75 复用同一份) ── */
1565
+ /** 图表样式文本(**唯一一份**):类名走 `prefix` 命名空间;容器零 padding;
1566
+ * 断点数值逐值取 `CHART_BREAKPOINTS`(`mobileMaxPx`/`dotSizeMobilePx`/`lineHeightMobilePx`)。
1567
+ *
1568
+ * **#75 复用点**:`buildStyleSheet()` 的 `charts` 样式区直接引用本函数产出,
1569
+ * **不得**在别处重述图表 CSS 文本(#78 结论,违反即 S1)。
1570
+ * 本函数**不从 `src/index.ts` 导出**——冻结面无该条目,导出会打破
1571
+ * 「新增运行时出口恰好等于清单 implemented 的运行时项」出口面锁(同 `ChartError` 口径)。 */
1572
+ export function chartsCss(prefix) {
1573
+ const p = prefix;
1574
+ const mobile = CHART_BREAKPOINTS.mobileMaxPx;
1575
+ const dotMobile = CHART_BREAKPOINTS.dotSizeMobilePx;
1576
+ const lineHeightMobile = CHART_BREAKPOINTS.lineHeightMobilePx;
1577
+ return [
1578
+ '.' + p + 'charts{position:relative;margin:0;padding:0;box-sizing:border-box;font-family:inherit;color:var(--fg,#1d1d1f);--' + p + 'charts-dot:' + SCATTER_DOT_DEFAULT_PX + 'px}',
1579
+ '.' + p + 'charts *{box-sizing:border-box}',
1580
+ '.' + p + 'charts-svg{display:block;width:100%;height:auto;overflow:visible}',
1581
+ '.' + p + 'charts-empty{padding:0}',
1582
+ '.' + p + 'charts-anim .' + p + 'charts-line{transition:stroke-dashoffset .8s cubic-bezier(.22,1,.36,1)}',
1583
+ '.' + p + 'charts-anim .' + p + 'charts-bar{transition:height .5s cubic-bezier(.22,1,.36,1)}',
1584
+ '.' + p + 'charts-anim .' + p + 'charts-fillbar{transition:width .5s cubic-bezier(.22,1,.36,1)}',
1585
+ '.' + p + 'charts-anim .' + p + 'charts-dot{animation:' + p + 'charts-fade .5s ease both}',
1586
+ '@keyframes ' + p + 'charts-fade{from{opacity:0}to{opacity:1}}',
1587
+ '.' + p + 'charts-dot{pointer-events:none}',
1588
+ '.' + p + 'charts-dot-anomaly{filter:drop-shadow(0 0 3px rgba(255,59,48,.18))}',
1589
+ '.' + p + 'charts-markpoint{filter:drop-shadow(0 1px 2px rgba(0,0,0,.28))}',
1590
+ '.' + p + 'charts-scatter .' + p + 'charts-dot{r:calc(var(--' + p + 'charts-dot) / 2)}',
1591
+ '.' + p + 'charts-legend{display:flex;flex-wrap:wrap;gap:8px;font-size:12px;line-height:1.5;color:var(--fg2,#6e6e73)}',
1592
+ '.' + p + 'charts-legend-right{flex-direction:column;align-items:flex-start}',
1593
+ '.' + p + 'charts-legend-bottom{flex-direction:row;justify-content:center}',
1594
+ '.' + p + 'charts-legend-item{display:inline-flex;align-items:center;gap:6px}',
1595
+ '.' + p + 'charts-legend-swatch{display:inline-block;width:8px;height:8px;border-radius:2px}',
1596
+ '.' + p + 'charts-legend-swatch-dashed{background:repeating-linear-gradient(90deg,var(--fg3,#86868b) 0 4px,transparent 4px 8px)}',
1597
+ '.' + p + 'charts-legend-value{color:var(--fg,#1d1d1f)}',
1598
+ '.' + p + 'charts-legend-pct{color:var(--fg3,#86868b)}',
1599
+ '.' + p + 'charts-pct{display:block;font-size:12px;color:var(--fg2,#6e6e73);text-align:right}',
1600
+ /* t-chartfix:进度轨道最小可辨高度 12px。`height` 选项→内联 `height:Npx`/viewBox
1601
+ * 的映射由 `E.height` 钉死(缺省 8)不得改;此处只加样式层下限(不同属性,不与
1602
+ * 内联 height 冲突),缺省与过小的自定义高度渲染为 12px,65% 处可读。 */
1603
+ '.' + p + 'charts-progress .' + p + 'charts-svg{min-height:12px}',
1604
+ '.' + p + 'charts-spark-value{font-size:11px}',
1605
+ /* 文本字号(旧 `charts.js:55,67,70,79,87,90,92,96,102-103,113,122` 逐值):
1606
+ * 缺了这些规则 SVG 文本会继承页面字号(14–16 用户单位),比旧版大 30%–60%(R2-N12)。 */
1607
+ '.' + p + 'charts-tick{font-size:9.5px}',
1608
+ '.' + p + 'charts-xlabel{font-size:10px}',
1609
+ '.' + p + 'charts-value{font-size:10px}',
1610
+ '.' + p + 'charts-value-last{font-size:10.5px;font-weight:700}',
1611
+ '.' + p + 'charts-marktext{font-size:10px}',
1612
+ '.' + p + 'charts-marktext-v{font-size:10px}',
1613
+ '.' + p + 'charts-mptext{font-size:10.5px;font-weight:700}',
1614
+ '.' + p + 'charts-bar .' + p + 'charts-xlabel{font-size:9.5px}',
1615
+ /* t-chartfix D1(2026-09-09 编排者裁定):桌面端 bar 轴标签 10.5px>数值 10px 系倒挂 bug 值
1616
+ * (测试快照,非契约冻结;契约正文零命中 10.5px)。改 9.5px=移动端同选择器值,恢复层级
1617
+ * (9.5<10)与双端一致;全局 xlabel 10px(相等非倒挂)不动,最小 scope。 */
1618
+ '.' + p + 'charts-bar .' + p + 'charts-value{font-size:10px}',
1619
+ '.' + p + 'charts-center-label{font-size:8px}',
1620
+ '.' + p + 'charts-center-value{font-size:13px;font-weight:700}',
1621
+ '.' + p + 'charts-gauge-value{font-size:26px;font-weight:800}',
1622
+ '.' + p + 'charts-gauge-label{font-size:11px}',
1623
+ '@media (max-width:' + mobile + 'px){'
1624
+ + '.' + p + 'charts{--' + p + 'charts-dot:' + dotMobile + 'px}'
1625
+ + '.' + p + 'charts-line .' + p + 'charts-svg{height:' + lineHeightMobile + 'px}'
1626
+ + '.' + p + 'charts-bar .' + p + 'charts-xlabel{font-size:9.5px}'
1627
+ + '.' + p + 'charts-legend{font-size:11.5px}'
1628
+ + '}',
1629
+ ].join(LF);
1630
+ }
1631
+ /* ── 图表 helpers JS(唯一产出者,FX-22;首步幂等自注入 `<style>`) ────── */
1632
+ function helpersPrefix(input) {
1633
+ const prefix = input === undefined || input === null ? undefined : input.prefix;
1634
+ return typeof prefix === 'string' && prefix !== '' ? prefix : STYLE_PREFIX;
1635
+ }
1636
+ function helpersStyleId(input) {
1637
+ const styleId = input === undefined || input === null ? undefined : input.styleId;
1638
+ return typeof styleId === 'string' && styleId !== '' ? styleId : CHARTS_STYLE_ID;
1639
+ }
1640
+ /** 冻结签名:`buildChartsHelpersJs(input?: ChartsHelpersInput): string`。
1641
+ *
1642
+ * 产出恒为**非空**、**经典 script** 作用域可跑的 IIFE(无 `import`/`export`/顶层 `await`),
1643
+ * 逐项满足 `SHARED_HELPERS_JS_RULE`:自包含(不依赖其它脚本或既有全局)/幂等(判据**只落 DOM**:
1644
+ * `getElementById(styleId)` 早退,**不**用全局哨兵)/允许页面侧 DOM(`document.*`)/
1645
+ * 不向 `window.<id>`/`globalThis.<id>` 赋值/不引 `node:`。
1646
+ *
1647
+ * 首步即幂等自注入 `<style id="{styleId ?? CHARTS_STYLE_ID}">`(R3/R12);图表 CSS 文本
1648
+ * **只此一处产出**(`chartsCss`),#75 落地时复用同一份文本,不得重述。 */
1649
+ export const buildChartsHelpersJs = (input) => {
1650
+ const prefix = helpersPrefix(input);
1651
+ const styleId = helpersStyleId(input);
1652
+ const css = chartsCss(prefix);
1653
+ const lines = [
1654
+ '(function () {',
1655
+ " 'use strict';",
1656
+ ' var STYLE_ID = ' + jsStr(styleId) + ';',
1657
+ ' var CSS = ' + jsStr(css) + ';',
1658
+ '',
1659
+ ' function boot() {',
1660
+ ' if (document.getElementById(STYLE_ID)) return;',
1661
+ ' var host = document.head || document.body;',
1662
+ ' if (!host) return;',
1663
+ ' var style = document.createElement("style");',
1664
+ ' style.id = STYLE_ID;',
1665
+ ' style.textContent = CSS;',
1666
+ ' host.appendChild(style);',
1667
+ ' }',
1668
+ '',
1669
+ ' if (document.head || document.body) boot();',
1670
+ ' else document.addEventListener("DOMContentLoaded", boot);',
1671
+ '}());',
1672
+ ];
1673
+ return lines.join(LF);
1674
+ };
1675
+ /* 记账:本文件产出的 HTML 恒用 `STYLE_PREFIX` 命名空间(`prefix` 覆盖只作用于 CSS 文本);
1676
+ * `CHART_STRUCTURE_RULE`/`CHART_EMPTY_RULE`/`CHART_COORD_RULE` 逐值由 `test/charts.test.mjs` 钉死。 */
1677
+ //# sourceMappingURL=charts.js.map