@squadbase/vantage 0.7.1-dev1.9737835 → 0.7.1-dev1.9d23147

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 (64) hide show
  1. package/dist/{build-U7GO26HR.js → build-AON4MAQQ.js} +3 -3
  2. package/dist/{build-U7GO26HR.js.map → build-AON4MAQQ.js.map} +1 -1
  3. package/dist/{chunk-EG4PX726.js → chunk-GWXKENNH.js} +122 -5
  4. package/dist/chunk-GWXKENNH.js.map +1 -0
  5. package/dist/{chunk-SC7K2RD6.js → chunk-I2GWIPTD.js} +101 -27
  6. package/dist/chunk-I2GWIPTD.js.map +1 -0
  7. package/dist/{chunk-SDJTTMLK.js → chunk-SR43D6EF.js} +13 -8
  8. package/dist/chunk-SR43D6EF.js.map +1 -0
  9. package/dist/{chunk-P2KIHF65.js → chunk-U6MWBCM6.js} +28 -7
  10. package/dist/chunk-U6MWBCM6.js.map +1 -0
  11. package/dist/cli.js +2 -2
  12. package/dist/components/index.d.ts +206 -4
  13. package/dist/components/index.js +3 -3
  14. package/dist/{dev-O42JFQ64.js → dev-BZ7FXDB3.js} +3 -3
  15. package/dist/{dev-O42JFQ64.js.map → dev-BZ7FXDB3.js.map} +1 -1
  16. package/dist/doc/index.js +1 -1
  17. package/dist/slide/index.js +1 -1
  18. package/dist/table/index.js +3 -3
  19. package/dist/ui/index.d.ts +28 -1
  20. package/dist/ui/index.js +172 -9
  21. package/dist/ui/index.js.map +1 -1
  22. package/dist/vite/index.js +1 -1
  23. package/package.json +2 -2
  24. package/registry/parts.json +81 -2
  25. package/registry/ui/area-chart.tsx +43 -8
  26. package/registry/ui/bar-chart.tsx +43 -8
  27. package/registry/ui/bar-list.tsx +43 -8
  28. package/registry/ui/box-plot-chart.tsx +42 -7
  29. package/registry/ui/category-bar.tsx +25 -5
  30. package/registry/ui/chart-frame.tsx +47 -14
  31. package/registry/ui/checkbox.tsx +4 -3
  32. package/registry/ui/code-block.tsx +44 -0
  33. package/registry/ui/combo-chart.tsx +20 -3
  34. package/registry/ui/combobox.tsx +7 -0
  35. package/registry/ui/context-menu.tsx +71 -0
  36. package/registry/ui/dashboard-card.tsx +20 -9
  37. package/registry/ui/data-table.tsx +7 -1
  38. package/registry/ui/date-range-picker.tsx +51 -4
  39. package/registry/ui/delta.tsx +36 -11
  40. package/registry/ui/donut-chart.tsx +43 -8
  41. package/registry/ui/echart.tsx +167 -7
  42. package/registry/ui/file-dropzone.tsx +143 -0
  43. package/registry/ui/filter-bar.tsx +74 -1
  44. package/registry/ui/forecast-chart.tsx +42 -7
  45. package/registry/ui/funnel-steps.tsx +22 -4
  46. package/registry/ui/heatmap.tsx +20 -3
  47. package/registry/ui/histogram-chart.tsx +42 -7
  48. package/registry/ui/kpi-card.tsx +12 -8
  49. package/registry/ui/legend.tsx +73 -15
  50. package/registry/ui/line-chart.tsx +43 -8
  51. package/registry/ui/page-shell.tsx +2 -0
  52. package/registry/ui/paired-chart.tsx +43 -8
  53. package/registry/ui/progress.tsx +1 -1
  54. package/registry/ui/scatter-chart.tsx +50 -12
  55. package/registry/ui/sparkline.tsx +43 -8
  56. package/registry/ui/states.tsx +34 -1
  57. package/registry/ui/status-badge.tsx +7 -7
  58. package/registry/ui/tabs.tsx +1 -0
  59. package/registry/ui/treemap.tsx +20 -3
  60. package/registry/ui/waterfall-chart.tsx +64 -11
  61. package/dist/chunk-EG4PX726.js.map +0 -1
  62. package/dist/chunk-P2KIHF65.js.map +0 -1
  63. package/dist/chunk-SC7K2RD6.js.map +0 -1
  64. package/dist/chunk-SDJTTMLK.js.map +0 -1
