base-paint 0.3.1 → 0.3.3

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 (62) hide show
  1. package/README.md +94 -1
  2. package/dist/blocks.d.ts +160 -6
  3. package/dist/blocks.d.ts.map +1 -1
  4. package/dist/blocks.js +1212 -27
  5. package/dist/blocks.js.map +1 -1
  6. package/dist/charts.d.ts +6 -4
  7. package/dist/charts.d.ts.map +1 -1
  8. package/dist/charts.js +426 -67
  9. package/dist/charts.js.map +1 -1
  10. package/dist/controls.d.ts +20 -5
  11. package/dist/controls.d.ts.map +1 -1
  12. package/dist/controls.js +242 -34
  13. package/dist/controls.js.map +1 -1
  14. package/dist/font.d.ts +3 -3
  15. package/dist/font.js +3 -3
  16. package/dist/help.d.ts +3 -3
  17. package/dist/help.d.ts.map +1 -1
  18. package/dist/help.js +32 -15
  19. package/dist/help.js.map +1 -1
  20. package/dist/helpShell.d.ts.map +1 -1
  21. package/dist/helpShell.js +2 -2
  22. package/dist/helpShell.js.map +1 -1
  23. package/dist/index.d.ts +10 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +8 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/injector.d.ts +1 -1
  28. package/dist/output/saveHtml.d.ts +37 -2
  29. package/dist/output/saveHtml.d.ts.map +1 -1
  30. package/dist/output/saveHtml.js +125 -15
  31. package/dist/output/saveHtml.js.map +1 -1
  32. package/dist/pageShapes.d.ts +117 -0
  33. package/dist/pageShapes.d.ts.map +1 -0
  34. package/dist/pageShapes.js +366 -0
  35. package/dist/pageShapes.js.map +1 -0
  36. package/dist/pageUi.d.ts +37 -0
  37. package/dist/pageUi.d.ts.map +1 -0
  38. package/dist/pageUi.js +237 -0
  39. package/dist/pageUi.js.map +1 -0
  40. package/dist/spec/charts.d.ts +16 -3
  41. package/dist/spec/charts.d.ts.map +1 -1
  42. package/dist/spec/charts.js +1 -1
  43. package/dist/spec/charts.js.map +1 -1
  44. package/dist/spec/controls.d.ts +30 -5
  45. package/dist/spec/controls.d.ts.map +1 -1
  46. package/dist/spec/controls.js +16 -3
  47. package/dist/spec/controls.js.map +1 -1
  48. package/dist/spec/help.d.ts +34 -6
  49. package/dist/spec/help.d.ts.map +1 -1
  50. package/dist/spec/help.js +21 -4
  51. package/dist/spec/help.js.map +1 -1
  52. package/dist/spec/index.d.ts +6 -3
  53. package/dist/spec/index.d.ts.map +1 -1
  54. package/dist/spec/index.js +27 -2
  55. package/dist/spec/index.js.map +1 -1
  56. package/dist/style.d.ts.map +1 -1
  57. package/dist/style.js +279 -47
  58. package/dist/style.js.map +1 -1
  59. package/dist/text.d.ts.map +1 -1
  60. package/dist/text.js +7 -2
  61. package/dist/text.js.map +1 -1
  62. package/package.json +1 -1
package/dist/charts.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /** base-paint/charts:#78 图表层运行时(契约 §3.5/§6.5)。
2
2
  *
3
- * 落地冻结面里 #78 的三条运行时出口:`charts`(8 方法)/`buildChartsHelpersJs`;
3
+ * 实施冻结面里 #78 的三条运行时出口:`charts`(8 方法)/`buildChartsHelpersJs`;
4
4
  * `ChartError` 与本文件**不**从 `src/index.ts` 导出(冻结面 44 条内无该运行时条目,
5
5
  * 与 `TemplateError`/`ControlsError` 同口径:调用方按 `name === 'ChartError'` + `code` 判定)。
6
6
  *
@@ -25,7 +25,7 @@
25
25
  * 图标 📊 与提示「有记录后自动生成图表」沿用旧基线(纯 UI 文案,非契约常量);
26
26
  * - `dotStyle`/`line.dotSize`/`combo.tooltip` 旧版是死参数,按 R21 实现为**新能力**:
27
27
  * `dotStyle` = 数据点内联 `style` 文本(逐点样式覆盖),`dotSize` = 点直径,`tooltip` = `data-tip` 数据属性;
28
- * - `avgLine(n)` = 旧语义的**均线序列**(窗口收敛 `3..items.length`,`old-baseline.md` §2.2),
28
+ * - `avgLine(n)` = 旧语义的**均线序列**(窗口统一为 `3..items.length`,`old-baseline.md` §2.2),
29
29
  * 不是水平线;`emptyText`/`compact`/`bar.grid` 旧版是死参数,按冻结类型补齐为有效选项;
30
30
  * - `prefix` 覆盖只影响 `buildChartsHelpersJs` **产出的 CSS 文本**;`charts.*` 产出的 HTML 恒用
31
31
  * `STYLE_PREFIX` 命名空间(与 `SharedHelpersInput.dataAttr` 覆盖口径同构,不一致由调用方自负)。
@@ -95,6 +95,9 @@ const MUTED_COLOR = 'var(--fg3,#86868b)';
95
95
  const DOT_FACE_COLOR = 'var(--card,#ffffff)';
96
96
  const DOT_DEFAULT_PX = 7;
97
97
  const SCATTER_DOT_DEFAULT_PX = 9;
98
+ /** 折线数据点抽稀上限(#424):`showDots` **未显式给**且点数超过它时不逐点画圆——
99
+ * 逐点画必重叠成一团(90 点 / 580 单位宽 → 点距 6.1,圆径 7)。显式 `showDots` 一律照办。 */
100
+ const DOT_STRIDE_MAX = 30;
98
101
  /** scatter 缺省 Y 轴刻度条数(旧 `charts.js:854` `yTicks:4`;冻结 `ScatterChartOptions` 无该字段,
99
102
  * 故按**缺省行为**渲染 4 条、不新增可关闭开关——登记为契约缺口,归后续票,R2-G1 裁定)。 */
100
103
  const SCATTER_Y_TICKS = 4;
@@ -102,6 +105,7 @@ const EMPTY_ICON = '📊';
102
105
  const EMPTY_HINT = '有记录后自动生成图表';
103
106
  const DONUT_ZERO_HINT = '合计为零, 无环形数据';
104
107
  const OWN_SCALE_NOTE = '各指标独立刻度';
108
+ /** 水平网格线**无 Y 轴标注时**的条数(#512:有标注时改按标注条数画,见 `gridSvg`)。 */
105
109
  const GRID_LINES = 3;
106
110
  /** 稳定 32 位散列(FNV-1a)——渐变 `<linearGradient id>` 由**渐变参数**派生,
107
111
  * 同一输入恒得同一 id、不同参数得不同 id,且**不跨调用共享可变状态**(R1-A1/FX-78-A1b-11)。 */
@@ -173,7 +177,7 @@ function normalizeItems(raw, kind, allowNull, mode = 'value') {
173
177
  return out;
174
178
  });
175
179
  }
