@squadbase/vantage 0.6.2-dev1.b28a4df → 0.6.2-dev1.b40ce36

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 (213) hide show
  1. package/dist/{build-X5NVCQKT.js → build-A23M4GCH.js} +7 -7
  2. package/dist/{build-X5NVCQKT.js.map → build-A23M4GCH.js.map} +1 -1
  3. package/dist/{check-ZBIH422T.js → check-FBHT6W6J.js} +5 -5
  4. package/dist/{check-ZBIH422T.js.map → check-FBHT6W6J.js.map} +1 -1
  5. package/dist/chunk-2IAPIOPS.js +152 -0
  6. package/dist/chunk-2IAPIOPS.js.map +1 -0
  7. package/dist/chunk-57GHR5LN.js +163 -0
  8. package/dist/chunk-57GHR5LN.js.map +1 -0
  9. package/dist/{chunk-7DZGJQ3P.js → chunk-5C2SEDPH.js} +21 -4
  10. package/dist/chunk-5C2SEDPH.js.map +1 -0
  11. package/dist/chunk-6OTTGAPO.js +44 -0
  12. package/dist/chunk-6OTTGAPO.js.map +1 -0
  13. package/dist/{chunk-XPQSTZUU.js → chunk-7S52M2SL.js} +4 -3
  14. package/dist/chunk-7S52M2SL.js.map +1 -0
  15. package/dist/{chunk-V3LF7BE7.js → chunk-ANZLVSEP.js} +4 -5
  16. package/dist/chunk-ANZLVSEP.js.map +1 -0
  17. package/dist/{chunk-XQUSGIDG.js → chunk-CKWPK4RO.js} +10 -3
  18. package/dist/chunk-CKWPK4RO.js.map +1 -0
  19. package/dist/chunk-JXFCSF3K.js +673 -0
  20. package/dist/chunk-JXFCSF3K.js.map +1 -0
  21. package/dist/{chunk-UNCSBSO4.js → chunk-QOXXOFGL.js} +112 -927
  22. package/dist/chunk-QOXXOFGL.js.map +1 -0
  23. package/dist/{chunk-CEXAQQ5W.js → chunk-S5MDTHH7.js} +180 -18
  24. package/dist/chunk-S5MDTHH7.js.map +1 -0
  25. package/dist/{chunk-2ICNHUG6.js → chunk-SDJTTMLK.js} +64 -7
  26. package/dist/chunk-SDJTTMLK.js.map +1 -0
  27. package/dist/{chunk-WAMZWZJB.js → chunk-SHZM3EXB.js} +19 -8
  28. package/dist/chunk-SHZM3EXB.js.map +1 -0
  29. package/dist/{chunk-JKLLFVEU.js → chunk-UWSQUHX6.js} +4 -99
  30. package/dist/chunk-UWSQUHX6.js.map +1 -0
  31. package/dist/cli.js +6 -6
  32. package/dist/client/index.d.ts +2 -0
  33. package/dist/client/index.js +8 -2
  34. package/dist/client/index.js.map +1 -1
  35. package/dist/components/index.d.ts +13 -249
  36. package/dist/components/index.js +5 -2
  37. package/dist/delta-text-CvwBGJ95.d.ts +15 -0
  38. package/dist/{dev-4XEDTELV.js → dev-H4I2JL63.js} +7 -7
  39. package/dist/{dev-4XEDTELV.js.map → dev-H4I2JL63.js.map} +1 -1
  40. package/dist/doc/index.d.ts +215 -2
  41. package/dist/doc/index.js +307 -3
  42. package/dist/doc/index.js.map +1 -1
  43. package/dist/{doctor-5E2VEZPK.js → doctor-B2O3GJDU.js} +5 -5
  44. package/dist/{doctor-5E2VEZPK.js.map → doctor-B2O3GJDU.js.map} +1 -1
  45. package/dist/format-U0cQl069.d.ts +243 -0
  46. package/dist/query/index.js +2 -2
  47. package/dist/{routes-3SGAFDMC.js → routes-PVKNT777.js} +4 -4
  48. package/dist/{routes-3SGAFDMC.js.map → routes-PVKNT777.js.map} +1 -1
  49. package/dist/slide/index.d.ts +272 -1
  50. package/dist/slide/index.js +972 -2
  51. package/dist/slide/index.js.map +1 -1
  52. package/dist/table/index.js +7 -4
  53. package/dist/table/index.js.map +1 -1
  54. package/dist/ui/index.d.ts +4 -1
  55. package/dist/ui/index.js +3 -3
  56. package/dist/ui/index.js.map +1 -1
  57. package/dist/{upgrade-JHXZBGQS.js → upgrade-6DT7ADRP.js} +4 -4
  58. package/dist/{upgrade-JHXZBGQS.js.map → upgrade-6DT7ADRP.js.map} +1 -1
  59. package/dist/vite/index.js +3 -3
  60. package/docs/en/changelog.md +2 -0
  61. package/docs/en/components.md +13 -2
  62. package/docs/en/doc/delta-text.md +40 -0
  63. package/docs/en/doc/doc-action-items.md +49 -0
  64. package/docs/en/doc/doc-callout.md +38 -0
  65. package/docs/en/doc/doc-data-table.md +60 -0
  66. package/docs/en/doc/doc-executive-summary.md +64 -0
  67. package/docs/en/doc/doc-figure.md +56 -0
  68. package/docs/en/doc/doc-key-findings.md +61 -0
  69. package/docs/en/doc/doc-kpi-scorecard.md +80 -0
  70. package/docs/en/doc/doc-notes-and-sources.md +43 -0
  71. package/docs/en/doc/doc-report-header.md +46 -0
  72. package/docs/en/pages-and-metadata.md +15 -0
  73. package/docs/en/parts/area-chart.md +99 -0
  74. package/docs/en/parts/bar-chart.md +91 -0
  75. package/docs/en/parts/bar-list.md +50 -0
  76. package/docs/en/parts/category-bar.md +37 -0
  77. package/docs/en/parts/chart-frame.md +39 -0
  78. package/docs/en/parts/delta.md +4 -3
  79. package/docs/en/parts/donut-chart.md +72 -0
  80. package/docs/en/parts/funnel-steps.md +2 -1
  81. package/docs/en/parts/heatmap.md +53 -0
  82. package/docs/en/parts/kpi-card.md +2 -1
  83. package/docs/en/parts/legend.md +35 -0
  84. package/docs/en/parts/line-chart.md +94 -0
  85. package/docs/en/parts/progress-circle.md +33 -0
  86. package/docs/en/parts/sample-data.md +39 -0
  87. package/docs/en/parts/scatter-chart.md +56 -0
  88. package/docs/en/parts/tracker.md +36 -0
  89. package/docs/en/parts/updated-at.md +30 -0
  90. package/docs/en/parts/waterfall-chart.md +56 -0
  91. package/docs/en/slide/slide-chart-full.md +48 -0
  92. package/docs/en/slide/slide-chart-insight.md +52 -0
  93. package/docs/en/slide/slide-columns.md +46 -0
  94. package/docs/en/slide/slide-comparison.md +68 -0
  95. package/docs/en/slide/slide-cover.md +39 -0
  96. package/docs/en/slide/slide-exec-summary.md +47 -0
  97. package/docs/en/slide/slide-heading.md +38 -0
  98. package/docs/en/slide/slide-kpi-grid.md +55 -0
  99. package/docs/en/slide/slide-kpi-hero.md +58 -0
  100. package/docs/en/slide/slide-next-actions.md +49 -0
  101. package/docs/en/slide/slide-scorecard-table.md +54 -0
  102. package/docs/en/slide/slide-section.md +44 -0
  103. package/docs/en/slide/slide-source-line.md +34 -0
  104. package/docs/en/ui/alert-dialog.md +42 -0
  105. package/docs/en/ui/avatar.md +31 -0
  106. package/docs/en/ui/combobox.md +74 -0
  107. package/docs/en/ui/drawer.md +41 -0
  108. package/docs/en/ui/empty.md +1 -1
  109. package/docs/en/ui/error-state.md +4 -3
  110. package/docs/en/ui/field.md +42 -0
  111. package/docs/en/ui/hover-card.md +21 -0
  112. package/docs/en/ui/kbd.md +17 -0
  113. package/docs/en/ui/loading.md +1 -1
  114. package/docs/en/ui/pagination.md +36 -0
  115. package/docs/en/ui/radio-group.md +21 -0
  116. package/docs/en/ui/toast.md +44 -0
  117. package/docs/en/ui/toggle-group.md +4 -3
  118. package/docs/index.json +857 -77
  119. package/docs/ja/changelog.md +2 -0
  120. package/docs/ja/components.md +12 -1
  121. package/docs/ja/doc/delta-text.md +40 -0
  122. package/docs/ja/doc/doc-action-items.md +49 -0
  123. package/docs/ja/doc/doc-callout.md +38 -0
  124. package/docs/ja/doc/doc-data-table.md +60 -0
  125. package/docs/ja/doc/doc-executive-summary.md +64 -0
  126. package/docs/ja/doc/doc-figure.md +56 -0
  127. package/docs/ja/doc/doc-key-findings.md +61 -0
  128. package/docs/ja/doc/doc-kpi-scorecard.md +80 -0
  129. package/docs/ja/doc/doc-notes-and-sources.md +43 -0
  130. package/docs/ja/doc/doc-report-header.md +46 -0
  131. package/docs/ja/page-types/slide.md +1 -0
  132. package/docs/ja/pages-and-metadata.md +15 -0
  133. package/docs/ja/parts/area-chart.md +49 -25
  134. package/docs/ja/parts/bar-chart.md +50 -24
  135. package/docs/ja/parts/bar-list.md +31 -8
  136. package/docs/ja/parts/chart-frame.md +36 -0
  137. package/docs/ja/parts/delta.md +5 -4
  138. package/docs/ja/parts/donut-chart.md +45 -26
  139. package/docs/ja/parts/funnel-steps.md +27 -8
  140. package/docs/ja/parts/heatmap.md +45 -0
  141. package/docs/ja/parts/kpi-card.md +2 -1
  142. package/docs/ja/parts/legend.md +36 -0
  143. package/docs/ja/parts/line-chart.md +48 -22
  144. package/docs/ja/parts/sample-data.md +61 -0
  145. package/docs/ja/parts/scatter-chart.md +69 -0
  146. package/docs/ja/parts/updated-at.md +27 -0
  147. package/docs/ja/parts/waterfall-chart.md +61 -0
  148. package/docs/ja/slide/slide-chart-full.md +48 -0
  149. package/docs/ja/slide/slide-chart-insight.md +52 -0
  150. package/docs/ja/slide/slide-columns.md +46 -0
  151. package/docs/ja/slide/slide-comparison.md +68 -0
  152. package/docs/ja/slide/slide-cover.md +39 -0
  153. package/docs/ja/slide/slide-exec-summary.md +47 -0
  154. package/docs/ja/slide/slide-heading.md +38 -0
  155. package/docs/ja/slide/slide-kpi-grid.md +55 -0
  156. package/docs/ja/slide/slide-kpi-hero.md +58 -0
  157. package/docs/ja/slide/slide-next-actions.md +49 -0
  158. package/docs/ja/slide/slide-scorecard-table.md +54 -0
  159. package/docs/ja/slide/slide-section.md +44 -0
  160. package/docs/ja/slide/slide-source-line.md +34 -0
  161. package/docs/ja/ui/empty.md +1 -1
  162. package/docs/ja/ui/error-state.md +4 -3
  163. package/docs/ja/ui/loading.md +1 -1
  164. package/docs/ja/ui/toggle-group.md +4 -3
  165. package/package.json +2 -2
  166. package/registry/ui/area-chart.tsx +88 -26
  167. package/registry/ui/attention-list.tsx +303 -0
  168. package/registry/ui/bar-chart.tsx +88 -26
  169. package/registry/ui/bar-list.tsx +87 -24
  170. package/registry/ui/box-plot-chart.tsx +3906 -0
  171. package/registry/ui/category-bar.tsx +54 -19
  172. package/registry/ui/chart-frame.tsx +56 -21
  173. package/registry/ui/dashboard-card.tsx +2 -2
  174. package/registry/ui/delta.tsx +11 -8
  175. package/registry/ui/donut-chart.tsx +89 -26
  176. package/registry/ui/echart.tsx +194 -44
  177. package/registry/ui/funnel-steps.tsx +33 -5
  178. package/registry/ui/heatmap.tsx +55 -21
  179. package/registry/ui/histogram-chart.tsx +3986 -0
  180. package/registry/ui/kpi-card.tsx +41 -14
  181. package/registry/ui/legend.tsx +87 -24
  182. package/registry/ui/line-chart.tsx +88 -26
  183. package/registry/ui/page-shell.tsx +9 -9
  184. package/registry/ui/paired-chart.tsx +3827 -0
  185. package/registry/ui/sample-data.tsx +1 -1
  186. package/registry/ui/scatter-chart.tsx +88 -26
  187. package/registry/ui/section-header.tsx +2 -2
  188. package/registry/ui/slider.tsx +1 -1
  189. package/registry/ui/status-badge.tsx +8 -8
  190. package/registry/ui/tooltip.tsx +6 -2
  191. package/registry/ui/tracker.tsx +9 -3
  192. package/registry/ui/trend-indicator.tsx +2 -2
  193. package/registry/ui/waterfall-chart.tsx +88 -26
  194. package/skills/vantage-design/SKILL.md +27 -25
  195. package/skills/vantage-design/references/extract.md +16 -14
  196. package/skills/vantage-design/references/format.md +6 -5
  197. package/skills/vantage-design/references/presets.md +60 -110
  198. package/skills/vantage-pitfalls/SKILL.md +5 -0
  199. package/skills/vantage-slide/SKILL.md +54 -36
  200. package/templates/AGENTS.md +4 -2
  201. package/templates/agents/doc.md +48 -0
  202. package/templates/agents/page.md +22 -1
  203. package/templates/agents/slide.md +70 -2
  204. package/theme.css +24 -20
  205. package/dist/chunk-2ICNHUG6.js.map +0 -1
  206. package/dist/chunk-7DZGJQ3P.js.map +0 -1
  207. package/dist/chunk-CEXAQQ5W.js.map +0 -1
  208. package/dist/chunk-JKLLFVEU.js.map +0 -1
  209. package/dist/chunk-UNCSBSO4.js.map +0 -1
  210. package/dist/chunk-V3LF7BE7.js.map +0 -1
  211. package/dist/chunk-WAMZWZJB.js.map +0 -1
  212. package/dist/chunk-XPQSTZUU.js.map +0 -1
  213. package/dist/chunk-XQUSGIDG.js.map +0 -1