@@ -171,10 +171,25 @@ function useLocale(locale?: string | null): string {
171
171
  const CHART_OTHER_COLOR = "color-mix(in oklab, var(--neutral) 50%, var(--background))"
172
172
 
173
173
  /**
174
- * True inside `ChartFrame`. The frame's plot area carries the chart's single
175
- * `role="img"` and label, so `EChart` drops its own when it sees this.
174
+ * What a `ChartFrame` tells the plot inside it. A frame whose plot is an image
175
+ * (`image`) puts `role="img"` on its plot area, unless an `EChart` inside it
176
+ * `claim`s the image: then the canvas alone carries `role="img"` with this name
177
+ * and description (`claimed`), so the action buttons beside the canvas are not
178
+ * inside the image (an interactive control inside `role="img"` is a
179
+ * nested-interactive violation and is not exposed). A `group` frame never
180
+ * hands the role to the canvas. `false` outside a frame.
176
181
  */
177
- const ChartFrameContext = createContext(false)
182
+ interface ChartFramePlot {
183
+ image: boolean
184
+ /** An `EChart` inside has taken over the `role="img"`. */
185
+ claimed: boolean
186
+ /** Take over the image role; returns the release. */
187
+ claim: () => () => void
188
+ ariaLabel?: string
189
+ ariaLabelledBy?: string
190
+ ariaDescribedBy?: string
191
+ }
192
+ const ChartFrameContext = createContext<ChartFramePlot | false>(false)
178
193
 
179
194
  /** Series palette, in order. Missing tokens are skipped, not defaulted. */
180
195
  const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const
@@ -398,6 +413,8 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
398
413
  tooltip: {
399
414
  // ECharts' default 0.4s transform lags the tooltip ~300ms behind the cursor.
400
415
  transitionDuration: 0,
416
+ // Stay inside the chart: cards are often narrow and overflow-hidden.
417
+ confine: true,
401
418
  backgroundColor: tokens.popover ?? undefined,
402
419
  borderColor: line,
403
420
  borderWidth: 1,
@@ -2273,6 +2290,10 @@ function legendLines(items: readonly string[], plotWidth?: number, reserve = 0):
2273
2290
 
2274
2291
  /** Width (px) kept free at the legend's right for the PNG action buttons (two 32px buttons + gaps). */
2275
2292
  const ACTIONS_RESERVE = 80
2293
+ /** Bars stay slim on wide cards (px); stacked segments round all corners and are parted by a hairline of the surface colour (px). */
2294
+ const BAR_MAX_WIDTH = 40
2295
+ const BAR_RADIUS = 3
2296
+ const STACK_GAP = 1.5
2276
2297
 
2277
2298
  /** Room (px) one time label (`2026年1月`) needs, gap included. */
2278
2299
  const TIME_LABEL_WIDTH = 80
@@ -2486,6 +2507,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2486
2507
  const compared = cartesianSeriesValues(data, [categories[compIdx]!], "default")[0]!
2487
2508
  return categories.map((_, i) => (i === compIdx ? compared : main[mainIdx.indexOf(i)]!))
2488
2509
  })()
2510
+ // Any negative bar: the part lays out the diverging view itself (a solid zero line, category labels kept at the axis end).
2511
+ const diverging = kind === "bar" && values.some((s) => s.some((v) => typeof v === "number" && v < 0))
2489
2512
  const comparisonColor = guideColor ? withAlpha(guideColor, COMPARISON_OPACITY) : undefined
2490
2513
 
2491
2514
  /* -- x labels: the actual rows, then the forecast rows that extend them */
@@ -2695,15 +2718,17 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2695
2718
  const seriesData = pad(values[c]!)
2696
2719
  if (kind === "bar") {
2697
2720
  const rounded = horizontalBars ? [0, 4, 4, 0] : [4, 4, 0, 0]
2698
- // Stacked: only the topmost segment is rounded.
2699
- const radius = stacked && c !== categories.length - 1 ? 0 : rounded
2721
+ // Stacked: every segment is rounded, with a hairline of the surface colour between segments (the gap).
2722
+ const radius = stacked ? BAR_RADIUS : rounded
2723
+ const gapStyle = stacked && labelBackground ? { borderColor: labelBackground, borderWidth: STACK_GAP } : {}
2700
2724
  return {
2701
2725
  id: SERIES_ID_PREFIX + category,
2702
2726
  type: "bar" as const,
2703
2727
  name: category,
2704
2728
  stack: stacked ? "total" : undefined,
2705
2729
  barCategoryGap,
2706
- itemStyle: { color, borderRadius: radius },
2730
+ barMaxWidth: BAR_MAX_WIDTH,
2731
+ itemStyle: { color, borderRadius: radius, ...gapStyle },
2707
2732
  markPoint: markPointFor(c, solid),
2708
2733
  markLine: undefined,
2709
2734
  data: seriesData.map((value, i) => {
@@ -2999,6 +3024,14 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2999
3024
  },
3000
3025
  })
3001
3026
  }