176
- /** `pct` 非数 → `pct-invalid`;超界收敛 0~100(旧 §6.5 逐字行为)。 */
180
+ /** `pct` 非数 → `pct-invalid`;超界统一为 0~100(旧 §6.5 逐字行为)。 */
177
181
  function normalizePct(raw, kind) {
178
182
  if (!isNum(raw))
179
183
  badPct('charts.' + kind + ': pct 无效: ' + String(raw));
@@ -194,7 +198,7 @@ function resolveCommon(raw, def) {
194
198
  : undefined,
195
199
  format: o !== undefined && typeof o.format === 'function' ? o.format : undefined,
196
200
  labels: labels === 'all' || labels === 'none' || labels === 'select' || labels === 'edge' ? labels : def.labels,
197
- showValues: showValues === true || showValues === false || showValues === 'edge' ? showValues : def.showValues,
201
+ showValues: showValues === true || showValues === false || showValues === 'edge' || showValues === 'last' ? showValues : def.showValues,
198
202
  labelRotate: numOr(o === undefined ? undefined : o.labelRotate, 0),
199
203
  yMin: o !== undefined && isNum(o.yMin) ? o.yMin : undefined,
200
204
  yMax: o !== undefined && isNum(o.yMax) ? o.yMax : undefined,
@@ -229,13 +233,22 @@ function actionAttrs(common, index) {
229
233
  function tipAttrs(common, makeText) {
230
234
  return common.tooltip ? ' data-tip="' + esc(makeText()) + '"' : '';
231
235
  }
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
+ /** 八个 kind **一律** `preserveAspectRatio="xMidYMid meet"`(等比,不拉伸文字)。
237
+ *
238
+ * #507(2026-09-15 公共层视觉机制返工)撤掉了折线/组合/散点三族的 `none` 满宽拉伸:
239
+ * 这三族当年取 `none` 的**唯一理由是**移动端 CSS 把 svg 盒高钉成 `lineHeightMobilePx`(150px),
240
+ * 非等比压低后 `meet` 会等比缩到 228.6px 宽、左右各留 ~73px 空白(R2-N9)。那条钉高在
241
+ * #424 返工时就撤了(改成 `height:auto` 按 viewBox 长宽比派生高度,见 `chartsCss` 的 ≤720px 段),
242
+ * **盒宽高比自此恒等于 viewBox 长宽比** ⇒ `none` 与 `meet` 逐像素同解,`none` 只剩两个隐患:
243
+ * ① 将来任一处把盒高钉回固定 px,图内文字立刻被横向抻宽(1000px 宽容器下 1.6×,字脸变扁);
244
+ * ② 读代码的人会以为本族真的在拉伸。
245
+ * **本条只改属性,不改任何坐标**:坐标唯一性走 `CHART_COORD_RULE = 'viewBox-only'`(映射函数
246
+ * 按 `common.width/height` 算,`meet` 与 `none` 下同一份坐标同解)。描边的
247
+ * `vector-effect="non-scaling-stroke"` 照旧保留(等比缩放下同样有用:描边不随缩放变粗)。 */
248
+ function svgOpen(common, extra) {
236
249
  return '<svg class="' + STYLE_PREFIX + 'charts-svg' + (extra === undefined ? '' : ' ' + extra) + '"'
237
250
  + ' viewBox="0 0 ' + n1(common.width) + ' ' + n1(common.height) + '"'
238
- + ' preserveAspectRatio="' + (aspect === 'none' ? 'none' : 'xMidYMid meet') + '" role="img" focusable="false">';
251
+ + ' preserveAspectRatio="xMidYMid meet" role="img" focusable="false">';
239
252
  }
240
253
  /** 空态联动(`CHART_EMPTY_RULE = 'emptyState'`):`empty:true`/`points:0`,不抛错。 */
241
254
  function emptyOutput(kind, raw, hint) {
@@ -257,17 +270,102 @@ function makeFrame(common, insets) {
257
270
  const y1 = Math.max(y0 + 1, common.height - insets.bottom);
258
271
  return { x0, x1, y0, y1, w: x1 - x0, h: y1 - y0 };
259
272
  }
260
- /** 留白进 viewBox(容器零 padding):左留白含刻度文字宽度。 */
273
+ /** 留白进 viewBox(容器零 padding):左留白含刻度文字宽度。
274
+ *
275
+ * `minLeft`/`minBottom`(#424 返工):折线的留白必须能容下**移动端**那一档字号——媒体查询把
276
+ * 折线文字放大到 17–18 用户单位(见 `chartsCss` 的 ≤720px 段与 `LINE_TEXT_MOBILE`),留白却是
277
+ * 双端共用的用户单位 → 只按桌面 9.5–10.5 估会让 390px 下的「70.5kg」顶出 viewBox 左沿。 */
261
278
  function insetsFor(common, opts) {
262
279
  const padX = common.compact ? 6 : 14;
263
280
  const padY = common.compact ? 4 : 8;
264
281
  return {
265
- left: padX + opts.tickWidth,
266
- right: padX,
267
- top: padY + (common.showValues === false ? 0 : opts.valueHeight),
268
- bottom: padY + opts.labelHeight,
282
+ left: Math.max(padX + opts.tickWidth, opts.minLeft ?? 0),
283
+ /* t512 WaveC:右留白缺省只是 `padX`,横轴标签以 `text-anchor:middle` 居中落在绘图区左右沿上,
284
+ * 首尾两条各有半个字宽探出绘图区 —— 左侧被 `minLeft`(按移动端字号估的刻度宽)顺带护住,
285
+ * 右侧无此保护,390px 下 9 条标签的末条顶出 viewBox 右沿约 6px。调用方按需传 `minRight`。 */
286
+ right: Math.max(padX, opts.minRight ?? 0),
287
+ top: Math.max(padY + (common.showValues === false ? 0 : opts.valueHeight), opts.minTop ?? 0),
288
+ bottom: Math.max(padY + opts.labelHeight, opts.minBottom ?? 0),
269
289
  };
270
290
  }
291
+ /** #424 返工:移动端(≤720px)折线的字号(**用户单位**)。
292
+ * viewBox 580×260 在 390px 手机上按宽度缩到 scale≈0.57(`width:100%` + 按 viewBox 比例算高,
293
+ * 见 `chartsCss` 的 ≤720px 段)——桌面档 9.5–10.5 单位实渲只剩 5.4px,肉眼不可读。
294
+ * 这里按 1/0.57≈1.75 倍提字号,实渲回到 ≈11.3px(390px 档)。
295
+ *
296
+ * t512 收尾(复审 P1):这一档原取 20 单位(512 档盒宽 450 → 实渲 15.52px)。复审表里那句「512 档刻度
297
+ * **实渲** 20px = 正文 1.6 倍」是**读错了量纲** —— 同一列里 820 档写 12.4、1000/1440 档写 10.8,逐值
298
+ * 正是本文件的**用户单位**常量;实渲像素 = 用户单位 × 盒宽 ÷ 580,20 单位在 512 档实渲 15.52px,
299
+ * 约等于正文 12–13px 的 1.2 倍。复审要的实渲 14–15px 里只有 15.0px 这一点能落:往下压到 15 单位
300
+ * 就只剩 11.64px,比正文还小,并破 #512 的「四档实渲 ≥15px」断言(`test/charts.test.mjs` 的 H.t512)。
301
+ * 故取 15 ÷ (450/580) = 19.33 → **19.4 单位**(512 档实渲 15.05px,留 0.4% 浮点余量,贴住那条下沿)。
302
+ * 要真压到 14px,得先改 #512 的 15px 下沿(契约变更,另开票)。
303
+ * 同一份数字既进 `chartsCss` 的媒体查询,也用来估留白(留白双端共用 → 取更大的这一档)。 */
304
+ const LINE_TEXT_MOBILE = { tick: 19.4, xlabel: 19.4, value: 19.4, last: 19.4, mark: 19.4 };
305
+ /** t512:折线族字号的**档位表**(用户单位)。
306
+ *
307
+ * svg 文本的 `font-size` 写在**用户单位**上,会被 viewBox 等比缩放(`preserveAspectRatio="xMidYMid meet"`
308
+ * + `width:100%;height:auto` → 两轴同倍),实渲像素 = 用户单位 × 缩放比,而缩放比 = svg 盒宽 ÷ 580
309
+ * (`LINE_DEFAULT_WIDTH`)——盒宽随视口变,所以**同一个用户单位在不同视口实渲出不同像素**。
310
+ * 只留 ≤720px 移动档(当时 20 单位,t512 收尾收到 19.4)那一档、721px 起落回桌面档 9.5/10 单位,就会在这条断点上**非单调地跳**:
311
+ * 720px 档盒宽 652 → 实渲 22.5px,721px 档盒宽 651 只剩 10.7px,再往宽走才慢慢回到 15px。
312
+ *
313
+ * 逐档补偿的算式:**用户单位 = 目标像素 ÷ 该档实测缩放比**。
314
+ * 缩放比取自 `docs/base/base-render/` 下 t507 证据的 CDP 四档实测(盒宽 ÷ 580):
315
+ * 512px → 盒 450×201.72 → 0.7759;820px → 750×336.20 → 1.2931;1000px/1440px → 930×416.89 → 1.6034
316
+ * (930 是卡片宽度上限,故 1000px 与 1440px 同档、实渲同值)。
317
+ *
318
+ * 三档结果(`chartsCss` 的 ≤720px/721–875px/≥876px 三段逐字用这三个数):
319
+ * 19.4 单位 → 15.05px(512px,t512 收尾前是 20 单位 → 15.5px);12.4 单位 → 16.0px(820px);
320
+ * 10.8 单位 → 17.3px(1000px/1440px)—— 四档**单调不降且 ≥15px**,不再随盒子宽度来回跳。
321
+ *
322
+ * **不变式(别越)**:任何档位的用户单位都不得超过 `LINE_TEXT_MOBILE.tick`(19.4)。
323
+ * `insetsFor` 的左留白是按**移动档那一档(19.4 单位)**估的(`charts.ts:843`,留白是双端共用的用户单位),
324
+ * 抬高它会让「70.5kg」这类刻度文字在**每个**视口都顶出 viewBox 左沿。故本表只抬中间档与宽档。
325
+ *
326
+ * **已知余量(如实记账)**:媒体查询只能按**视口**分档,而缩放比按**盒宽**走,同一档内仍是变化的
327
+ * —— 721–875px 档的下沿(721px)实渲 14.0px、上沿(875px)17.2px;这是视口分档的固有锯齿,
328
+ * 比改前同一段的 10.7–13.9px 已经抬高,要再抹平得按容器宽分档(另一票的事)。 */
329
+ const LINE_TEXT_WIDE_UNITS = 12.4;
330
+ const LINE_TEXT_FULL_UNITS = 10.8;
331
+ /** ≥876px 档的媒体查询下沿:由 `LINE_TEXT_FULL_UNITS` 的 ≥15px 要求**反解**得到——
332
+ * 缩放比须 ≤ 15 ÷ 10.8 = 1.3889 → 盒宽 ≤ 1.3889 × 580 = 805.6 → 视口 ≤ 875.6,取下沿 876。 */
333
+ const LINE_TEXT_WIDE_MAX_PX = 875;
334
+ /** 折线 X 标签行相对绘图区底边(`frame.y1`)的间距(用户单位)。最低那条刻度文字已抬到轴线上方
335
+ * (见 `ticksSvg` 的 `labelDy`),其文字盒下沿到 y1 附近;X 标签(19.4 单位)文字盒上沿顶在
336
+ * y1+gap−21 附近 —— gap ≥ 33 才不相撞(本条按 20 单位算出的下限,t512 收尾改小后仍富余;
337
+ * 实测 gap 34 时 390px 下余 ≈4.5 用户单位)。 */
338
+ const LINE_LABEL_GAP = 34;
339
+ /** 折线绘图区底留白下限:容下 `LINE_LABEL_GAP` + X 标签降部(19.4×0.25 = 4.9)。 */
340
+ const LINE_BOTTOM_MIN = 40;
341
+ /** 末值标签相对末点的抬升(用户单位):文字盒高随字号走(移动端 19.4 单位那一档),抬 6 单位时
342
+ * 390px 下标签盒底与数据线只余 0.4px、15 个折线顶点落在盒内 —— 按 0.7×字号 抬开(0.7×19.4 = 13.6,
343
+ * 取值 14 仍在 0.7× 之上,改字号后一字未动)。 */
344
+ const LINE_LAST_LABEL_LIFT = 14;
345
+ /** t512(最后一公里)· 折线族顶上那两条硬账。
346
+ *
347
+ * ① **顶端刻度标注得在框里**:刻度文字的落点是 `y = ty + labelDy`(基线),实渲文字盒高约 1.33em、
348
+ * 基线以上约占 1.04em —— 顶端那条刻度(`i = count−1`,`ty = frame.y0`)的盒子因此探出 viewBox
349
+ * 上沿被裁(复审实测 1000/1440 档 `relTop = −0.36px`、512 档 −7.47px)。留白旧口径只认
350
+ * `padY + valueHeight`(`showValues:false` 时仅 8 单位),而字号在 ≤720px 档是 19.4 单位(原 20)
351
+ * ⇒ 需要的上沿就是那 1.04em。留白是双端共用的**用户单位**,故按**最大那一档字号**
352
+ * (`LINE_TEXT_MOBILE.tick = 19.4`)算:ceil(1.04 × 19.4) = ceil(20.18) = 21 单位
353
+ * (20 单位时同为 21),四档一律够。
354
+ * ② **峰值与顶端刻度线的余量**:派生域旧口径两端各外扩 6%(`domainOf`)⇒ 峰值只离顶端刻度线
355
+ * 5.36% 的绘图区高,顶端那条线读成「标题下划线」(复审 §三-2:图高 416.89px、顶线在 12.83px、
356
+ * 512 档峰值墨顶离顶线只剩 6.13px)。`LINE_PEAK_HEADROOM` 把**上端**补到绘图区高的 10%
357
+ * (复审给的判据是 8–12%),下端 6% 不动。显式 `yMax` 是调用方的语义(「画到这儿」),不补。 */
358
+ const LINE_TICK_TOP_MIN = 21;
359
+ const LINE_PEAK_HEADROOM = 0.1;
360
+ /** 文字宽度估值(用户单位):ASCII ≈0.62em、CJK 全角 ≈1em。刻度留白按它算——留白必须容下
361
+ * **移动端那一档字号**,否则 390px 下「70.5kg」会顶出 viewBox 左沿(`overflow:visible` 也救不了,
362
+ * 卡片会裁)。 */
363
+ function textWidthUnits(text, size) {
364
+ let em = 0;
365
+ for (const ch of text)
366
+ em += (ch.codePointAt(0) ?? 0) > 0x2e7f ? 1 : 0.62;
367
+ return em * size;
368
+ }
271
369
  function xAt(frame, index, count) {
272
370
  return count <= 1 ? frame.x0 + frame.w / 2 : frame.x0 + (frame.w * index) / (count - 1);
273
371
  }
@@ -313,17 +411,41 @@ function domainOf(values, yMin, yMax, includeZero, padRatio = 0.06) {
313
411
  hi = lo + 1;
314
412
  return [lo, hi];
315
413
  }
316
- /** `yTicks` 收敛 2-6(旧 `charts.js:498`);`false`/非数 → 0 条。 */
414
+ /** `yTicks` 统一为 2-6(旧 `charts.js:498`);`false`/非数 → 0 条。 */
317
415
  function tickCount(value) {
318
416
  if (!isNum(value))
319
417
  return 0;
320
418
  return Math.max(2, Math.min(6, Math.round(value)));
321
419
  }
420
+ /** t512:折线族的**共享派生域** —— `domainOf` 之上再保一条峰值余量(见 `LINE_PEAK_HEADROOM`)。
421
+ *
422
+ * 只动**派生**路径:显式 `yMin`/`yMax` 是调用方的语义(域的边界=「画到这儿」),原样返回。
423
+ * 上端的求法:解 `(hi′ − max) / (hi′ − lo) = r` → `hi′ = (max − r·lo) / (1 − r)`;取下端不动
424
+ * (`lo` 仍是 6% 外扩后的值),故上端比下端厚,峰值不会再压在顶端那条刻度线上。
425
+ * 单值/全等数据(`domainOf` 内部已把 `hi` 抬到 `lo + 1`)时 `want` 不会超过原 `hi`,`Math.max` 兜住。 */
426
+ function lineDomainOf(values, yMin, yMax) {
427
+ const range = domainOf(values, yMin, yMax, false);
428
+ if (yMax !== undefined)
429
+ return range;
430
+ let max = -Infinity;
431
+ for (const v of values)
432
+ if (v > max)
433
+ max = v;
434
+ if (max === -Infinity)
435
+ return range;
436
+ const want = (max - LINE_PEAK_HEADROOM * range[0]) / (1 - LINE_PEAK_HEADROOM);
437
+ return [range[0], Math.max(range[1], want)];
438
+ }
322
439
  /* ── 共享 SVG 片段 ────────────────────────────────────────────────────── */
323
- function gridSvg(frame) {
440
+ function gridSvg(frame, count = 0) {
441
+ /** 对齐口径(#512):线条数取**实际标注条数**,位置与 `ticksSvg` 同一条均匀标度公式
442
+ * (`y1 - h*i/(n-1)` ⇔ `i` 刻度值映射到 `yAt`),故每条线都压着自己那条刻度。
443
+ * `count < 2`(`yTicks:false`/非数 → `tickCount` 给 0)时回退旧口径 `GRID_LINES` 三条:
444
+ * bar/combo 与未开 `yTicks` 的 line 本就无标注,不得因此变成空白图。 */
445
+ const lines = count >= 2 ? count : GRID_LINES;
324
446
  let out = '';
325
- for (let g = 0; g < GRID_LINES; g += 1) {
326
- const gy = frame.y1 - (frame.h * g) / (GRID_LINES - 1);
447
+ for (let g = 0; g < lines; g += 1) {
448
+ const gy = frame.y1 - (frame.h * g) / (lines - 1);
327
449
  out += '<line class="' + STYLE_PREFIX + 'charts-grid" x1="' + n1(frame.x0) + '" y1="' + n1(gy)
328
450
  + '" x2="' + n1(frame.x1) + '" y2="' + n1(gy) + '" stroke="' + GRID_COLOR
329
451
  + '" stroke-width="1" vector-effect="non-scaling-stroke"/>';
@@ -337,23 +459,44 @@ function ticksSvg(frame, lo, hi, count, format) {
337
459
  for (let i = 0; i < count; i += 1) {
338
460
  const tv = lo + ((hi - lo) * i) / (count - 1);
339
461
  const ty = yAt(frame, tv, lo, hi);
462
+ /* #424 返工:最低那条刻度与绘图区底边同高(`lo` 就是下界),它的文字盒会探进 X 标签那一行。
463
+ * 移动端字号提到 20 单位(t512 收尾收到 19.4)后两者只余 0.3px(实测)——最低那条改到轴线上方 1 单位起基线,
464
+ * 其余刻度保持旧口径 `ty + 3`(旧 `charts.js` 字面值不动)。 */
465
+ const labelDy = i === 0 ? -1 : 3;
340
466
  out += '<line class="' + STYLE_PREFIX + 'charts-ytick" x1="' + n1(frame.x0 - 5) + '" y1="' + n1(ty)
341
467
  + '" x2="' + n1(frame.x0) + '" y2="' + n1(ty) + '" stroke="' + GRID_COLOR
342
468
  + '" stroke-width="1" vector-effect="non-scaling-stroke"/>'
343
- + '<text class="' + STYLE_PREFIX + 'charts-tick" x="' + n1(frame.x0 - 7) + '" y="' + n1(ty + 3)
469
+ + '<text class="' + STYLE_PREFIX + 'charts-tick" x="' + n1(frame.x0 - 7) + '" y="' + n1(ty + labelDy)
344
470
  + '" text-anchor="end" fill="' + MUTED_COLOR + '">' + esc(fmtValue(round2(tv), format)) + '</text>';
345
471
  }
346
472
  return out;
347
473
  }
348
474
  /** X 轴标签集合:`'all'` 全量/`'edge'` 首尾/`'select'` 首+峰值+尾(line 口径)
349
475
  * /`'none'` 无。`select='edge'` 时 `'select'` 退化为首尾——**bar 口径**:
350
- * 旧 bar 的 `'select'` 只显示首尾(`charts.js:385-388`),与 line 的「首+峰值+尾」不同(R2-N7)。 */
351
- function labelIndexes(mode, items, select = 'peak') {
476
+ * 旧 bar 的 `'select'` 只显示首尾(`charts.js:385-388`),与 line 的「首+峰值+尾」不同(R2-N7)。
477
+ *
478
+ * #424 补一条下限(只对 line 的 `'select'`):**峰值落在端点时首+峰+尾去重后只剩 2 个标签**
479
+ * (单调下降的体重曲线必然如此,峰值就是首日),横轴中段又变成没有时间参照。故去重后不足 3 个、
480
+ * 且点数 ≥3 时,补一个**离首点最远的中间点**——仍满足「首+峰+尾」,顺带给出中段参照。
481
+ *
482
+ * #567 追加 `every`(只对 `'all'`):每 k 点取一枚(含末点),给「点全画、标签抽稀」用;
483
+ * 非 `'all'` 或非法 k(非 ≥2 有限数)时忽略(默认行为不变)。 */
484
+ function labelIndexes(mode, items, select = 'peak', every) {
352
485
  const n = items.length;
353
486
  if (mode === 'none' || n === 0)
354
487
  return [];
355
- if (mode === 'all')
488
+ if (mode === 'all') {
489
+ if (every !== undefined && Number.isFinite(every) && Math.floor(every) >= 2) {
490
+ const k = Math.floor(every);
491
+ const picked = [];
492
+ for (let i = 0; i < n; i += k)
493
+ picked.push(i);
494
+ if (picked[picked.length - 1] !== n - 1)
495
+ picked.push(n - 1);
496
+ return picked;
497
+ }
356
498
  return items.map((_, i) => i);
499
+ }
357
500
  if (mode === 'edge' || (mode === 'select' && select === 'edge'))
358
501
  return n === 1 ? [0] : [0, n - 1];
359
502
  let peak = 0;
@@ -364,23 +507,31 @@ function labelIndexes(mode, items, select = 'peak') {
364
507
  peak = i;
365
508
  }
366
509
  });
367
- return Array.from(new Set([0, peak, n - 1])).sort((a, b) => a - b);
510
+ const picked = Array.from(new Set([0, peak, n - 1])).sort((a, b) => a - b);
511
+ if (picked.length < 3 && n >= 3) {
512
+ const mid = Math.round((n - 1) / 2);
513
+ const extra = [mid, n - 1 - mid, 1, n - 2].find((i) => i > 0 && i < n - 1 && !picked.includes(i));
514
+ if (extra !== undefined)
515
+ picked.splice(1, 0, extra);
516
+ }
517
+ return picked;
368
518
  }
369
519
  /* t-chartfix:X 标签的 x 标度缺省与折线点同一 `xAt` 点标度;柱族(bar/combo)
370
520
  * 的柱列中心走 band 标度 `x0 + slot*(i+0.5)`——点标度会把首尾标签钉在绘图区
371
521
  * 左右边缘(3 柱时偏差 48.7 单位),与柱错位。调用方按自家几何传入。 */
372
- function xLabelsSvg(common, frame, items, select = 'peak', xOf = (i, n) => xAt(frame, i, n)) {
522
+ function xLabelsSvg(common, frame, items, select = 'peak', xOf = (i, n) => xAt(frame, i, n), gap, every) {
373
523
  if (common.labels === 'none')
374
524
  return '';
375
- const baseY = frame.y1 + (common.compact ? 9 : 12);
376
- return labelIndexes(common.labels, items, select).map((i) => {
525
+ const baseY = frame.y1 + (gap ?? (common.compact ? 9 : 12));
526
+ return labelIndexes(common.labels, items, select, every).map((i) => {
377
527
  const x = xOf(i, items.length);
378
528
  const rotate = common.labelRotate === 0 ? '' : ' transform="rotate(' + common.labelRotate + ' ' + n1(x) + ' ' + n1(baseY) + ')"';
379
529
  return '<text class="' + STYLE_PREFIX + 'charts-xlabel" x="' + n1(x) + '" y="' + n1(baseY)
380
530
  + '" text-anchor="middle"' + rotate + ' fill="' + MUTED_COLOR + '">' + esc(items[i].label) + '</text>';
381
531
  }).join('');
382
532
  }
383
- /** `showValues` 标签集合:`true` 全量(相邻中心距 < 26 单位跳过)/`'edge'` 首尾有效点/`false` 无。 */
533
+ /** `showValues` 标签集合:`true` 全量(相邻中心距 < 26 单位跳过)/`'edge'` 首尾有效点/
534
+ * `'last'` 只标最后一个有效点(#567)/`false` 无。 */
384
535
  function valueIndexes(mode, frame, items) {
385
536
  if (mode === false)
386
537
  return [];
@@ -391,6 +542,8 @@ function valueIndexes(mode, frame, items) {
391
542
  });
392
543
  if (valid.length === 0)
393
544
  return [];
545
+ if (mode === 'last')
546
+ return [valid[valid.length - 1]];
394
547
  if (mode === 'edge')
395
548
  return valid.length === 1 ? [valid[0]] : [valid[0], valid[valid.length - 1]];
396
549
  const out = [];
@@ -426,6 +579,7 @@ function resolveLineSeries(raw, mainItems, line) {
426
579
  smooth: line.smooth,
427
580
  area: line.area,
428
581
  ownScale: false,
582
+ avg: false,
429
583
  }];
430
584
  }
431
585
  return list.map((entry, i) => {
@@ -441,9 +595,25 @@ function resolveLineSeries(raw, mainItems, line) {
441
595
  smooth: series.smooth === undefined ? line.smooth : series.smooth === true,
442
596
  area: series.area === true,
443
597
  ownScale: series.ownScale === true,
598
+ avg: false,
444
599
  };
445
600
  });
446
601
  }
602
+ /** 跨空档桥接段(#458):相邻实测点之间隔着至少一个空档时,把两端点直连为一段
603
+ * (`M端点L端点`,只用实测点坐标,空档点不参与);首末空档无对端可接,不出段。 */
604
+ function bridgePath(pts) {
605
+ let d = '';
606
+ let prev = -1;
607
+ for (let i = 0; i < pts.length; i += 1) {
608
+ if (pts[i][2])
609
+ continue;
610
+ if (prev >= 0 && i - prev > 1) {
611
+ d += 'M' + n1(pts[prev][0]) + ' ' + n1(pts[prev][1]) + 'L' + n1(pts[i][0]) + ' ' + n1(pts[i][1]);
612
+ }
613
+ prev = i;
614
+ }
615
+ return d;
616
+ }
447
617
  function polyPath(pts, connect) {
448
618
  if (connect) {
449
619
  const valid = pts.filter((p) => !p[2]);
@@ -582,9 +752,27 @@ function resolveMarkPoint(raw) {
582
752
  return raw;
583
753
  return undefined;
584
754
  }
755
+ /** 折线默认 viewBox:320×210 → 580×260(#424)。
756
+ *
757
+ * (#507 起本族不再走 `preserveAspectRatio="none"`,见 `svgOpen`;下面的量级关系一字不变——
758
+ * 真正决定实渲字号的是「盒宽 ÷ viewBox 宽」,与 `none`/`meet` 无关。)SVG 的**盒尺寸**仍按 viewBox 长宽比算
759
+ * (`width:100%` + `height:auto`),所以 320×210 塞进 930px 卡片时会被拉到 2.91 倍
760
+ * ——10px 图内文字渲染 29px、点径 20px(t-chartfix #160 的实测)。#160 用
761
+ * `max-width:480px` 压回 1.5 倍,代价是图只占卡片一半宽(930 里居中的 480,左右各空 225)。
762
+ *
763
+ * #424 改从源头修:viewBox 放到真实卡片量级(580×260 ≈ 930×417 的 0.62 相似形)。
764
+ * 卡片越宽、图越大、字越大:930px 卡 → scale 1.6 → 图内 9.5–10px 文字渲染 15.2–16px。
765
+ * 于是 `max-width` 上限不再需要(见 `chartsCss`,桌面规则已撤)。 */
766
+ const LINE_DEFAULT_WIDTH = 580;
767
+ const LINE_DEFAULT_HEIGHT = 260;
585
768
  function renderLine(raw) {
586
769
  const input = requireObject(raw, 'charts.line: input');
587
- const common = resolveCommon(input.options, { width: 320, height: 210, labels: 'edge', showValues: false });
770
+ const common = resolveCommon(input.options, {
771
+ width: LINE_DEFAULT_WIDTH,
772
+ height: LINE_DEFAULT_HEIGHT,
773
+ labels: 'edge',
774
+ showValues: false,
775
+ });
588
776
  const opts = isPlainObject(input.options) ? input.options : undefined;
589
777
  const line = {
590
778
  ...common,
@@ -593,12 +781,18 @@ function renderLine(raw) {
593
781
  smooth: opts !== undefined && opts.smooth === true,
594
782
  step: opts !== undefined && opts.step === true,
595
783
  showDots: opts === undefined || opts.showDots !== false,
784
+ showDotsExplicit: opts !== undefined && opts.showDots !== undefined,
596
785
  dotSize: numOr(opts === undefined ? undefined : opts.dotSize, DOT_DEFAULT_PX),
597
786
  dotStyle: opts !== undefined && typeof opts.dotStyle === 'string' && opts.dotStyle !== '' ? opts.dotStyle : undefined,
598
787
  area: opts !== undefined && opts.area === true,
599
788
  areaOpacity: numOr(opts === undefined ? undefined : opts.areaOpacity, 0.12),
600
789
  yTicks: opts === undefined || opts.yTicks === undefined ? false : opts.yTicks,
601
790
  connectNulls: opts !== undefined && opts.connectNulls === true,
791
+ /* #458:显式 gapStyle 优先;未给时由 connectNulls 回落(true→connect,否则→break),
792
+ * 缺省产出与改前逐字节相同。 */
793
+ gapStyle: opts !== undefined && (opts.gapStyle === 'break' || opts.gapStyle === 'connect' || opts.gapStyle === 'dashed')
794
+ ? opts.gapStyle
795
+ : (opts !== undefined && opts.connectNulls === true ? 'connect' : 'break'),
602
796
  legend: opts !== undefined && opts.legend === true,
603
797
  highlightLast: opts !== undefined && opts.highlightLast === true,
604
798
  avgLine: opts !== undefined && isNum(opts.avgLine) ? opts.avgLine : undefined,
@@ -610,10 +804,19 @@ function renderLine(raw) {
610
804
  ? opts.highlightPoints
611
805
  : undefined,
612
806
  series: opts !== undefined && Array.isArray(opts.series) ? opts.series : undefined,
807
+ labelEvery: opts !== undefined && isNum(opts.labelEvery) && Math.floor(opts.labelEvery) >= 2
808
+ ? Math.floor(opts.labelEvery)
809
+ : undefined,
613
810
  };
614
811
  const items = normalizeItems(input.items, 'line', true);
615
812
  if (items.length === 0)
616
813
  return emptyOutput('line', input.options);
814
+ /* 点密度(#424):`showDots` 未显式给且点数超上限 → 隔 k 个画一个(n=90 → k=3 → 30 个)。
815
+ * 末点**只按 stride 命中**时才画:`highlightLast` 会另外补一个实心末点圈(同 x,r 大 1),
816
+ * 两条路都无条件画会叠出「双圈」——所以末点改由 `highlightLast` 单独负责(调用方开它)。 */
817
+ const dotStride = line.showDotsExplicit || line.dotSize <= 0 || items.length <= DOT_STRIDE_MAX
818
+ ? 1
819
+ : Math.ceil(items.length / DOT_STRIDE_MAX);
617
820
  const series = resolveLineSeries(input.options, items, line);
618
821
  const main = series[0];
619
822
  /* band:与主序列 items 等长,否则抛错;hi/lo 值并入共享域 */
@@ -646,12 +849,33 @@ function renderLine(raw) {
646
849
  if (v !== null)
647
850
  sharedValues.push(v);
648
851
  }
649
- const [lo, hi] = domainOf(sharedValues, line.yMin, line.yMax, false);
852
+ const [lo, hi] = lineDomainOf(sharedValues, line.yMin, line.yMax);
650
853
  const tickN = tickCount(line.yTicks);
854
+ /* 刻度留白按**移动端字号**估(`LINE_TEXT_MOBILE.tick`):留白是双端共用的用户单位,桌面档估算
855
+ * 在 390px 下会漏 —— 见 `insetsFor` 的 `minLeft`。上沿同理(`LINE_TICK_TOP_MIN`):顶端那条
856
+ * 标注的基线以上要 1.04em,≤720px 档字号 19.4 单位最大(原 20),按它留才不会在四档里被裁。 */
857
+ let tickTextW = 0;
858
+ for (let i = 0; i < tickN; i += 1) {
859
+ const tv = lo + ((hi - lo) * i) / (tickN - 1);
860
+ tickTextW = Math.max(tickTextW, textWidthUnits(fmtValue(round2(tv), line.format), LINE_TEXT_MOBILE.tick));
861
+ }
862
+ /* t512 WaveC:右留白按**移动端字号**估横轴标签半宽(留白是双端共用的用户单位,取最大那一档
863
+ * `LINE_TEXT_MOBILE.xlabel`;`textWidthUnits` 的 0.62em 本已偏保守,+1 只收 `n1` 舍入)。
864
+ * 横轴标签居中落在绘图区沿上,末条的半个字宽要收进 viewBox,右留白不得小于它。 */
865
+ let xlabelHalfW = 0;
866
+ if (line.labels !== 'none') {
867
+ for (const item of items) {
868
+ xlabelHalfW = Math.max(xlabelHalfW, textWidthUnits(item.label, LINE_TEXT_MOBILE.xlabel) / 2);
869
+ }
870
+ }
651
871
  const frame = makeFrame(line, insetsFor(line, {
652
872
  tickWidth: tickN === 0 ? 0 : (line.compact ? 16 : 22),
653
873
  labelHeight: line.labels === 'none' ? 0 : (line.compact ? 10 : 14),
654
874
  valueHeight: line.compact ? 9 : 12,
875
+ minLeft: tickN === 0 ? 0 : Math.ceil(tickTextW) + 9,
876
+ minTop: tickN === 0 ? 0 : LINE_TICK_TOP_MIN,
877
+ minBottom: line.labels === 'none' ? 0 : LINE_BOTTOM_MIN,
878
+ minRight: line.labels === 'none' ? 0 : Math.ceil(xlabelHalfW) + 1,
655
879
  }));
656
880
  const ownDoms = series.map((s) => {
657
881
  if (!s.ownScale)
@@ -671,9 +895,12 @@ function renderLine(raw) {
671
895
  ]);
672
896
  };
673
897
  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
898
+ /* avgLine:均线序列(窗口统一为 3..items.length)。旧版 `charts.js:414` 是 `if(opt.avgLine&&!opt.series)`
899
+ * ——传了 `series` 就不叠加(R2-N6,调用方自己管序列)。#424 放开这条互斥:`series` 同时用于给**主序列**
900
+ * 起图例名(旧help模板 `chart-multi-v2` 的图例写「每次称重/7 天均线」,均线是引擎现算的、调用方拿不到它的
901
+ * 数据)——两种用法合并,叠加均线仍由 `avgLine` 决定。主序列恒 `series[0]`、均线恒追加在末尾,
902
+ * 「只看第一条」的逻辑(`highlightLast`/`markPoint`)不受影响。 */
903
+ const avgWindow = line.avgLine === undefined
677
904
  ? undefined
678
905
  : Math.max(3, Math.min(items.length, Math.round(line.avgLine || 7)));
679
906
  if (avgWindow !== undefined && items.length >= 2) {
@@ -691,7 +918,18 @@ function renderLine(raw) {
691
918
  }
692
919
  return { label: item.label, value: n === 0 ? null : round2(sum / n) };
693
920
  });
694
- series.push({ name: '均线', items: avgItems, color: MUTED_COLOR, dashed: true, smooth: line.smooth, area: false, ownScale: false });
921
+ series.push({
922
+ /* #424 返工:条目名按**实际窗口**生成(调用点传的是变量窗口,如 `reviewDocs.ts:52` 的
923
+ * `avgLine: win`)——写死「7 天均线」在窗口 ≠7 时是错的。 */
924
+ name: avgWindow + ' 天均线',
925
+ items: avgItems,
926
+ color: MUTED_COLOR,
927
+ dashed: true,
928
+ smooth: line.smooth,
929
+ area: false,
930
+ ownScale: false,
931
+ avg: true,
932
+ });
695
933
  seriesPts.push(avgItems.map((item, i) => [
696
934
  xAt(frame, i, avgItems.length),
697
935
  item.value === null ? 0 : yAt(frame, item.value, lo, hi),
@@ -750,30 +988,49 @@ function renderLine(raw) {
750
988
  if (s.ownScale)
751
989
  anyOwnScale = true;
752
990
  const pts = seriesPts[si];
753
- const isAvg = s.name === '均线' && avgWindow !== undefined && si === series.length - 1;
991
+ /* #424 返工:均线身份**只看显式标记**(注入时置 `avg: true`),不再靠「虚线+非独立刻度+末位」
992
+ * 猜——调用方自己的虚线末位序列从此不再被误判。 */
993
+ const isAvg = s.avg;
754
994
  const color = s.color ?? line.color ?? CHART_PALETTE[si % CHART_PALETTE.length];
995
+ /* #458:连通标志由 gapStyle 统一给(connect 档等价旧 connectNulls:true,其余断开);
996
+ * dashed 档实线走断开,桥接段另出一条同色虚线(均线序列不参与桥接)。 */
997
+ const connect = line.gapStyle === 'connect';
755
998
  if (s.area && !isAvg) {
756
- const d = areaPath(pts, line.connectNulls, frame.y1, s.smooth && !line.step, line.step);
999
+ const d = areaPath(pts, connect, frame.y1, s.smooth && !line.step, line.step);
757
1000
  if (d !== '') {
758
1001
  pathsSvg += '<path class="' + STYLE_PREFIX + 'charts-area" d="' + d + '" fill="' + esc(color)
759
1002
  + '" fill-opacity="' + line.areaOpacity + '" stroke="none"/>';
760
1003
  }
761
1004
  }
762
- const d = line.step ? stepPath(pts, line.connectNulls) : s.smooth ? smoothPath(pts, line.connectNulls) : polyPath(pts, line.connectNulls);
1005
+ const d = line.step ? stepPath(pts, connect) : s.smooth ? smoothPath(pts, connect) : polyPath(pts, connect);
763
1006
  if (d !== '') {
764
1007
  pathsSvg += '<path class="' + STYLE_PREFIX + 'charts-line' + (isAvg ? ' ' + STYLE_PREFIX + 'charts-avg' : '')
765
1008
  + '" d="' + d + '" fill="none" stroke="' + esc(color)
766
1009
  + '" stroke-width="' + line.lineWidth + '" stroke-linejoin="round" stroke-linecap="round"'
767
1010
  + (s.dashed ? ' stroke-dasharray="6 5"' : '') + ' vector-effect="non-scaling-stroke"/>';
768
1011
  }
769
- if (line.legend && !isAvg && s.name !== '')
1012
+ if (line.gapStyle === 'dashed' && !isAvg) {
1013
+ const b = bridgePath(pts);
1014
+ if (b !== '') {
1015
+ pathsSvg += '<path class="' + STYLE_PREFIX + 'charts-line ' + STYLE_PREFIX + 'charts-line-bridge'
1016
+ + '" d="' + b + '" fill="none" stroke="' + esc(color)
1017
+ + '" stroke-width="' + line.lineWidth + '" stroke-linejoin="round" stroke-linecap="round"'
1018
+ + ' stroke-dasharray="5 4" vector-effect="non-scaling-stroke"/>';
1019
+ }
1020
+ }
1021
+ /* 图例条目(#424 起均线条目也算一条):均线是引擎注入的序列(`avg: true`),名字按窗口生成、
1022
+ * 进图例;调用方自己的**虚线序列**(配对页 cross 轴那条)不进图例——那是调用方图例的事。 */
1023
+ if (line.legend && s.name !== '' && (s.avg || !(avgWindow !== undefined && s.dashed))) {
770
1024
  legendEntries.push({ name: s.name, color });
1025
+ }
771
1026
  if (isAvg)
772
1027
  return;
773
1028
  if (line.showDots) {
774
1029
  pts.forEach((p, i) => {
775
1030
  if (p[2])
776
1031
  return;
1032
+ if (dotStride > 1 && (i % dotStride) !== 0)
1033
+ return;
777
1034
  const item = s.items[i];
778
1035
  const anomaly = item.anomaly === true;
779
1036
  const dotClass = STYLE_PREFIX + 'charts-dot' + (anomaly ? ' ' + STYLE_PREFIX + 'charts-dot-anomaly' : '')
@@ -849,10 +1106,17 @@ function renderLine(raw) {
849
1106
  marksSvg += '<circle class="' + STYLE_PREFIX + 'charts-dot-last" data-i="' + last + '" cx="' + n1(p[0])
850
1107
  + '" cy="' + n1(p[1]) + '" r="' + (line.dotSize / 2 + 1) + '" fill="' + esc(color) + '" stroke="' + esc(color)
851
1108
  + '" 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)">'
1109
+ /* 末值文本受 `showValues`/`labels:'select'` 门控(旧 `charts.js:619-621`);高亮圈无条件。
1110
+ * #424:末值常落在绘图区**右边缘**(末点就在 `frame.x1` 上),`middle` 锚会让文字一半探出图外
1111
+ * (实测「70.6kg」右半截贴到卡片边)。距边不足 6% 宽时改 `end`/`start` 锚并收进 4 单位。
1112
+ * #567:`showValues:'last'` 时末值已由上面的数值标签画出(恰一枚),此处只留圈、不再追加文本
1113
+ * (否则同一末值印两遍)。 */
1114
+ if ((line.showValues !== false && line.showValues !== 'last') || line.labels === 'select') {
1115
+ const edgeRatio = (p[0] - frame.x0) / frame.w;
1116
+ const lastAnchor = edgeRatio > 0.94 ? 'end' : edgeRatio < 0.06 ? 'start' : 'middle';
1117
+ const lastX = edgeRatio > 0.94 ? p[0] - 4 : edgeRatio < 0.06 ? p[0] + 4 : p[0];
1118
+ valuesSvg += '<text class="' + STYLE_PREFIX + 'charts-value ' + STYLE_PREFIX + 'charts-value-last" x="' + n1(lastX)
1119
+ + '" y="' + n1(p[1] - LINE_LAST_LABEL_LIFT) + '" text-anchor="' + lastAnchor + '" fill="var(--fg,#1d1d1f)">'
856
1120
  + esc(fmtValue(main.items[last].value, line.format)) + '</text>';
857
1121
  }
858
1122
  }
@@ -868,7 +1132,9 @@ function renderLine(raw) {
868
1132
  + '" x2="' + n1(frame.x1) + '" y2="' + n1(my) + '" stroke="' + esc(color)
869
1133
  + '" stroke-width="1.5" stroke-dasharray="5 4" vector-effect="non-scaling-stroke"/>'
870
1134
  + '<text class="' + STYLE_PREFIX + 'charts-marktext" x="' + n1(frame.x1 - 2) + '" y="' + n1(my - 4)
871
- + '" text-anchor="end" fill="' + esc(color) + '">'
1135
+ /* t-chartfix #160:标签恒用静音灰 `--fg3,#86868b`(老help模板 `公共组件/assets/charts.js:87`
1136
+ * 同款),**线**仍跟序列色/`mark.color`——线色由 3 条测试锁,标签色不锁。 */
1137
+ + '" text-anchor="end" fill="' + MUTED_COLOR + '">'
872
1138
  + esc(mark.label !== undefined && mark.label !== null ? String(mark.label) : String(mark.value)) + '</text>';
873
1139
  }
874
1140
  if (mark.xValue !== undefined && mark.xValue !== null) {
@@ -897,7 +1163,8 @@ function renderLine(raw) {
897
1163
  + '" x2="' + n1(mx) + '" y2="' + n1(frame.y1) + '" stroke="' + esc(color)
898
1164
  + '" stroke-width="1.5" stroke-dasharray="5 4" vector-effect="non-scaling-stroke"/>'
899
1165
  + '<text class="' + STYLE_PREFIX + 'charts-marktext-v" x="' + n1(labelX) + '" y="' + n1(frame.y0 - 4)
900
- + '" text-anchor="' + anchor + '" fill="' + esc(color) + '">'
1166
+ /* t-chartfix #160:同上——垂直线标签也走 `MUTED_COLOR`,线 stroke 不变。 */
1167
+ + '" text-anchor="' + anchor + '" fill="' + MUTED_COLOR + '">'
901
1168
  + esc(mark.label !== undefined && mark.label !== null ? String(mark.label) : items[idx].label) + '</text>';
902
1169
  }
903
1170
  }
@@ -943,15 +1210,11 @@ function renderLine(raw) {
943
1210
  + '</text>';
944
1211
  }
945
1212
  }
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);
1213
+ const points = series.reduce((sum, s) => (s.avg ? sum : sum + s.items.filter((item) => item.value !== null).length), 0);
951
1214
  const html = containerOpen('line', line, STYLE_PREFIX + 'charts-line')
952
1215
  + legendHtml(legendEntries)
953
- + svgOpen(line, undefined, 'none')
954
- + (line.grid ? gridSvg(frame) : '')
1216
+ + svgOpen(line)
1217
+ + (line.grid ? gridSvg(frame, tickN) : '')
955
1218
  + bandSvg
956
1219
  + fillSvg
957
1220
  + ticksSvg(frame, lo, hi, tickN, line.format)
@@ -960,7 +1223,7 @@ function renderLine(raw) {
960
1223
  + marksSvg
961
1224
  + valuesSvg
962
1225
  + markPointSvg
963
- + xLabelsSvg(line, frame, items)
1226
+ + xLabelsSvg(line, frame, items, 'peak', undefined, LINE_LABEL_GAP, line.labelEvery)
964
1227
  + '</svg></div>';
965
1228
  return { kind: 'line', html, empty: points === 0, points };
966
1229
  }
@@ -1016,12 +1279,12 @@ function renderBar(raw) {
1016
1279
  return total > best ? total : best;
1017
1280
  }, 0)
1018
1281
  : 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
- }));
1282
+ /* #567 柱状 Y 轴刻度(默认不开:`yTicks` 缺省 → 0 条,行为与改前逐字同);
1283
+ * 数值标签抽稀步长(`valueThin`,用户单位;非法值=全标)。 */
1284
+ const barTicks = tickCount(opts === undefined ? undefined : opts.yTicks);
1285
+ const valueThin = opts !== undefined && isNum(opts.valueThin) && opts.valueThin > 0
1286
+ ? opts.valueThin
1287
+ : undefined;
1025
1288
  let lo = 0;
1026
1289
  let hi = 1;
1027
1290
  if (stacked && stackMode === 'percent') {
@@ -1049,6 +1312,22 @@ function renderBar(raw) {
1049
1312
  lo = dlo;
1050
1313
  hi = dhi;
1051
1314
  }
1315
+ /* 刻度留白按移动端字号估(与折线同口径 `LINE_TEXT_MOBILE.tick`);不开刻度时三项全 0,
1316
+ * 绘图区几何与改前逐字同(`tickN = 0` 旧口径)。 */
1317
+ let tickTextW = 0;
1318
+ if (barTicks >= 2) {
1319
+ for (let i = 0; i < barTicks; i += 1) {
1320
+ const tv = lo + ((hi - lo) * i) / (barTicks - 1);
1321
+ tickTextW = Math.max(tickTextW, textWidthUnits(fmtValue(round2(tv), common.format), LINE_TEXT_MOBILE.tick));
1322
+ }
1323
+ }
1324
+ const frame = makeFrame(common, insetsFor(common, {
1325
+ tickWidth: barTicks === 0 ? 0 : (common.compact ? 16 : 22),
1326
+ labelHeight: common.labels === 'none' ? 0 : (common.compact ? 10 : 14),
1327
+ valueHeight: common.compact ? 9 : 12,
1328
+ minLeft: barTicks === 0 ? 0 : Math.ceil(tickTextW) + 9,
1329
+ minTop: barTicks === 0 ? 0 : LINE_TICK_TOP_MIN,
1330
+ }));
1052
1331
  const yZero = yAt(frame, 0, lo, hi);
1053
1332
  const slot = frame.w / items.length;
1054
1333
  const barW = Math.min(slot * (common.compact ? 0.82 : 0.62), 34);
@@ -1103,10 +1382,45 @@ function renderBar(raw) {
1103
1382
  });
1104
1383
  barsSvg += group + '</g>';
1105
1384
  });
1106
- /* 数值标签:单柱 = 值;stacked = 柱顶合计;grouped = 每子柱顶部 */
1385
+ /* 数值标签:单柱 = 值;stacked = 柱顶合计;grouped = 每子柱顶部。
1386
+ * #567:`false` 无;`true` 全量(`valueThin` 给正数时按柱中心距抽稀,默认不变);
1387
+ * `'edge'` 只标首尾有效点(改前落进全开那支——59 柱 59 枚压字即此 bug);
1388
+ * `'last'` 只标末值。抽稀只作用于柱组一级(grouped 整组取舍,不拆组内子柱)。 */
1389
+ const labelSet = (() => {
1390
+ if (common.showValues === false)
1391
+ return new Set();
1392
+ if (common.showValues === true && valueThin !== undefined) {
1393
+ const keep = new Set();
1394
+ let lastX = -Infinity;
1395
+ items.forEach((item, i) => {
1396
+ if (item.value === null && !multi)
1397
+ return;
1398
+ const cx = frame.x0 + slot * (i + 0.5);
1399
+ if (cx - lastX < valueThin)
1400
+ return;
1401
+ keep.add(i);
1402
+ lastX = cx;
1403
+ });
1404
+ return keep;
1405
+ }
1406
+ if (common.showValues === true)
1407
+ return new Set(items.map((_, i) => i));
1408
+ /* 多值模式(stacked/grouped)的 `item.value` 为空,`valueIndexes` 按值取会落空——
1409
+ * 按柱组取首末(`'edge'` 首尾组、`'last'` 末组),标签内容仍走各组既有口径(合计/子柱值)。 */
1410
+ if (multi) {
1411
+ if (items.length === 0)
1412
+ return new Set();
1413
+ if (common.showValues === 'last')
1414
+ return new Set([items.length - 1]);
1415
+ return new Set(items.length === 1 ? [0] : [0, items.length - 1]);
1416
+ }
1417
+ return new Set(valueIndexes(common.showValues, frame, items));
1418
+ })();
1107
1419
  let valuesSvg = '';
1108
- if (common.showValues !== false) {
1420
+ if (labelSet.size > 0) {
1109
1421
  items.forEach((item, i) => {
1422
+ if (!labelSet.has(i))
1423
+ return;
1110
1424
  const cx = frame.x0 + slot * (i + 0.5);
1111
1425
  if (!multi) {
1112
1426
  const value = item.value;
@@ -1143,9 +1457,12 @@ function renderBar(raw) {
1143
1457
  const html = containerOpen('bar', common, STYLE_PREFIX + 'charts-bar')
1144
1458
  + legendHtml(legendEntries)
1145
1459
  + svgOpen(common)
1146
- + (common.grid ? gridSvg(frame) : '')
1460
+ /* #567:开 `yTicks` 时网格线与刻度线同条数同位置(折线 #512 口径);不开时 `tickN = 0`
1461
+ * 回退旧三线口径,几何与改前逐字同。 */
1462
+ + (common.grid ? gridSvg(frame, barTicks) : '')
1147
1463
  + barsSvg
1148
1464
  + valuesSvg
1465
+ + ticksSvg(frame, lo, hi, barTicks, common.format)
1149
1466
  /* t-chartfix:柱标签取柱列中心(与 `barsSvg` 同一 band 标度),不得用点标度。 */
1150
1467
  + xLabelsSvg(common, frame, items, 'edge', (i, n) => frame.x0 + (frame.w / n) * (i + 0.5))
1151
1468
  + '</svg></div>';
@@ -1351,7 +1668,7 @@ function renderCombo(raw) {
1351
1668
  const points = bars.length + lines.length;
1352
1669
  const html = containerOpen('combo', common, STYLE_PREFIX + 'charts-combo')
1353
1670
  + legend
1354
- + svgOpen(common, undefined, 'none')
1671
+ + svgOpen(common)
1355
1672
  + (common.grid ? gridSvg(frame) : '')
1356
1673
  + barsSvg
1357
1674
  + lineSvg
@@ -1528,8 +1845,8 @@ function renderScatter(raw) {
1528
1845
  }
1529
1846
  const points = items.length;
1530
1847
  const html = containerOpen('scatter', common, STYLE_PREFIX + 'charts-scatter')
1531
- + svgOpen(common, undefined, 'none')
1532
- + (common.grid ? gridSvg(frame) : '')
1848
+ + svgOpen(common)
1849
+ + (common.grid ? gridSvg(frame, SCATTER_Y_TICKS) : '')
1533
1850
  + ticksSvg(frame, ylo, yhi, SCATTER_Y_TICKS, common.format)
1534
1851
  + regressionSvg
1535
1852
  + dotsSvg
@@ -1562,8 +1879,20 @@ export const charts = new Proxy(CHART_DISPATCH, {
1562
1879
  },
1563
1880
  });
1564
1881
  /* ── 图表 CSS 文本(R12:唯一产出者 = 本文件内部常量/函数;#75 复用同一份) ── */
1882
+ /** t512:折线族六个文本类的一组字号规则(**用户单位**,逐档补偿后的值——算式见 `LINE_TEXT_WIDE_UNITS`)。
1883
+ * 类名与字形族与 ≤720px 段的六条逐字同款,只换数字;抽成一函数免得三档各抄一遍走散。 */
1884
+ function lineFontRules(p, units) {
1885
+ return '.' + p + 'charts-line .' + p + 'charts-tick{font-size:' + units + 'px}'
1886
+ + '.' + p + 'charts-line .' + p + 'charts-xlabel{font-size:' + units + 'px}'
1887
+ + '.' + p + 'charts-line .' + p + 'charts-value{font-size:' + units + 'px}'
1888
+ + '.' + p + 'charts-line .' + p + 'charts-value-last{font-size:' + units + 'px}'
1889
+ + '.' + p + 'charts-line .' + p + 'charts-marktext{font-size:' + units + 'px}'
1890
+ + '.' + p + 'charts-line .' + p + 'charts-marktext-v{font-size:' + units + 'px}';
1891
+ }
1565
1892
  /** 图表样式文本(**唯一一份**):类名走 `prefix` 命名空间;容器零 padding;
1566
- * 断点数值逐值取 `CHART_BREAKPOINTS`(`mobileMaxPx`/`dotSizeMobilePx`/`lineHeightMobilePx`)。
1893
+ * 断点数值逐值取 `CHART_BREAKPOINTS`(`mobileMaxPx`/`dotSizeMobilePx`)。
1894
+ * (`lineHeightMobilePx` 自 #424 返工起不再进 CSS:折线在移动端改按 viewBox 长宽比派生高度,
1895
+ * 见下方 ≤720px 段的注释;该常量仍留在冻结的 `CHART_BREAKPOINTS` 里不动。)
1567
1896
  *
1568
1897
  * **#75 复用点**:`buildStyleSheet()` 的 `charts` 样式区直接引用本函数产出,
1569
1898
  * **不得**在别处重述图表 CSS 文本(#78 结论,违反即 S1)。
@@ -1573,7 +1902,6 @@ export function chartsCss(prefix) {
1573
1902
  const p = prefix;
1574
1903
  const mobile = CHART_BREAKPOINTS.mobileMaxPx;
1575
1904
  const dotMobile = CHART_BREAKPOINTS.dotSizeMobilePx;
1576
- const lineHeightMobile = CHART_BREAKPOINTS.lineHeightMobilePx;
1577
1905
  return [
1578
1906
  '.' + 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
1907
  '.' + p + 'charts *{box-sizing:border-box}',
@@ -1608,6 +1936,10 @@ export function chartsCss(prefix) {
1608
1936
  '.' + p + 'charts-xlabel{font-size:10px}',
1609
1937
  '.' + p + 'charts-value{font-size:10px}',
1610
1938
  '.' + p + 'charts-value-last{font-size:10.5px;font-weight:700}',
1939
+ /* #424 返工:末值标签与末点同高、常与折线/均线交叠(390px 下实测线从标签盒里穿过)。
1940
+ * 给折线族的末值加白色描边(`paint-order:stroke` 先描边后填充),压在线上也读得清;
1941
+ * 不新增变量名——`--bg` 是本仓既有 token,缺省回退白。 */
1942
+ '.' + p + 'charts-line .' + p + 'charts-value-last{paint-order:stroke;stroke:var(--bg,#fff);stroke-width:3px;stroke-linejoin:round}',
1611
1943
  '.' + p + 'charts-marktext{font-size:10px}',
1612
1944
  '.' + p + 'charts-marktext-v{font-size:10px}',
1613
1945
  '.' + p + 'charts-mptext{font-size:10.5px;font-weight:700}',
@@ -1620,12 +1952,39 @@ export function chartsCss(prefix) {
1620
1952
  '.' + p + 'charts-center-value{font-size:13px;font-weight:700}',
1621
1953
  '.' + p + 'charts-gauge-value{font-size:26px;font-weight:800}',
1622
1954
  '.' + p + 'charts-gauge-label{font-size:11px}',
1955
+ /* t-chartfix #160 的桌面等比上限 `max-width:480px`(=320×1.5)在 #424 撤销:
1956
+ * 折线 viewBox 已放大到 580×260(`LINE_DEFAULT_WIDTH/HEIGHT`),930px 卡片下 scale≈1.6
1957
+ * ——图内 9.5–10px 文字渲染 15.2–16px,靠 viewBox 自身就压住了字号,不再需要砍宽度。
1958
+ * 留着它反而把图钉死在卡片中缝(930 里 480,左右各空 225px)。 */
1623
1959
  '@media (max-width:' + mobile + 'px){'
1624
1960
  + '.' + p + 'charts{--' + p + 'charts-dot:' + dotMobile + 'px}'
1625
- + '.' + p + 'charts-line .' + p + 'charts-svg{height:' + lineHeightMobile + 'px}'
1961
+ /* #424 返工:原来这里把折线 svg 盒高钉成 `lineHeightMobilePx`(150px),而折线是
1962
+ * `preserveAspectRatio="none"` 的满宽拉伸族 —— 580×260/580×300/580×180 三种 viewBox
1963
+ * 被压进同一个 150px 高,横向 0.57、纵向 0.50/0.83 → 圆点变椭圆、字被压扁(实测失真比
1964
+ * 1.13/0.68)。改成按 viewBox 长宽比派生高度(`height:auto`,与 `.charts-svg` 基规则同款),
1965
+ * 三种 viewBox 一律等比缩放;字号补偿见 `LINE_TEXT_MOBILE`。 */
1966
+ + '.' + p + 'charts-line .' + p + 'charts-svg{height:auto}'
1626
1967
  + '.' + p + 'charts-bar .' + p + 'charts-xlabel{font-size:9.5px}'
1627
1968
  + '.' + p + 'charts-legend{font-size:11.5px}'
1969
+ /* #424 返工(移动端折线文字不可读):上面那条把折线 svg 盒高钉成 150px,580×260 的 viewBox
1970
+ * 在 390px 手机上被压到 scale≈0.58(`preserveAspectRatio="none"`)——图内 9.5/10 单位实测
1971
+ * 只有 5.4/5.7px。这里只对**折线族**按 1/0.58≈1.75 倍提字号,把实渲拉回 ~10px;字号常量与
1972
+ * 留白估算同源(`LINE_TEXT_MOBILE`),留白已按这一档放大,文字不会再顶出 viewBox。 */
1973
+ + '.' + p + 'charts-line .' + p + 'charts-tick{font-size:' + LINE_TEXT_MOBILE.tick + 'px}'
1974
+ + '.' + p + 'charts-line .' + p + 'charts-xlabel{font-size:' + LINE_TEXT_MOBILE.xlabel + 'px}'
1975
+ + '.' + p + 'charts-line .' + p + 'charts-value{font-size:' + LINE_TEXT_MOBILE.value + 'px}'
1976
+ + '.' + p + 'charts-line .' + p + 'charts-value-last{font-size:' + LINE_TEXT_MOBILE.last + 'px}'
1977
+ + '.' + p + 'charts-line .' + p + 'charts-marktext{font-size:' + LINE_TEXT_MOBILE.mark + 'px}'
1978
+ + '.' + p + 'charts-line .' + p + 'charts-marktext-v{font-size:' + LINE_TEXT_MOBILE.mark + 'px}'
1628
1979
  + '}',
1980
+ /* t512(字号随盒宽乱跳的根因):上面那一段只按 ≤720px 给了一档(原 20 用户单位,t512 收尾收到 19.4),721px 起落回桌面档,
1981
+ * 断点两侧实渲 22.5px → 10.7px。这里补两档,用户单位 = 目标像素 ÷ 该档实测缩放比
1982
+ * (算式与四档实测读数见 `LINE_TEXT_WIDE_UNITS` 的注释)——只给**折线族**六条文本类,
1983
+ * 杆/散点/环/量表各族沿用基规则 9.5/10 单位不动(它们没有被报「字号乱跳」)。 */
1984
+ '@media (min-width:' + (mobile + 1) + 'px) and (max-width:' + LINE_TEXT_WIDE_MAX_PX + 'px){'
1985
+ + lineFontRules(p, LINE_TEXT_WIDE_UNITS) + '}',
1986
+ '@media (min-width:' + (LINE_TEXT_WIDE_MAX_PX + 1) + 'px){'
1987
+ + lineFontRules(p, LINE_TEXT_FULL_UNITS) + '}',
1629
1988
  ].join(LF);
1630
1989
  }
1631
1990
  /* ── 图表 helpers JS(唯一产出者,FX-22;首步幂等自注入 `<style>`) ────── */
@@ -1645,7 +2004,7 @@ function helpersStyleId(input) {
1645
2004
  * 不向 `window.<id>`/`globalThis.<id>` 赋值/不引 `node:`。
1646
2005
  *
1647
2006
  * 首步即幂等自注入 `<style id="{styleId ?? CHARTS_STYLE_ID}">`(R3/R12);图表 CSS 文本
1648
- * **只此一处产出**(`chartsCss`),#75 落地时复用同一份文本,不得重述。 */
2007
+ * **只此一处产出**(`chartsCss`),#75 实施时复用同一份文本,不得重述。 */
1649
2008
  export const buildChartsHelpersJs = (input) => {
1650
2009
  const prefix = helpersPrefix(input);
1651
2010
  const styleId = helpersStyleId(input);