@@ -43,7 +43,7 @@
43
43
  * line takes its name from it ("昨日 617"), the same way a `label` on any `vs` does.
44
44
  * - **Two or more comparisons sit under the value**, not beside it, so the value
45
45
  * keeps its place and its width whatever the card holds.
46
- * - **Polarity drives everything.** With `polarity="lower-is-better"` the target
46
+ * - **`betterWhen` drives everything.** With `betterWhen="down"` the target
47
47
  * is met at or below it, and the record badge marks the lowest value.
48
48
  * - **Rates change in points.** With a `percent` format the delta and
49
49
  * `{difference}` are in percentage points ("-0.5pt"); `change="relative"` opts
@@ -147,7 +147,9 @@ export interface KpiCardProps extends Omit<ComponentProps<"div">, "children"> {
147
147
  * @example provisional / provisional={{ label: "暫定" }}
148
148
  */
149
149
  provisional?: boolean | { label?: string }
150
- /** Whether a rise is good. Default `"higher-is-better"`. `"neutral"` never colours the change and skips the target and record logic. */
150
+ /** Which direction is good: `"up"` (default) or `"down"` (churn, error rate). Wins over `polarity`. */
151
+ betterWhen?: "up" | "down"
152
+ /** @deprecated Older name of `betterWhen` (`"higher-is-better"` / `"lower-is-better"`). `"neutral"` (never colours the change, skips the target and record logic) is only reachable here. `betterWhen` wins if both are given. */
151
153
  polarity?: Polarity
152
154
  /** `"relative"` ("+12.4%"), `"points"` ("-0.5pt") or `"difference"` ("+¥530万", in `format`). Default: points for a `percent` format, else relative. */
153
155
  change?: ChangeDisplay
@@ -160,7 +162,7 @@ export interface KpiCardProps extends Omit<ComponentProps<"div">, "children"> {
160
162
  /**
161
163
  * Target for the current period. Under the value it adds a 6px bar (actual, the pace
162
164
  * projection, a tick at the target) and one line "目標の 49%・このペースなら 98%"
163
- * (the projection only while `period` is in progress). With `polarity="lower-is-better"`
165
+ * (the projection only while `period` is in progress). With `betterWhen="down"`
164
166
  * or a rate target (points) there is no bar: "目標 … まであと …" in the footer.
165
167
  */
166
168
  target?: number
@@ -235,6 +237,11 @@ const DELTA_PLACEMENT =
235
237
  * (32px; 24px for `sm`), so 218px / 288px here are the 250px / 320px card widths. It also owns the gap above the footer.
236
238
  */
237
239
  const HEADER_CLASS = "@container/card pb-(--card-spacing)"
240
+ /** A value this long (a huge standard-notation currency) may wrap between digit groups, and its delta then moves under it so the number gets the full row. Ordinary values stay on one line beside the delta. */
241
+ const LONG_VALUE_CHARS = 10
242
+ const DELTA_UNDER_PLACEMENT = "col-start-1 row-span-1 row-start-3 justify-self-start"
243
+ // No grey footer band: value, comparison and notes share one surface, 8px apart (the footer only keeps its subgrid row).
244
+ const KPI_FOOTER_CLASS = "flex-col items-start gap-2 rounded-none border-t-0 bg-transparent pt-0 text-sm"
238
245
 
239
246
  const PLACEHOLDER = /\{(\w+)\}/g
240
247
 
@@ -307,6 +314,11 @@ function warnShallowSummary(template: string) {
307
314
  )
308
315
  }
309
316
 
317
+ /** Splits formatted text after each thousands separator so a huge standard-notation value wraps between digit groups instead of clipping. The text itself is unchanged. */
318
+ function digitGroups(text: string): string[] {
319
+ return text.match(/[^,,]*[,,]|[^,,]+$/g) ?? [text]
320
+ }
321
+
310
322
  export function KpiCard({
311
323
  label,
312
324
  value,
@@ -315,7 +327,8 @@ export function KpiCard({
315
327
  comparison: comparisonProp,
316
328
  comparisons,
317
329
  provisional,
318
- polarity,
330
+ betterWhen,
331
+ polarity: polarityProp,
319
332
  change,
320
333
  neutralBand,
321
334
  showPrevious = true,
@@ -342,6 +355,8 @@ export function KpiCard({
342
355
  const isProvisional = provisional !== undefined && provisional !== false
343
356
  const provisionalLabel = typeof provisional === "object" && provisional.label ? provisional.label : "速報"
344
357
  const sample = useSampleMark()
358
+ // `betterWhen` is the documented name and wins over the older `polarity`.
359
+ const polarity: Polarity | undefined = betterWhen ? (betterWhen === "down" ? "lower-is-better" : "higher-is-better") : polarityProp
345
360
  const neutral = polarity === "neutral"
346
361
  const hasPrevious = comparison !== undefined && showPrevious
347
362
  // Two or more badges would squeeze the value from the right; they get their own row under it instead.
@@ -417,7 +432,7 @@ export function KpiCard({
417
432
  ) : null}
418
433
  </CardHeader>
419
434
  {(summary || record || (hasTarget && !targetBar) || sourceParts.length > 0 || hasUpdated || showSparkline) && (
420
- <CardFooter className="flex-col items-start gap-1.5 text-sm">
435
+ <CardFooter className={KPI_FOOTER_CLASS}>
421
436
  {summary && <Skeleton aria-hidden="true" className="h-5 w-4/5" />}
422
437
  {record && <Skeleton aria-hidden="true" className="h-5 w-28 rounded-4xl" />}
423
438
  {hasTarget && !targetBar && <Skeleton aria-hidden="true" className="h-5 w-3/5" />}
@@ -535,22 +550,34 @@ export function KpiCard({
535
550
  />
536
551
  ))
537
552
 
553
+ const valueText = fmt(value)
554
+ const longValue = valueText.length > LONG_VALUE_CHARS
555
+
538
556
  return (
539
557
  <Card data-slot="kpi-card" size={size} className={cardClass} {...props}>
540
558
  <CardHeader className={HEADER_CLASS}>
541
- <CardDescription data-slot="kpi-card-label" title={label} className="col-start-1 min-w-0 truncate text-[13px] font-medium text-muted-foreground">
559
+ <CardDescription data-slot="kpi-card-label" title={label} className="col-start-1 min-w-0 truncate text-[13px] leading-snug font-medium text-muted-foreground">
542
560
  {label}
543
561
  </CardDescription>
544
562
  <CardTitle
545
563
  data-slot="kpi-card-value"
546
- title={fmt(value)}
564
+ title={valueText}
547
565
  className={cn(
548
- "col-start-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 font-semibold tracking-tight tabular-nums",
566
+ "col-start-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 leading-none font-semibold tracking-tight tabular-nums",
549
567
  // Fixed size: the step follows the card width only, never the length of the value.
550
568
  valueSize,
551
569
  )}
552
570
  >
553
- <span className="whitespace-nowrap">{fmt(value)}</span>
571
+ <span data-slot="kpi-card-value-text" className={longValue ? "min-w-0 break-words" : "whitespace-nowrap"}>
572
+ {longValue
573
+ ? digitGroups(valueText).map((group, i) => (
574
+ <span key={i} className="whitespace-nowrap">
575
+ {group}
576
+ <wbr />
577
+ </span>
578
+ ))
579
+ : valueText}
580
+ </span>
554
581
  {isProvisional && (
555
582
  <Badge data-slot="kpi-card-provisional" variant="outline" className="align-middle">
556
583
  {provisionalLabel}
@@ -564,7 +591,7 @@ export function KpiCard({
564
591
  {deltas}
565
592
  </div>
566
593
  ) : (
567
- <CardAction className={DELTA_PLACEMENT}>{deltas}</CardAction>
594
+ <CardAction className={longValue ? DELTA_UNDER_PLACEMENT : DELTA_PLACEMENT}>{deltas}</CardAction>
568
595
  ))}
569
596
  {hasPrevious && comparison && (
570
597
  <div data-slot="kpi-card-previous" className="col-span-full text-sm text-muted-foreground tabular-nums">
@@ -614,9 +641,9 @@ export function KpiCard({
614
641
  )}
615
642
  </CardHeader>
616
643
  {hasFooter && (
617
- <CardFooter className="flex-col items-start gap-1.5 text-sm">
644
+ <CardFooter className={KPI_FOOTER_CLASS}>
618
645
  {summaryText && (
619
- <p data-slot="kpi-card-summary" title={summaryText} className="line-clamp-2 font-medium tabular-nums">
646
+ <p data-slot="kpi-card-summary" title={summaryText} className="line-clamp-2 text-sm leading-relaxed text-muted-foreground tabular-nums">
620
647
  {summaryText}
621
648
  </p>
622
649
  )}
@@ -634,13 +661,13 @@ export function KpiCard({
634
661
  <p
635
662
  data-slot="kpi-card-source"
636
663
  title={sourceParts.join("・")}
637
- className="line-clamp-2 w-full text-xs text-muted-foreground tabular-nums"
664
+ className="line-clamp-2 w-full text-xs leading-normal text-muted-foreground tabular-nums"
638
665
  >
639
666
  {sourceParts.join("・")}
640
667
  </p>
641
668
  )}
642
669
  {hasUpdated && (
643
- <p data-slot="kpi-card-updated" className="text-xs text-muted-foreground">
670
+ <p data-slot="kpi-card-updated" className="text-xs leading-normal text-muted-foreground">
644
671
  {updatedAtText(updatedAt, updatedAtDisplay, locale, now, timeZone)}
645
672
  </p>
646
673
  )}
@@ -17,7 +17,7 @@ import { forwardRef } from "react"
17
17
 
18
18
  import { cn } from "@squadbase/vantage/ui"
19
19
  import type { ChartSeriesColor } from "@squadbase/vantage/components"
20
- import { createContext, useCallback, useEffect, useRef, useState, type ForwardedRef } from "react"
20
+ import { createContext, useCallback, useEffect, useRef, useState, type ForwardedRef, type RefObject } from "react"
21
21
  import type { ChartTone } from "@squadbase/vantage/components"
22
22
  import { DEFAULT_LOCALE, EMPTY_VALUE, formatValue, toValueFormatter, type ValueFormat } from "@squadbase/vantage/components"
23
23
  import type { ChartColor, ChartColors, ValueFormatter } from "@squadbase/vantage/components"
@@ -50,6 +50,8 @@ const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--ch
50
50
  interface ChartTokens {
51
51
  /** Resolved `--chart-1..5`, in order. */
52
52
  series: string[]
53
+ /** Computed `font-family` of the container, so canvas text matches the DOM. Optional. */
54
+ fontFamily?: string
53
55
  /** Body text: titles and the default `textStyle`. */
54
56
  foreground: string | null
55
57
  /** Secondary text: axis labels, legend, subtitles. */
@@ -58,10 +60,23 @@ interface ChartTokens {
58
60
  border: string | null
59
61
  /** Tooltip surface. */
60
62
  popover: string | null
63
+ /** Semantic and surface dependencies used by typed chart options. Optional for hand-built tokens. */
64
+ colors?: Record<string, string>
65
+ /** Actual inherited painting surface (may change without a token changing). */
66
+ surface?: string | null
61
67
  /** Tooltip text. */
62
68
  popoverForeground: string | null
63
69
  }
64
70
 
71
+ // Keep scope out of the public token shape. A resolved snapshot remembers where arbitrary
72
+ // var(--token) expressions must be read, without retaining unmounted charts globally.
73
+ const tokenScopes = new WeakMap<ChartTokens, Element>()
74
+ const OPTION_COLOR_TOKENS = [
75
+ "--background", "--card", "--muted", "--primary", "--destructive",
76
+ ...["positive", "negative", "warning", "neutral", "info"].flatMap((tone) =>
77
+ ["", "-fill", "-subtle"].map((suffix) => `--${tone}${suffix}`)),
78
+ ]
79
+
65
80
  let normalizeContext: CanvasRenderingContext2D | null | undefined
66
81
 
67
82
  /** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
@@ -117,14 +132,19 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
117
132
  const color = read(token)
118
133
  if (color) series.push(color)
119
134
  }
120
- return {
135
+ const tokens: ChartTokens = {
121
136
  series,
137
+ colors: Object.fromEntries(OPTION_COLOR_TOKENS.map((name) => [name, styles.getPropertyValue(name).trim()])),
138
+ surface: readChartSurface(target),
139
+ ...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
122
140
  foreground: read("--foreground"),
123
141
  mutedForeground: read("--muted-foreground"),
124
142
  border: read("--border"),
125
143
  popover: read("--popover"),
126
144
  popoverForeground: read("--popover-foreground"),
127
145
  }
146
+ tokenScopes.set(tokens, target)
147
+ return tokens
128
148
  }
129
149
 
130
150
  /** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
@@ -132,8 +152,13 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
132
152
  if (a === b) return true
133
153
  if (!a || !b) return false
134
154
  return (
155
+ tokenScopes.get(a) === tokenScopes.get(b) &&
156
+ a.surface === b.surface &&
157
+ Object.keys(a.colors ?? {}).length === Object.keys(b.colors ?? {}).length &&
158
+ Object.entries(a.colors ?? {}).every(([name, value]) => b.colors?.[name] === value) &&
135
159
  a.series.length === b.series.length &&
136
160
  a.series.every((color, i) => color === b.series[i]) &&
161
+ a.fontFamily === b.fontFamily &&
137
162
  a.foreground === b.foreground &&
138
163
  a.mutedForeground === b.mutedForeground &&
139
164
  a.border === b.border &&
@@ -156,7 +181,7 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
156
181
  * attributes alone leaves the chart painted in the previous palette until a
157
182
  * reload — the tokens in the DOM are new, the canvas is not.
158
183
  */
159
- function watchChartTokens(onChange: () => void): () => void {
184
+ function watchChartTokens(onChange: () => void, scope?: Element | null): () => void {
160
185
  if (typeof document === "undefined") return () => {}
161
186
  // Style edits arrive as bursts of mutations; coalesce them into one read, and
162
187
  // take it after the next frame so a swapped stylesheet has been applied.
@@ -171,8 +196,11 @@ function watchChartTokens(onChange: () => void): () => void {
171
196
 
172
197
  const themeObserver = new MutationObserver(schedule)
173
198
  const attributeFilter = ["class", "style", "data-theme"]
174
- themeObserver.observe(document.documentElement, { attributes: true, attributeFilter })
175
- if (document.body) themeObserver.observe(document.body, { attributes: true, attributeFilter })
199
+ // Only the chart and its ancestors can change its inherited theme. Never observe
200
+ // the document subtree: canvas/tooltip/rendering mutations would trigger every chart.
201
+ for (let node: Element | null = scope ?? document.body ?? document.documentElement; node; node = node.parentElement) {
202
+ themeObserver.observe(node, { attributes: true, attributeFilter })
203
+ }
176
204
 
177
205
  const styleObserver = new MutationObserver(schedule)
178
206
  if (document.head) {
@@ -228,7 +256,7 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
228
256
  // Let the page background show through: charts sit on cards, not on a canvas.
229
257
  backgroundColor: "transparent",
230
258
  ...(tokens.series.length > 0 ? { color: tokens.series } : {}),
231
- textStyle: { color: text },
259
+ textStyle: { color: text, ...(tokens.fontFamily ? { fontFamily: tokens.fontFamily } : {}) },
232
260
  title: { textStyle: { color: text }, subtextStyle: { color: muted } },
233
261
  legend: { textStyle: { color: muted } },
234
262
  categoryAxis: axisTheme(tokens, false),
@@ -244,7 +272,11 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
244
272
  padding: [8, 12],
245
273
  // Rounded card with a soft shadow; in dark mode the shadow barely shows, so the border carries the edge.
246
274
  extraCssText: "border-radius:8px;box-shadow:var(--shadow-card, 0 4px 12px rgb(0 0 0 / 0.08));",
247
- textStyle: { color: tokens.popoverForeground ?? undefined, fontSize: 12 },
275
+ textStyle: {
276
+ color: tokens.popoverForeground ?? undefined,
277
+ fontSize: 12,
278
+ ...(tokens.fontFamily ? { fontFamily: tokens.fontFamily } : {}),
279
+ },
248
280
  axisPointer: { lineStyle: { color: line }, crossStyle: { color: line } },
249
281
  },
250
282
  }
@@ -256,22 +288,23 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
256
288
  * components that build an ECharts option from token colours themselves (the
257
289
  * typed charts), so their palette follows light/dark like `EChart`'s theme.
258
290
  */
259
- function useChartTokens(): ChartTokens | null {
291
+ function useChartTokens(scope?: Element | null | RefObject<Element | null>): ChartTokens | null {
260
292
  const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
261
293
  // Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
262
294
  // replayed by React on every rebase and returns a fresh object each time, so the state
263
295
  // never settles ("Maximum update depth exceeded" once the tokens change after mount).
264
296
  const tokensRef = useRef(tokens)
265
297
  useEffect(() => {
298
+ const target = scope && "current" in scope ? scope.current : scope
266
299
  const sync = () => {
267
- const next = readChartTokens()
300
+ const next = readChartTokens(target)
268
301
  if (sameChartTokens(tokensRef.current, next)) return
269
302
  tokensRef.current = next
270
303
  setTokens(next)
271
304
  }
272
305
  sync()
273
- return watchChartTokens(sync)
274
- }, [])
306
+ return watchChartTokens(sync, target)
307
+ }, [scope])
275
308
  return tokens
276
309
  }
277
310
 
@@ -283,7 +316,7 @@ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
283
316
  /**
284
317
  * Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
285
318
  * the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
286
- * variants `"positive-fill"`, …) are read off the document root (`--positive`, …); `var(--token)` is read off the document root; any
319
+ * variants `"positive-fill"`, …) are read in the snapshot's inherited scope (`--positive`, …); `var(--token)` uses the same scope; any
287
320
  * other CSS colour is normalized like the tokens are. A colour that can't be
288
321
  * resolved comes back as `""`, which ECharts treats as "use the palette".
289
322
  */
@@ -294,12 +327,13 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
294
327
  // Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
295
328
  if (TONE_NAME_RE.test(color.trim())) {
296
329
  if (typeof document === "undefined") return ""
297
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue(`--${color.trim()}`)) ?? ""
330
+ const target = (tokens && tokenScopes.get(tokens)) ?? document.documentElement
331
+ return toCanvasColor(getComputedStyle(target).getPropertyValue(`--${color.trim()}`)) ?? ""
298
332
  }
299
333
  if (typeof document === "undefined") return color
300
334
  let value = color.trim()
301
335
  const cssVar = CSS_VAR_RE.exec(value)
302
- const styles = getComputedStyle(document.documentElement)
336
+ const styles = getComputedStyle((tokens && tokenScopes.get(tokens)) ?? document.documentElement)
303
337
  if (cssVar) {
304
338
  value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
305
339
  } else if (value.includes("var(")) {
@@ -345,7 +379,7 @@ function readChartSurface(el: Element | null): string | null {
345
379
  if (color) return color
346
380
  }
347
381
  }
348
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue("--background"))
382
+ return toCanvasColor(getComputedStyle(el ?? document.documentElement).getPropertyValue("--background"))
349
383
  }
350
384
 
351
385
  /** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
@@ -359,9 +393,10 @@ const WIDTH_BUCKET = 40
359
393
  */
360
394
  function useChartSurface<E extends Element>(
361
395
  forwarded: ForwardedRef<E>,
362
- tokens: ChartTokens | null,
363
- ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined } {
396
+ tokens?: ChartTokens | null,
397
+ ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined; tokens: ChartTokens | null } {
364
398
  const [node, setNode] = useState<E | null>(null)
399
+ const scopedTokens = useChartTokens(node)
365
400
  const [surface, setSurface] = useState<string | undefined>(undefined)
366
401
  const [width, setWidth] = useState<number | undefined>(undefined)
367
402
  const ref = useCallback(
@@ -375,7 +410,7 @@ function useChartSurface<E extends Element>(
375
410
  useEffect(() => {
376
411
  if (!node) return
377
412
  setSurface(readChartSurface(node) ?? undefined)
378
- }, [node, tokens])
413
+ }, [node, tokens, scopedTokens])
379
414
  useEffect(() => {
380
415
  if (!node || typeof ResizeObserver === "undefined") return
381
416
  const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
@@ -384,7 +419,7 @@ function useChartSurface<E extends Element>(
384
419
  observer.observe(node)
385
420
  return () => observer.disconnect()
386
421
  }, [node])
387
- return { ref, surface, width }
422
+ return { ref, surface, width, tokens: scopedTokens }
388
423
  }
389
424
 
390
425
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-utils.ts (internal to @squadbase/vantage) ---------- */
@@ -1538,6 +1573,10 @@ const toDate = (t: ParsedTime) => new Date(t.year, t.month - 1, t.day)
1538
1573
 
1539
1574
  /** How many x labels a time axis shows at most (the step is picked so the labels never crowd). */
1540
1575
  const TIME_AXIS_MAX_LABELS = 6
1576
+ /** Under this plot width (px) the axis, legend and labels switch to their compact form. */
1577
+ const NARROW_PLOT_WIDTH = 400
1578
+ /** The most x labels a narrow plot shows (a 262px plot reads 4 at most). */
1579
+ const NARROW_TIME_AXIS_MAX_LABELS = 4
1541
1580
 
1542
1581
  /** Calendar-friendly label steps per granularity (every 3 months, every week …). */
1543
1582
  const TIME_AXIS_STEPS: Record<TimeGranularity, readonly number[]> = {
@@ -1983,7 +2022,9 @@ const LEGEND_LINE_BUDGET = 320
1983
2022
  /** How many lines the plain legend takes, estimated from its labels (12px text + 8px dot + gaps). */
1984
2023
  function legendLines(items: readonly string[], plotWidth?: number, reserve = 0): number {
1985
2024
  const width = items.reduce((sum, name) => sum + [...name].length * 12 + 8 + 5 + 16, 0)
1986
- const budget = Math.max(80, (plotWidth ? Math.min(LEGEND_LINE_BUDGET, plotWidth) : LEGEND_LINE_BUDGET) - reserve)
2025
+ // A narrow plot has no room for the PNG buttons beside the legend: they move off its line, so no reserve.
2026
+ const narrow = plotWidth !== undefined && plotWidth < NARROW_PLOT_WIDTH
2027
+ const budget = Math.max(80, (plotWidth ? (narrow ? plotWidth : Math.min(LEGEND_LINE_BUDGET, plotWidth)) : LEGEND_LINE_BUDGET) - (narrow ? 0 : reserve))
1987
2028
  return Math.min(3, Math.max(1, Math.ceil(width / budget)))
1988
2029
  }
1989
2030
 
@@ -2238,7 +2279,15 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2238
2279
  ? null
2239
2280
  : timeAxisLabelStep(
2240
2281
  rawIndex,
2241
- plotWidth ? Math.max(2, Math.min(TIME_AXIS_MAX_LABELS, Math.floor(plotWidth / TIME_LABEL_WIDTH))) : undefined,
2282
+ plotWidth
2283
+ ? Math.max(
2284
+ 2,
2285
+ Math.min(
2286
+ plotWidth < NARROW_PLOT_WIDTH ? NARROW_TIME_AXIS_MAX_LABELS : TIME_AXIS_MAX_LABELS,
2287
+ Math.floor(plotWidth / TIME_LABEL_WIDTH),
2288
+ ),
2289
+ )
2290
+ : undefined,
2242
2291
  )
2243
2292
  // Plain (non-date) x labels of a line chart: thinned to every n-th so they never touch, never rotated.
2244
2293
  const plainStep = (() => {
@@ -2284,8 +2333,12 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2284
2333
  : []
2285
2334
  // On a narrow plot the max and the latest label would print over each other: keep the latest.
2286
2335
  const pointGap = plotWidth && labels.length > 1 ? plotWidth / (kind === "bar" ? labels.length : labels.length - 1) : null
2336
+ // By default only the latest value is labelled; an explicit `highlight` keeps its max + latest.
2337
+ const latestOnly = cfg.highlight === undefined && !shareHighlight && picked.length === 2
2287
2338
  const highlightPoints =
2288
- picked.length === 2 && pointGap !== null && Math.abs(picked[1]!.index - picked[0]!.index) * pointGap < HIGHLIGHT_LABEL_WIDTH
2339
+ latestOnly
2340
+ ? [picked[1]!]
2341
+ : picked.length === 2 && pointGap !== null && Math.abs(picked[1]!.index - picked[0]!.index) * pointGap < HIGHLIGHT_LABEL_WIDTH
2289
2342
  ? [picked[1]!]
2290
2343
  : picked
2291
2344
 
@@ -2496,6 +2549,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2496
2549
  ? { width: 0, color, opacity: 0 }
2497
2550
  : { width: 2, color, opacity: dimSeries ? DIMMED : 1 },
2498
2551
  itemStyle: { color, opacity: dimSeries ? DIMMED : 1 },
2552
+ // Hovering a series fades the others (not for a stack, where the bands read together).
2553
+ emphasis: stacked ? undefined : { focus: "series" as const },
2499
2554
  areaStyle: areaStyle && dimSeries ? { ...areaStyle, opacity: DIMMED * 0.3 } : areaStyle,
2500
2555
  markPoint: markPointFor(c, color),
2501
2556
  markLine: undefined,
@@ -2942,7 +2997,10 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2942
2997
  legend: {
2943
2998
  show: showLegend,
2944
2999
  top: 0,
2945
- right: reserveActions ? ACTIONS_RESERVE : 0,
3000
+ // Narrow: left-aligned and wrapping above the plot, so nothing is cut off at the right edge.
3001
+ ...(plotWidth !== undefined && plotWidth < NARROW_PLOT_WIDTH
3002
+ ? { left: 0, right: 0 }
3003
+ : { right: reserveActions ? ACTIONS_RESERVE : 0 }),
2946
3004
  // Plain wraps onto a second line at narrow widths instead of hiding items behind arrows.
2947
3005
  type: "plain",
2948
3006
  icon: "circle",
@@ -3003,7 +3061,12 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3003
3061
  }
3004
3062
  }
3005
3063
  const shares = percent ? cartesianSeriesValues(i <= lastActual ? [row] : [], categories, "percent") : null
3006
- for (const p of list) {
3064
+ // Rows follow the legend order (categories first), not ECharts' own series order.
3065
+ const legendRank = (p: AxisTooltipParam) => {
3066
+ const at = categories.indexOf(p.seriesName ?? "")
3067
+ return at === -1 ? categories.length : at
3068
+ }
3069
+ for (const p of [...list].sort((a, b) => legendRank(a) - legendRank(b))) {
3007
3070
  const color = typeof p.color === "string" ? p.color : "currentColor"
3008
3071
  if (p.seriesId === FORECAST_SERIES_ID) {
3009
3072
  // At and before the boundary the forecast repeats the actual value.