3027
+ if (diverging) {
3028
+ markLines.push({
3029
+ name: "zero",
3030
+ [horizontalBars ? "xAxis" : "yAxis"]: 0,
3031
+ lineStyle: { type: "solid", width: 1, color: guideColor },
3032
+ label: { show: false },
3033
+ })
3034
+ }
3002
3035
  if (forecast) {
3003
3036
  const forecastSpan =
3004
3037
  plotWidth && labels.length > 1
@@ -3144,6 +3177,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3144
3177
  show: horizontalBars ? showYAxis : showXAxis,
3145
3178
  boundaryGap: kind === "bar",
3146
3179
  inverse: horizontalBars,
3180
+ // Diverging: the axis stays at the plot's edge (not at 0, where labels would hit the negative bars); the zero line is a mark line.
3181
+ axisLine: diverging ? { show: false, onZero: false } : undefined,
3147
3182
  axisTick: { show: false },
3148
3183
  axisLabel: {
3149
3184
  hideOverlap: true,
@@ -3246,7 +3281,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3246
3281
  // Narrow: left-aligned and wrapping above the plot, so nothing is cut off at the right edge.
3247
3282
  ...(plotWidth !== undefined && plotWidth < NARROW_PLOT_WIDTH
3248
3283
  ? { left: 0, right: 0 }
3249
- : { right: reserveActions ? ACTIONS_RESERVE : 0 }),
3284
+ : { left: 0, right: reserveActions ? ACTIONS_RESERVE : 0 }),
3250
3285
  // Plain wraps onto a second line at narrow widths instead of hiding items behind arrows.
3251
3286
  type: "plain",
3252
3287
  icon: "circle",
@@ -1891,6 +1891,10 @@ function legendLines(items: readonly string[], plotWidth?: number, reserve = 0):
1891
1891
 
1892
1892
  /** Width (px) kept free at the legend's right for the PNG action buttons (two 32px buttons + gaps). */
1893
1893
  const ACTIONS_RESERVE = 80
1894
+ /** Bars stay slim on wide cards (px); stacked segments round all corners and are parted by a hairline of the surface colour (px). */
1895
+ const BAR_MAX_WIDTH = 40
1896
+ const BAR_RADIUS = 3
1897
+ const STACK_GAP = 1.5
1894
1898
 
1895
1899
  /** Room (px) one time label (`2026年1月`) needs, gap included. */
1896
1900
  const TIME_LABEL_WIDTH = 80
@@ -2104,6 +2108,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2104
2108
  const compared = cartesianSeriesValues(data, [categories[compIdx]!], "default")[0]!
2105
2109
  return categories.map((_, i) => (i === compIdx ? compared : main[mainIdx.indexOf(i)]!))
2106
2110
  })()
2111
+ // Any negative bar: the part lays out the diverging view itself (a solid zero line, category labels kept at the axis end).
2112
+ const diverging = kind === "bar" && values.some((s) => s.some((v) => typeof v === "number" && v < 0))
2107
2113
  const comparisonColor = guideColor ? withAlpha(guideColor, COMPARISON_OPACITY) : undefined
2108
2114
 
2109
2115
  /* -- x labels: the actual rows, then the forecast rows that extend them */
@@ -2313,15 +2319,17 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2313
2319
  const seriesData = pad(values[c]!)
2314
2320
  if (kind === "bar") {
2315
2321
  const rounded = horizontalBars ? [0, 4, 4, 0] : [4, 4, 0, 0]
2316
- // Stacked: only the topmost segment is rounded.
2317
- const radius = stacked && c !== categories.length - 1 ? 0 : rounded
2322
+ // Stacked: every segment is rounded, with a hairline of the surface colour between segments (the gap).
2323
+ const radius = stacked ? BAR_RADIUS : rounded
2324
+ const gapStyle = stacked && labelBackground ? { borderColor: labelBackground, borderWidth: STACK_GAP } : {}
2318
2325
  return {
2319
2326
  id: SERIES_ID_PREFIX + category,
2320
2327
  type: "bar" as const,
2321
2328
  name: category,
2322
2329
  stack: stacked ? "total" : undefined,
2323
2330
  barCategoryGap,
2324
- itemStyle: { color, borderRadius: radius },
2331
+ barMaxWidth: BAR_MAX_WIDTH,
2332
+ itemStyle: { color, borderRadius: radius, ...gapStyle },
2325
2333
  markPoint: markPointFor(c, solid),
2326
2334
  markLine: undefined,
2327
2335
  data: seriesData.map((value, i) => {
@@ -2617,6 +2625,14 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2617
2625
  },
2618
2626
  })
2619
2627
  }
2628
+ if (diverging) {
2629
+ markLines.push({
2630
+ name: "zero",
2631
+ [horizontalBars ? "xAxis" : "yAxis"]: 0,
2632
+ lineStyle: { type: "solid", width: 1, color: guideColor },
2633
+ label: { show: false },
2634
+ })
2635
+ }
2620
2636
  if (forecast) {
2621
2637
  const forecastSpan =
2622
2638
  plotWidth && labels.length > 1
@@ -2762,6 +2778,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2762
2778
  show: horizontalBars ? showYAxis : showXAxis,
2763
2779
  boundaryGap: kind === "bar",
2764
2780
  inverse: horizontalBars,
2781
+ // Diverging: the axis stays at the plot's edge (not at 0, where labels would hit the negative bars); the zero line is a mark line.
2782
+ axisLine: diverging ? { show: false, onZero: false } : undefined,
2765
2783
  axisTick: { show: false },
2766
2784
  axisLabel: {
2767
2785
  hideOverlap: true,
@@ -2864,7 +2882,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2864
2882
  // Narrow: left-aligned and wrapping above the plot, so nothing is cut off at the right edge.
2865
2883
  ...(plotWidth !== undefined && plotWidth < NARROW_PLOT_WIDTH
2866
2884
  ? { left: 0, right: 0 }
2867
- : { right: reserveActions ? ACTIONS_RESERVE : 0 }),
2885
+ : { left: 0, right: reserveActions ? ACTIONS_RESERVE : 0 }),
2868
2886
  // Plain wraps onto a second line at narrow widths instead of hiding items behind arrows.
2869
2887
  type: "plain",
2870
2888
  icon: "circle",
@@ -177,10 +177,25 @@ function useLocale(locale?: string | null): string {
177
177
  const CHART_OTHER_COLOR = "color-mix(in oklab, var(--neutral) 50%, var(--background))"
178
178
 
179
179
  /**
180
- * True inside `ChartFrame`. The frame's plot area carries the chart's single
181
- * `role="img"` and label, so `EChart` drops its own when it sees this.
180
+ * What a `ChartFrame` tells the plot inside it. A frame whose plot is an image
181
+ * (`image`) puts `role="img"` on its plot area, unless an `EChart` inside it
182
+ * `claim`s the image: then the canvas alone carries `role="img"` with this name
183
+ * and description (`claimed`), so the action buttons beside the canvas are not
184
+ * inside the image (an interactive control inside `role="img"` is a
185
+ * nested-interactive violation and is not exposed). A `group` frame never
186
+ * hands the role to the canvas. `false` outside a frame.
182
187
  */
183
- const ChartFrameContext = createContext(false)
188
+ interface ChartFramePlot {
189
+ image: boolean
190
+ /** An `EChart` inside has taken over the `role="img"`. */
191
+ claimed: boolean
192
+ /** Take over the image role; returns the release. */
193
+ claim: () => () => void
194
+ ariaLabel?: string
195
+ ariaLabelledBy?: string
196
+ ariaDescribedBy?: string
197
+ }
198
+ const ChartFrameContext = createContext<ChartFramePlot | false>(false)
184
199
 
185
200
  /** Series palette, in order. Missing tokens are skipped, not defaulted. */
186
201
  const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const
@@ -404,6 +419,8 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
404
419
  tooltip: {
405
420
  // ECharts' default 0.4s transform lags the tooltip ~300ms behind the cursor.
406
421
  transitionDuration: 0,
422
+ // Stay inside the chart: cards are often narrow and overflow-hidden.
423
+ confine: true,
407
424
  backgroundColor: tokens.popover ?? undefined,
408
425
  borderColor: line,
409
426
  borderWidth: 1,
@@ -173,10 +173,25 @@ function useLocale(locale?: string | null): string {
173
173
  const CHART_OTHER_COLOR = "color-mix(in oklab, var(--neutral) 50%, var(--background))"
174
174
 
175
175
  /**
176
- * True inside `ChartFrame`. The frame's plot area carries the chart's single
177
- * `role="img"` and label, so `EChart` drops its own when it sees this.
176
+ * What a `ChartFrame` tells the plot inside it. A frame whose plot is an image
177
+ * (`image`) puts `role="img"` on its plot area, unless an `EChart` inside it
178
+ * `claim`s the image: then the canvas alone carries `role="img"` with this name
179
+ * and description (`claimed`), so the action buttons beside the canvas are not
180
+ * inside the image (an interactive control inside `role="img"` is a
181
+ * nested-interactive violation and is not exposed). A `group` frame never
182
+ * hands the role to the canvas. `false` outside a frame.
178
183
  */
179
- const ChartFrameContext = createContext(false)
184
+ interface ChartFramePlot {
185
+ image: boolean
186
+ /** An `EChart` inside has taken over the `role="img"`. */
187
+ claimed: boolean
188
+ /** Take over the image role; returns the release. */
189
+ claim: () => () => void
190
+ ariaLabel?: string
191
+ ariaLabelledBy?: string
192
+ ariaDescribedBy?: string
193
+ }
194
+ const ChartFrameContext = createContext<ChartFramePlot | false>(false)
180
195
 
181
196
  /** Series palette, in order. Missing tokens are skipped, not defaulted. */
182
197
  const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const
@@ -400,6 +415,8 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
400
415
  tooltip: {
401
416
  // ECharts' default 0.4s transform lags the tooltip ~300ms behind the cursor.
402
417
  transitionDuration: 0,
418
+ // Stay inside the chart: cards are often narrow and overflow-hidden.
419
+ confine: true,
403
420
  backgroundColor: tokens.popover ?? undefined,
404
421
  borderColor: line,
405
422
  borderWidth: 1,
@@ -1436,6 +1453,10 @@ function legendLines(items: readonly string[], plotWidth?: number, reserve = 0):
1436
1453
 
1437
1454
  /** Width (px) kept free at the legend's right for the PNG action buttons (two 32px buttons + gaps). */
1438
1455
  const ACTIONS_RESERVE = 80
1456
+ /** Bars stay slim on wide cards (px); stacked segments round all corners and are parted by a hairline of the surface colour (px). */
1457
+ const BAR_MAX_WIDTH = 40
1458
+ const BAR_RADIUS = 3
1459
+ const STACK_GAP = 1.5
1439
1460
 
1440
1461
  /** Room (px) one time label (`2026年1月`) needs, gap included. */
1441
1462
  const TIME_LABEL_WIDTH = 80
@@ -1649,6 +1670,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1649
1670
  const compared = cartesianSeriesValues(data, [categories[compIdx]!], "default")[0]!
1650
1671
  return categories.map((_, i) => (i === compIdx ? compared : main[mainIdx.indexOf(i)]!))
1651
1672
  })()
1673
+ // Any negative bar: the part lays out the diverging view itself (a solid zero line, category labels kept at the axis end).
1674
+ const diverging = kind === "bar" && values.some((s) => s.some((v) => typeof v === "number" && v < 0))
1652
1675
  const comparisonColor = guideColor ? withAlpha(guideColor, COMPARISON_OPACITY) : undefined
1653
1676
 
1654
1677
  /* -- x labels: the actual rows, then the forecast rows that extend them */
@@ -1858,15 +1881,17 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1858
1881
  const seriesData = pad(values[c]!)
1859
1882
  if (kind === "bar") {
1860
1883
  const rounded = horizontalBars ? [0, 4, 4, 0] : [4, 4, 0, 0]
1861
- // Stacked: only the topmost segment is rounded.
1862
- const radius = stacked && c !== categories.length - 1 ? 0 : rounded
1884
+ // Stacked: every segment is rounded, with a hairline of the surface colour between segments (the gap).
1885
+ const radius = stacked ? BAR_RADIUS : rounded
1886
+ const gapStyle = stacked && labelBackground ? { borderColor: labelBackground, borderWidth: STACK_GAP } : {}
1863
1887
  return {
1864
1888
  id: SERIES_ID_PREFIX + category,
1865
1889
  type: "bar" as const,
1866
1890
  name: category,
1867
1891
  stack: stacked ? "total" : undefined,
1868
1892
  barCategoryGap,
1869
- itemStyle: { color, borderRadius: radius },
1893
+ barMaxWidth: BAR_MAX_WIDTH,
1894
+ itemStyle: { color, borderRadius: radius, ...gapStyle },
1870
1895
  markPoint: markPointFor(c, solid),
1871
1896
  markLine: undefined,
1872
1897
  data: seriesData.map((value, i) => {
@@ -2162,6 +2187,14 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2162
2187
  },
2163
2188
  })
2164
2189
  }
2190
+ if (diverging) {
2191
+ markLines.push({
2192
+ name: "zero",
2193
+ [horizontalBars ? "xAxis" : "yAxis"]: 0,
2194
+ lineStyle: { type: "solid", width: 1, color: guideColor },
2195
+ label: { show: false },
2196
+ })
2197
+ }
2165
2198
  if (forecast) {
2166
2199
  const forecastSpan =
2167
2200
  plotWidth && labels.length > 1
@@ -2307,6 +2340,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2307
2340
  show: horizontalBars ? showYAxis : showXAxis,
2308
2341
  boundaryGap: kind === "bar",
2309
2342
  inverse: horizontalBars,
2343
+ // Diverging: the axis stays at the plot's edge (not at 0, where labels would hit the negative bars); the zero line is a mark line.
2344
+ axisLine: diverging ? { show: false, onZero: false } : undefined,
2310
2345
  axisTick: { show: false },
2311
2346
  axisLabel: {
2312
2347
  hideOverlap: true,
@@ -2409,7 +2444,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2409
2444
  // Narrow: left-aligned and wrapping above the plot, so nothing is cut off at the right edge.
2410
2445
  ...(plotWidth !== undefined && plotWidth < NARROW_PLOT_WIDTH
2411
2446
  ? { left: 0, right: 0 }
2412
- : { right: reserveActions ? ACTIONS_RESERVE : 0 }),
2447
+ : { left: 0, right: reserveActions ? ACTIONS_RESERVE : 0 }),
2413
2448
  // Plain wraps onto a second line at narrow widths instead of hiding items behind arrows.
2414
2449
  type: "plain",
2415
2450
  icon: "circle",
@@ -8,7 +8,7 @@
8
8
  * Five tiers, following shadcn dashboard-01 SectionCards:
9
9
  *
10
10
  * 1. label (CardDescription, muted)
11
- * 2. value (CardTitle, tabular-nums; text-4xl once the card is ≥ 320px wide (a comparison label is ~180px, so narrower cards would overlap the value))
11
+ * 2. value (CardTitle, tabular-nums; text-[26px] once the card is ≥ 320px wide (a comparison label is ~180px, so narrower cards would overlap the value))
12
12
  * 3. delta (CardAction: arrow + sign + colour + comparison label — `Delta`)
13
13
  * 4. previous actual ("前月 ¥4,290万"; on by default, `showPrevious={false}` hides it)
14
14
  * 5. conclusion (`summary`: a *template* whose placeholders the card fills)
@@ -590,7 +590,7 @@ export function KpiCard({
590
590
  if (summary !== undefined) warnShallowSummary(summary, t)
591
591
  // Two rows of the parent grid (body, footer), shared by subgrid; a plain two-row grid when standalone.
592
592
  const cardClass = cn("row-span-2 grid content-start gap-y-0 grid-rows-subgrid", className)
593
- const valueSize = small ? "text-xl @[218px]/card:text-2xl" : "text-2xl @[218px]/card:text-4xl"
593
+ const valueSize = small ? "text-xl @[218px]/card:text-2xl" : "text-2xl @[218px]/card:text-[26px]"
594
594
 
595
595
  if (isLoading) {
596
596
  return (
@@ -748,6 +748,7 @@ export function KpiCard({
748
748
  change={display}
749
749
  digits={digits}
750
750
  locale={locale}
751
+ className="min-w-0 shrink whitespace-normal"
751
752
  />
752
753
  ))
753
754
 
@@ -757,16 +758,18 @@ export function KpiCard({
757
758
  return (
758
759
  <Card data-slot="kpi-card" size={size} className={cardClass} {...props}>
759
760
  <CardHeader className={HEADER_CLASS}>
760
- <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">
761
- {label}
761
+ <CardDescription data-slot="kpi-card-label" title={label} className="col-start-1 flex min-w-0 items-center gap-2 text-[13px] leading-snug font-medium text-muted-foreground">
762
+ <span className="min-w-0 truncate">{label}</span>
763
+ {sample && <SampleBadge label={sample.label} className="shrink-0" />}
762
764
  </CardDescription>
763
765
  <CardTitle
764
766
  data-slot="kpi-card-value"
765
767
  title={valueText}
766
768
  className={cn(
767
- "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",
769
+ "col-start-1 flex min-w-0 items-center gap-x-2 gap-y-1 leading-none font-medium tracking-tight tabular-nums",
768
770
  // Fixed size: the step follows the card width only, never the length of the value.
769
771
  valueSize,
772
+ (longValue || isProvisional) && "flex-wrap",
770
773
  )}
771
774
  >
772
775
  <span data-slot="kpi-card-value-text" className={longValue ? "min-w-0 break-words" : "whitespace-nowrap"}>
@@ -784,7 +787,8 @@ export function KpiCard({
784
787
  {provisionalLabel}
785
788
  </Badge>
786
789
  )}
787
- {sample && <SampleBadge label={sample.label} />}
790
+ {/* The delta sits right next to the value; a long value or several comparisons put it under. */}
791
+ {comparison && !stacked && !longValue && deltas}
788
792
  </CardTitle>
789
793
  {comparison &&
790
794
  (stacked ? (
@@ -792,7 +796,7 @@ export function KpiCard({
792
796
  {deltas}
793
797
  </div>
794
798
  ) : (
795
- <CardAction className={longValue ? DELTA_UNDER_PLACEMENT : DELTA_PLACEMENT}>{deltas}</CardAction>
799
+ longValue && <CardAction className={DELTA_UNDER_PLACEMENT}>{deltas}</CardAction>
796
800
  ))}
797
801
  {hasPrevious && comparison && (
798
802
  <div data-slot="kpi-card-previous" className="col-span-full text-sm text-muted-foreground tabular-nums">
@@ -879,7 +883,7 @@ export function KpiCard({
879
883
  <Sparkline
880
884
  data={historyPoints.map((v) => ({ value: v }))}
881
885
  height={SPARKLINE_HEIGHT}
882
- color="text-chart-1"
886
+ color="chart-1"
883
887
  highlight="latest"
884
888
  aria-label={t.history(label, historyPoints.length)}
885
889
  />
@@ -172,10 +172,25 @@ function useLocale(locale?: string | null): string {
172
172
  const CHART_OTHER_COLOR = "color-mix(in oklab, var(--neutral) 50%, var(--background))"
173
173
 
174
174
  /**
175
- * True inside `ChartFrame`. The frame's plot area carries the chart's single
176
- * `role="img"` and label, so `EChart` drops its own when it sees this.
177
- */
178
- const ChartFrameContext = createContext(false)
175
+ * What a `ChartFrame` tells the plot inside it. A frame whose plot is an image
176
+ * (`image`) puts `role="img"` on its plot area, unless an `EChart` inside it
177
+ * `claim`s the image: then the canvas alone carries `role="img"` with this name
178
+ * and description (`claimed`), so the action buttons beside the canvas are not
179
+ * inside the image (an interactive control inside `role="img"` is a
180
+ * nested-interactive violation and is not exposed). A `group` frame never
181
+ * hands the role to the canvas. `false` outside a frame.
182
+ */
183
+ interface ChartFramePlot {
184
+ image: boolean
185
+ /** An `EChart` inside has taken over the `role="img"`. */
186
+ claimed: boolean
187
+ /** Take over the image role; returns the release. */
188
+ claim: () => () => void
189
+ ariaLabel?: string
190
+ ariaLabelledBy?: string
191
+ ariaDescribedBy?: string
192
+ }
193
+ const ChartFrameContext = createContext<ChartFramePlot | false>(false)
179
194
 
180
195
  /** Series palette, in order. Missing tokens are skipped, not defaulted. */
181
196
  const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const
@@ -399,6 +414,8 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
399
414
  tooltip: {
400
415
  // ECharts' default 0.4s transform lags the tooltip ~300ms behind the cursor.
401
416
  transitionDuration: 0,
417
+ // Stay inside the chart: cards are often narrow and overflow-hidden.
418
+ confine: true,
402
419
  backgroundColor: tokens.popover ?? undefined,
403
420
  borderColor: line,
404
421
  borderWidth: 1,
@@ -2274,6 +2291,10 @@ function legendLines(items: readonly string[], plotWidth?: number, reserve = 0):
2274
2291
 
2275
2292
  /** Width (px) kept free at the legend's right for the PNG action buttons (two 32px buttons + gaps). */
2276
2293
  const ACTIONS_RESERVE = 80
2294
+ /** Bars stay slim on wide cards (px); stacked segments round all corners and are parted by a hairline of the surface colour (px). */
2295
+ const BAR_MAX_WIDTH = 40
2296
+ const BAR_RADIUS = 3
2297
+ const STACK_GAP = 1.5
2277
2298
 
2278
2299
  /** Room (px) one time label (`2026年1月`) needs, gap included. */
2279
2300
  const TIME_LABEL_WIDTH = 80
@@ -2487,6 +2508,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2487
2508
  const compared = cartesianSeriesValues(data, [categories[compIdx]!], "default")[0]!
2488
2509
  return categories.map((_, i) => (i === compIdx ? compared : main[mainIdx.indexOf(i)]!))
2489
2510
  })()
2511
+ // Any negative bar: the part lays out the diverging view itself (a solid zero line, category labels kept at the axis end).
2512
+ const diverging = kind === "bar" && values.some((s) => s.some((v) => typeof v === "number" && v < 0))
2490
2513
  const comparisonColor = guideColor ? withAlpha(guideColor, COMPARISON_OPACITY) : undefined
2491
2514
 
2492
2515
  /* -- x labels: the actual rows, then the forecast rows that extend them */
@@ -2696,15 +2719,17 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2696
2719
  const seriesData = pad(values[c]!)
2697
2720
  if (kind === "bar") {
2698
2721
  const rounded = horizontalBars ? [0, 4, 4, 0] : [4, 4, 0, 0]
2699
- // Stacked: only the topmost segment is rounded.
2700
- const radius = stacked && c !== categories.length - 1 ? 0 : rounded
2722
+ // Stacked: every segment is rounded, with a hairline of the surface colour between segments (the gap).
2723
+ const radius = stacked ? BAR_RADIUS : rounded
2724
+ const gapStyle = stacked && labelBackground ? { borderColor: labelBackground, borderWidth: STACK_GAP } : {}
2701
2725
  return {
2702
2726
  id: SERIES_ID_PREFIX + category,
2703
2727
  type: "bar" as const,
2704
2728
  name: category,
2705
2729
  stack: stacked ? "total" : undefined,
2706
2730
  barCategoryGap,
2707
- itemStyle: { color, borderRadius: radius },
2731
+ barMaxWidth: BAR_MAX_WIDTH,
2732
+ itemStyle: { color, borderRadius: radius, ...gapStyle },
2708
2733
  markPoint: markPointFor(c, solid),
2709
2734
  markLine: undefined,
2710
2735
  data: seriesData.map((value, i) => {
@@ -3000,6 +3025,14 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3000
3025
  },
3001
3026
  })
3002
3027
  }
3028
+ if (diverging) {
3029
+ markLines.push({
3030
+ name: "zero",
3031
+ [horizontalBars ? "xAxis" : "yAxis"]: 0,
3032
+ lineStyle: { type: "solid", width: 1, color: guideColor },
3033
+ label: { show: false },
3034
+ })
3035
+ }
3003
3036
  if (forecast) {
3004
3037
  const forecastSpan =
3005
3038
  plotWidth && labels.length > 1
@@ -3145,6 +3178,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3145
3178
  show: horizontalBars ? showYAxis : showXAxis,
3146
3179
  boundaryGap: kind === "bar",
3147
3180
  inverse: horizontalBars,
3181
+ // Diverging: the axis stays at the plot's edge (not at 0, where labels would hit the negative bars); the zero line is a mark line.
3182
+ axisLine: diverging ? { show: false, onZero: false } : undefined,
3148
3183
  axisTick: { show: false },
3149
3184
  axisLabel: {
3150
3185
  hideOverlap: true,
@@ -3247,7 +3282,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3247
3282
  // Narrow: left-aligned and wrapping above the plot, so nothing is cut off at the right edge.
3248
3283
  ...(plotWidth !== undefined && plotWidth < NARROW_PLOT_WIDTH
3249
3284
  ? { left: 0, right: 0 }
3250
- : { right: reserveActions ? ACTIONS_RESERVE : 0 }),
3285
+ : { left: 0, right: reserveActions ? ACTIONS_RESERVE : 0 }),
3251
3286
  // Plain wraps onto a second line at narrow widths instead of hiding items behind arrows.
3252
3287
  type: "plain",
3253
3288
  icon: "circle",
@@ -3768,6 +3803,10 @@ export interface LegendProps {
3768
3803
  labels?: Record<string, string>
3769
3804
  /** 並べ方。`"horizontal"`(既定。折り返す横並び)か `"vertical"`(縦並び)。 */
3770
3805
  layout?: "horizontal" | "vertical"
3806
+ /** 隠している系列名(`onHiddenChange` と対)。グラフに渡す `categories` からこの系列を外す。色は凡例の全 `categories` で決まるので、外しても残りの色は変わらない。 */
3807
+ hidden?: string[]
3808
+ /** 渡すと凡例のチップが押せるボタンになり、押すたびに系列を隠す・戻す(キーボードで操作でき、隠れたチップは薄くなる)。新しい `hidden` が渡される。 */
3809
+ onHiddenChange?: (hidden: string[]) => void
3771
3810
  /** 凡例の読み上げ名。省略時は「凡例」。 */
3772
3811
  ariaLabel?: string
3773
3812
  className?: string
@@ -3791,7 +3830,7 @@ const TEXT = {
3791
3830
 
3792
3831
  /** Plain-HTML legend. Renders nothing for an empty `categories`. */
3793
3832
  export const Legend = forwardRef<HTMLUListElement, LegendProps>(function Legend(
3794
- { categories, colors, labels, layout = "horizontal", ariaLabel, className },
3833
+ { categories, colors, labels, layout = "horizontal", hidden = [], onHiddenChange, ariaLabel, className },
3795
3834
  ref,
3796
3835
  ) {
3797
3836
  const t = TEXT[uiLanguage(useLocale())]
@@ -3805,12 +3844,31 @@ export const Legend = forwardRef<HTMLUListElement, LegendProps>(function Legend(
3805
3844
  aria-label={ariaLabel ?? t.legend}
3806
3845
  className={cn("m-0 flex list-none gap-x-4 gap-y-1 p-0 text-xs", layout === "vertical" ? "flex-col" : "flex-row flex-wrap", className)}
3807
3846
  >
3808
- {categories.map((category, i) => (
3809
- <li key={category} data-slot="legend-item" className="flex min-w-0 items-center gap-1.5">
3810
- <span aria-hidden="true" className="size-2.5 shrink-0 rounded-[2px]" style={{ background: swatches[i] }} />
3811
- <span className="min-w-0 truncate text-muted-foreground">{labelOf(category, labels)}</span>
3812
- </li>
3813
- ))}
3847
+ {categories.map((category, i) => {
3848
+ const off = hidden.includes(category)
3849
+ const chip = (
3850
+ <>
3851
+ <span aria-hidden="true" className={cn("size-2.5 shrink-0 rounded-[2px]", off && "opacity-30")} style={{ background: swatches[i] }} />
3852
+ <span className={cn("min-w-0 truncate text-muted-foreground", off && "line-through opacity-60")}>{labelOf(category, labels)}</span>
3853
+ </>
3854
+ )
3855
+ return (
3856
+ <li key={category} data-slot="legend-item" data-hidden={off || undefined} className="flex min-w-0 items-center gap-1.5">
3857
+ {onHiddenChange ? (
3858
+ <button
3859
+ type="button"
3860
+ aria-pressed={!off}
3861
+ onClick={() => onHiddenChange(off ? hidden.filter((c) => c !== category) : [...hidden, category])}
3862
+ className="flex min-w-0 cursor-pointer items-center gap-1.5 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
3863
+ >
3864
+ {chip}
3865
+ </button>
3866
+ ) : (
3867
+ chip
3868
+ )}
3869
+ </li>
3870
+ )
3871
+ })}
3814
3872
  </ul>
3815
3873
  )
3816
3874
  })