@squadbase/vantage 0.7.1-dev1.01a6413 → 0.7.1-dev1.0d13f9a

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 (58) hide show
  1. package/dist/{chunk-MQUIAP66.js → chunk-5HBUBVQ3.js} +28 -7
  2. package/dist/chunk-5HBUBVQ3.js.map +1 -0
  3. package/dist/{chunk-EG4PX726.js → chunk-GWXKENNH.js} +122 -5
  4. package/dist/chunk-GWXKENNH.js.map +1 -0
  5. package/dist/{chunk-YJQCB3UP.js → chunk-I2GWIPTD.js} +219 -67
  6. package/dist/chunk-I2GWIPTD.js.map +1 -0
  7. package/dist/{chunk-P2KIHF65.js → chunk-U6MWBCM6.js} +28 -7
  8. package/dist/chunk-U6MWBCM6.js.map +1 -0
  9. package/dist/components/index.d.ts +223 -6
  10. package/dist/components/index.js +4 -4
  11. package/dist/doc/index.js +1 -1
  12. package/dist/slide/index.js +1 -1
  13. package/dist/table/index.js +4 -4
  14. package/dist/ui/index.d.ts +35 -4
  15. package/dist/ui/index.js +174 -10
  16. package/dist/ui/index.js.map +1 -1
  17. package/package.json +2 -2
  18. package/registry/parts.json +101 -3
  19. package/registry/ui/area-chart.tsx +43 -8
  20. package/registry/ui/bar-chart.tsx +43 -8
  21. package/registry/ui/bar-list.tsx +43 -8
  22. package/registry/ui/box-plot-chart.tsx +42 -7
  23. package/registry/ui/category-bar.tsx +25 -5
  24. package/registry/ui/chart-frame.tsx +47 -14
  25. package/registry/ui/checkbox.tsx +4 -3
  26. package/registry/ui/code-block.tsx +44 -0
  27. package/registry/ui/combo-chart.tsx +20 -3
  28. package/registry/ui/combobox.tsx +7 -0
  29. package/registry/ui/context-menu.tsx +71 -0
  30. package/registry/ui/dashboard-card.tsx +20 -9
  31. package/registry/ui/data-table.tsx +7 -1
  32. package/registry/ui/date-range-picker.tsx +51 -4
  33. package/registry/ui/delta.tsx +36 -11
  34. package/registry/ui/donut-chart.tsx +43 -8
  35. package/registry/ui/echart.tsx +167 -7
  36. package/registry/ui/file-dropzone.tsx +143 -0
  37. package/registry/ui/filter-bar.tsx +74 -1
  38. package/registry/ui/forecast-chart.tsx +42 -7
  39. package/registry/ui/funnel-steps.tsx +22 -4
  40. package/registry/ui/heatmap.tsx +20 -3
  41. package/registry/ui/histogram-chart.tsx +42 -7
  42. package/registry/ui/kpi-card.tsx +12 -8
  43. package/registry/ui/legend.tsx +73 -15
  44. package/registry/ui/line-chart.tsx +43 -8
  45. package/registry/ui/page-shell.tsx +2 -0
  46. package/registry/ui/paired-chart.tsx +43 -8
  47. package/registry/ui/progress.tsx +1 -1
  48. package/registry/ui/scatter-chart.tsx +50 -12
  49. package/registry/ui/sparkline.tsx +43 -8
  50. package/registry/ui/states.tsx +34 -1
  51. package/registry/ui/status-badge.tsx +7 -7
  52. package/registry/ui/tabs.tsx +1 -0
  53. package/registry/ui/treemap.tsx +20 -3
  54. package/registry/ui/waterfall-chart.tsx +64 -11
  55. package/dist/chunk-EG4PX726.js.map +0 -1
  56. package/dist/chunk-MQUIAP66.js.map +0 -1
  57. package/dist/chunk-P2KIHF65.js.map +0 -1
  58. package/dist/chunk-YJQCB3UP.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@squadbase/vantage",
3
- "version": "0.7.1-dev1.01a6413",
3
+ "version": "0.7.1-dev1.0d13f9a",
4
4
  "description": "Data dashboard framework for Squadbase Editor. Vite-powered, config-free, one-file first.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -152,7 +152,7 @@
152
152
  "vitest": "^5.0.5",
153
153
  "@squadbase/squadkit": "0.0.0"
154
154
  },
155
- "gitHead": "01a6413cae8e306d317219583aeca54b09ff1576",
155
+ "gitHead": "0d13f9a25b7bd5007320dfadec959dc531180e2d",
156
156
  "scripts": {
157
157
  "build": "node scripts/build-docs.mjs && node scripts/build.mjs",
158
158
  "build:js": "tsup --no-dts",
@@ -2130,10 +2130,10 @@
2130
2130
  },
2131
2131
  {
2132
2132
  "name": "color",
2133
- "type": "string | undefined",
2133
+ "type": "ChartColor | undefined",
2134
2134
  "required": false,
2135
2135
  "json": true,
2136
- "doc": "Tailwind text-colour class; drives both stroke and fill via `currentColor`."
2136
+ "doc": "Line / bar colour: a theme series token (`\"chart-1\"`〜`\"chart-5\"`), a meaning colour (`\"positive\"` / `\"negative\"` / `\"warning\"` / `\"neutral\"` / `\"info\"`), or any CSS colour (`\"#16a34a\"`, `\"var(--destr…"
2137
2137
  },
2138
2138
  {
2139
2139
  "name": "area",
@@ -4830,6 +4830,17 @@
4830
4830
  "json": true,
4831
4831
  "doc": "年度の開始月(1-12)。"
4832
4832
  },
4833
+ {
4834
+ "name": "comparison",
4835
+ "type": "\"previous-period\" | \"previous-year\" | undefined",
4836
+ "required": false,
4837
+ "json": true,
4838
+ "values": [
4839
+ "previous-period",
4840
+ "previous-year"
4841
+ ],
4842
+ "doc": "比較期間。"
4843
+ },
4833
4844
  {
4834
4845
  "name": "maxDate",
4835
4846
  "type": "Date | undefined",
@@ -5134,6 +5145,13 @@
5134
5145
  "required": false,
5135
5146
  "json": true
5136
5147
  },
5148
+ {
5149
+ "name": "headline",
5150
+ "type": "ReactNode",
5151
+ "required": false,
5152
+ "json": true,
5153
+ "doc": "One headline number or status at the right end of the header (\"212 tasks\", a `StatusBadge`)."
5154
+ },
5137
5155
  {
5138
5156
  "name": "actions",
5139
5157
  "type": "ReactNode",
@@ -5144,7 +5162,8 @@
5144
5162
  "name": "footer",
5145
5163
  "type": "ReactNode",
5146
5164
  "required": false,
5147
- "json": true
5165
+ "json": true,
5166
+ "doc": "One line at the bottom of the card: a summary fact on the left, an action or hint on the right."
5148
5167
  },
5149
5168
  {
5150
5169
  "name": "headerClassName",
@@ -5216,6 +5235,85 @@
5216
5235
  "json": true
5217
5236
  }
5218
5237
  ]
5238
+ },
5239
+ {
5240
+ "name": "PageShellHeader",
5241
+ "module": "components/page-shell",
5242
+ "doc": "PageShellHeader — the page header band (title and update time, source / period, overall status); inside `PageShell` without `title`, with the title in `PageShellHeading`.",
5243
+ "props": [
5244
+ {
5245
+ "name": "updatedAt",
5246
+ "type": "string | number | Date | undefined",
5247
+ "required": false,
5248
+ "json": true,
5249
+ "doc": "When the data was last updated."
5250
+ },
5251
+ {
5252
+ "name": "updatedAtVariant",
5253
+ "type": "\"relative\" | \"absolute-first\" | undefined",
5254
+ "required": false,
5255
+ "json": true,
5256
+ "values": [
5257
+ "relative",
5258
+ "absolute-first"
5259
+ ],
5260
+ "doc": "`\"relative\"` (default) or `\"absolute-first\"` (\"16:41 更新(23 分前)\") for pages where \"now\" matters."
5261
+ },
5262
+ {
5263
+ "name": "timeZone",
5264
+ "type": "string | undefined",
5265
+ "required": false,
5266
+ "json": true,
5267
+ "doc": "IANA time zone for the absolute time of `updatedAt`."
5268
+ },
5269
+ {
5270
+ "name": "source",
5271
+ "type": "string | undefined",
5272
+ "required": false,
5273
+ "json": true,
5274
+ "doc": "Where the data comes from."
5275
+ },
5276
+ {
5277
+ "name": "period",
5278
+ "type": "{ start: string | number | Date; end: string | number | Date; } | undefined",
5279
+ "required": false,
5280
+ "json": true,
5281
+ "doc": "The period the data covers."
5282
+ },
5283
+ {
5284
+ "name": "status",
5285
+ "type": "ReactNode",
5286
+ "required": false,
5287
+ "json": true,
5288
+ "doc": "The page's overall state (an alert, a status badge)."
5289
+ },
5290
+ {
5291
+ "name": "children",
5292
+ "type": "ReactNode",
5293
+ "required": false,
5294
+ "json": true
5295
+ }
5296
+ ]
5297
+ },
5298
+ {
5299
+ "name": "SampleData",
5300
+ "module": "components/sample-data",
5301
+ "doc": "SampleData — marks the values inside it as a sample: they are not connected to real data yet (a draft laid out before its queries are wired, a screen built from column definitions only).",
5302
+ "props": [
5303
+ {
5304
+ "name": "label",
5305
+ "type": "string | undefined",
5306
+ "required": false,
5307
+ "json": true,
5308
+ "doc": "印の文言。"
5309
+ },
5310
+ {
5311
+ "name": "children",
5312
+ "type": "ReactNode",
5313
+ "required": false,
5314
+ "json": true
5315
+ }
5316
+ ]
5219
5317
  }
5220
5318
  ]
5221
5319
  }
@@ -170,10 +170,25 @@ function useLocale(locale?: string | null): string {
170
170
  const CHART_OTHER_COLOR = "color-mix(in oklab, var(--neutral) 50%, var(--background))"
171
171
 
172
172
  /**
173
- * True inside `ChartFrame`. The frame's plot area carries the chart's single
174
- * `role="img"` and label, so `EChart` drops its own when it sees this.
175
- */
176
- const ChartFrameContext = createContext(false)
173
+ * What a `ChartFrame` tells the plot inside it. A frame whose plot is an image
174
+ * (`image`) puts `role="img"` on its plot area, unless an `EChart` inside it
175
+ * `claim`s the image: then the canvas alone carries `role="img"` with this name
176
+ * and description (`claimed`), so the action buttons beside the canvas are not
177
+ * inside the image (an interactive control inside `role="img"` is a
178
+ * nested-interactive violation and is not exposed). A `group` frame never
179
+ * hands the role to the canvas. `false` outside a frame.
180
+ */
181
+ interface ChartFramePlot {
182
+ image: boolean
183
+ /** An `EChart` inside has taken over the `role="img"`. */
184
+ claimed: boolean
185
+ /** Take over the image role; returns the release. */
186
+ claim: () => () => void
187
+ ariaLabel?: string
188
+ ariaLabelledBy?: string
189
+ ariaDescribedBy?: string
190
+ }
191
+ const ChartFrameContext = createContext<ChartFramePlot | false>(false)
177
192
 
178
193
  /** Series palette, in order. Missing tokens are skipped, not defaulted. */
179
194
  const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const
@@ -397,6 +412,8 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
397
412
  tooltip: {
398
413
  // ECharts' default 0.4s transform lags the tooltip ~300ms behind the cursor.
399
414
  transitionDuration: 0,
415
+ // Stay inside the chart: cards are often narrow and overflow-hidden.
416
+ confine: true,
400
417
  backgroundColor: tokens.popover ?? undefined,
401
418
  borderColor: line,
402
419
  borderWidth: 1,
@@ -2272,6 +2289,10 @@ function legendLines(items: readonly string[], plotWidth?: number, reserve = 0):
2272
2289
 
2273
2290
  /** Width (px) kept free at the legend's right for the PNG action buttons (two 32px buttons + gaps). */
2274
2291
  const ACTIONS_RESERVE = 80
2292
+ /** Bars stay slim on wide cards (px); stacked segments round all corners and are parted by a hairline of the surface colour (px). */
2293
+ const BAR_MAX_WIDTH = 40
2294
+ const BAR_RADIUS = 3
2295
+ const STACK_GAP = 1.5
2275
2296
 
2276
2297
  /** Room (px) one time label (`2026年1月`) needs, gap included. */
2277
2298
  const TIME_LABEL_WIDTH = 80
@@ -2485,6 +2506,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2485
2506
  const compared = cartesianSeriesValues(data, [categories[compIdx]!], "default")[0]!
2486
2507
  return categories.map((_, i) => (i === compIdx ? compared : main[mainIdx.indexOf(i)]!))
2487
2508
  })()
2509
+ // Any negative bar: the part lays out the diverging view itself (a solid zero line, category labels kept at the axis end).
2510
+ const diverging = kind === "bar" && values.some((s) => s.some((v) => typeof v === "number" && v < 0))
2488
2511
  const comparisonColor = guideColor ? withAlpha(guideColor, COMPARISON_OPACITY) : undefined
2489
2512
 
2490
2513
  /* -- x labels: the actual rows, then the forecast rows that extend them */
@@ -2694,15 +2717,17 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2694
2717
  const seriesData = pad(values[c]!)
2695
2718
  if (kind === "bar") {
2696
2719
  const rounded = horizontalBars ? [0, 4, 4, 0] : [4, 4, 0, 0]
2697
- // Stacked: only the topmost segment is rounded.
2698
- const radius = stacked && c !== categories.length - 1 ? 0 : rounded
2720
+ // Stacked: every segment is rounded, with a hairline of the surface colour between segments (the gap).
2721
+ const radius = stacked ? BAR_RADIUS : rounded
2722
+ const gapStyle = stacked && labelBackground ? { borderColor: labelBackground, borderWidth: STACK_GAP } : {}
2699
2723
  return {
2700
2724
  id: SERIES_ID_PREFIX + category,
2701
2725
  type: "bar" as const,
2702
2726
  name: category,
2703
2727
  stack: stacked ? "total" : undefined,
2704
2728
  barCategoryGap,
2705
- itemStyle: { color, borderRadius: radius },
2729
+ barMaxWidth: BAR_MAX_WIDTH,
2730
+ itemStyle: { color, borderRadius: radius, ...gapStyle },
2706
2731
  markPoint: markPointFor(c, solid),
2707
2732
  markLine: undefined,
2708
2733
  data: seriesData.map((value, i) => {
@@ -2998,6 +3023,14 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2998
3023
  },
2999
3024
  })
3000
3025
  }
3026
+ if (diverging) {
3027
+ markLines.push({
3028
+ name: "zero",
3029
+ [horizontalBars ? "xAxis" : "yAxis"]: 0,
3030
+ lineStyle: { type: "solid", width: 1, color: guideColor },
3031
+ label: { show: false },
3032
+ })
3033
+ }
3001
3034
  if (forecast) {
3002
3035
  const forecastSpan =
3003
3036
  plotWidth && labels.length > 1
@@ -3143,6 +3176,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3143
3176
  show: horizontalBars ? showYAxis : showXAxis,
3144
3177
  boundaryGap: kind === "bar",
3145
3178
  inverse: horizontalBars,
3179
+ // 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.
3180
+ axisLine: diverging ? { show: false, onZero: false } : undefined,
3146
3181
  axisTick: { show: false },
3147
3182
  axisLabel: {
3148
3183
  hideOverlap: true,
@@ -3245,7 +3280,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3245
3280
  // Narrow: left-aligned and wrapping above the plot, so nothing is cut off at the right edge.
3246
3281
  ...(plotWidth !== undefined && plotWidth < NARROW_PLOT_WIDTH
3247
3282
  ? { left: 0, right: 0 }
3248
- : { right: reserveActions ? ACTIONS_RESERVE : 0 }),
3283
+ : { left: 0, right: reserveActions ? ACTIONS_RESERVE : 0 }),
3249
3284
  // Plain wraps onto a second line at narrow widths instead of hiding items behind arrows.
3250
3285
  type: "plain",
3251
3286
  icon: "circle",
@@ -7,7 +7,7 @@
7
7
  * (`index`) and the series keys (`categories`) instead of an ECharts option.
8
8
  * Grouped, stacked or 100%-stacked; vertical or horizontal bars. Colours are the
9
9
  * `--chart-1..5` tokens in fixed order (more than 5 series fold into「その他」)
10
- * and follow light/dark. The value axis always starts at 0. A horizontal bar chart of one
10
+ * and follow light/dark. The value axis always reaches 0 (negative values add a solid zero line on their own). A horizontal bar chart of one
11
11
  * series (`barOptions.layout: "vertical"`) is a light tint by default, like BarList; `barOptions.highlight`
12
12
  * names the one bar the page is about, drawn in the full colour.
13
13
  * Renders inside `ChartFrame` (title, summary, loading skeleton, empty state).
@@ -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,
@@ -2275,6 +2292,10 @@ function legendLines(items: readonly string[], plotWidth?: number, reserve = 0):
2275
2292
 
2276
2293
  /** Width (px) kept free at the legend's right for the PNG action buttons (two 32px buttons + gaps). */
2277
2294
  const ACTIONS_RESERVE = 80
2295
+ /** Bars stay slim on wide cards (px); stacked segments round all corners and are parted by a hairline of the surface colour (px). */
2296
+ const BAR_MAX_WIDTH = 40
2297
+ const BAR_RADIUS = 3
2298
+ const STACK_GAP = 1.5
2278
2299
 
2279
2300
  /** Room (px) one time label (`2026年1月`) needs, gap included. */
2280
2301
  const TIME_LABEL_WIDTH = 80
@@ -2488,6 +2509,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2488
2509
  const compared = cartesianSeriesValues(data, [categories[compIdx]!], "default")[0]!
2489
2510
  return categories.map((_, i) => (i === compIdx ? compared : main[mainIdx.indexOf(i)]!))
2490
2511
  })()
2512
+ // Any negative bar: the part lays out the diverging view itself (a solid zero line, category labels kept at the axis end).
2513
+ const diverging = kind === "bar" && values.some((s) => s.some((v) => typeof v === "number" && v < 0))
2491
2514
  const comparisonColor = guideColor ? withAlpha(guideColor, COMPARISON_OPACITY) : undefined
2492
2515
 
2493
2516
  /* -- x labels: the actual rows, then the forecast rows that extend them */
@@ -2697,15 +2720,17 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2697
2720
  const seriesData = pad(values[c]!)
2698
2721
  if (kind === "bar") {
2699
2722
  const rounded = horizontalBars ? [0, 4, 4, 0] : [4, 4, 0, 0]
2700
- // Stacked: only the topmost segment is rounded.
2701
- const radius = stacked && c !== categories.length - 1 ? 0 : rounded
2723
+ // Stacked: every segment is rounded, with a hairline of the surface colour between segments (the gap).
2724
+ const radius = stacked ? BAR_RADIUS : rounded
2725
+ const gapStyle = stacked && labelBackground ? { borderColor: labelBackground, borderWidth: STACK_GAP } : {}
2702
2726
  return {
2703
2727
  id: SERIES_ID_PREFIX + category,
2704
2728
  type: "bar" as const,
2705
2729
  name: category,
2706
2730
  stack: stacked ? "total" : undefined,
2707
2731
  barCategoryGap,
2708
- itemStyle: { color, borderRadius: radius },
2732
+ barMaxWidth: BAR_MAX_WIDTH,
2733
+ itemStyle: { color, borderRadius: radius, ...gapStyle },
2709
2734
  markPoint: markPointFor(c, solid),
2710
2735
  markLine: undefined,
2711
2736
  data: seriesData.map((value, i) => {
@@ -3001,6 +3026,14 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3001
3026
  },
3002
3027
  })
3003
3028
  }
3029
+ if (diverging) {
3030
+ markLines.push({
3031
+ name: "zero",
3032
+ [horizontalBars ? "xAxis" : "yAxis"]: 0,
3033
+ lineStyle: { type: "solid", width: 1, color: guideColor },
3034
+ label: { show: false },
3035
+ })
3036
+ }
3004
3037
  if (forecast) {
3005
3038
  const forecastSpan =
3006
3039
  plotWidth && labels.length > 1
@@ -3146,6 +3179,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3146
3179
  show: horizontalBars ? showYAxis : showXAxis,
3147
3180
  boundaryGap: kind === "bar",
3148
3181
  inverse: horizontalBars,
3182
+ // 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.
3183
+ axisLine: diverging ? { show: false, onZero: false } : undefined,
3149
3184
  axisTick: { show: false },
3150
3185
  axisLabel: {
3151
3186
  hideOverlap: true,
@@ -3248,7 +3283,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3248
3283
  // Narrow: left-aligned and wrapping above the plot, so nothing is cut off at the right edge.
3249
3284
  ...(plotWidth !== undefined && plotWidth < NARROW_PLOT_WIDTH
3250
3285
  ? { left: 0, right: 0 }
3251
- : { right: reserveActions ? ACTIONS_RESERVE : 0 }),
3286
+ : { left: 0, right: reserveActions ? ACTIONS_RESERVE : 0 }),
3252
3287
  // Plain wraps onto a second line at narrow widths instead of hiding items behind arrows.
3253
3288
  type: "plain",
3254
3289
  icon: "circle",
@@ -186,10 +186,25 @@ function useLocale(locale?: string | null): string {
186
186
  const CHART_OTHER_COLOR = "color-mix(in oklab, var(--neutral) 50%, var(--background))"
187
187
 
188
188
  /**
189
- * True inside `ChartFrame`. The frame's plot area carries the chart's single
190
- * `role="img"` and label, so `EChart` drops its own when it sees this.
191
- */
192
- const ChartFrameContext = createContext(false)
189
+ * What a `ChartFrame` tells the plot inside it. A frame whose plot is an image
190
+ * (`image`) puts `role="img"` on its plot area, unless an `EChart` inside it
191
+ * `claim`s the image: then the canvas alone carries `role="img"` with this name
192
+ * and description (`claimed`), so the action buttons beside the canvas are not
193
+ * inside the image (an interactive control inside `role="img"` is a
194
+ * nested-interactive violation and is not exposed). A `group` frame never
195
+ * hands the role to the canvas. `false` outside a frame.
196
+ */
197
+ interface ChartFramePlot {
198
+ image: boolean
199
+ /** An `EChart` inside has taken over the `role="img"`. */
200
+ claimed: boolean
201
+ /** Take over the image role; returns the release. */
202
+ claim: () => () => void
203
+ ariaLabel?: string
204
+ ariaLabelledBy?: string
205
+ ariaDescribedBy?: string
206
+ }
207
+ const ChartFrameContext = createContext<ChartFramePlot | false>(false)
193
208
 
194
209
  /** Series palette, in order. Missing tokens are skipped, not defaulted. */
195
210
  const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const
@@ -413,6 +428,8 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
413
428
  tooltip: {
414
429
  // ECharts' default 0.4s transform lags the tooltip ~300ms behind the cursor.
415
430
  transitionDuration: 0,
431
+ // Stay inside the chart: cards are often narrow and overflow-hidden.
432
+ confine: true,
416
433
  backgroundColor: tokens.popover ?? undefined,
417
434
  borderColor: line,
418
435
  borderWidth: 1,
@@ -2288,6 +2305,10 @@ function legendLines(items: readonly string[], plotWidth?: number, reserve = 0):
2288
2305
 
2289
2306
  /** Width (px) kept free at the legend's right for the PNG action buttons (two 32px buttons + gaps). */
2290
2307
  const ACTIONS_RESERVE = 80
2308
+ /** Bars stay slim on wide cards (px); stacked segments round all corners and are parted by a hairline of the surface colour (px). */
2309
+ const BAR_MAX_WIDTH = 40
2310
+ const BAR_RADIUS = 3
2311
+ const STACK_GAP = 1.5
2291
2312
 
2292
2313
  /** Room (px) one time label (`2026年1月`) needs, gap included. */
2293
2314
  const TIME_LABEL_WIDTH = 80
@@ -2501,6 +2522,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2501
2522
  const compared = cartesianSeriesValues(data, [categories[compIdx]!], "default")[0]!
2502
2523
  return categories.map((_, i) => (i === compIdx ? compared : main[mainIdx.indexOf(i)]!))
2503
2524
  })()
2525
+ // Any negative bar: the part lays out the diverging view itself (a solid zero line, category labels kept at the axis end).
2526
+ const diverging = kind === "bar" && values.some((s) => s.some((v) => typeof v === "number" && v < 0))
2504
2527
  const comparisonColor = guideColor ? withAlpha(guideColor, COMPARISON_OPACITY) : undefined
2505
2528
 
2506
2529
  /* -- x labels: the actual rows, then the forecast rows that extend them */
@@ -2710,15 +2733,17 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2710
2733
  const seriesData = pad(values[c]!)
2711
2734
  if (kind === "bar") {
2712
2735
  const rounded = horizontalBars ? [0, 4, 4, 0] : [4, 4, 0, 0]
2713
- // Stacked: only the topmost segment is rounded.
2714
- const radius = stacked && c !== categories.length - 1 ? 0 : rounded
2736
+ // Stacked: every segment is rounded, with a hairline of the surface colour between segments (the gap).
2737
+ const radius = stacked ? BAR_RADIUS : rounded
2738
+ const gapStyle = stacked && labelBackground ? { borderColor: labelBackground, borderWidth: STACK_GAP } : {}
2715
2739
  return {
2716
2740
  id: SERIES_ID_PREFIX + category,
2717
2741
  type: "bar" as const,
2718
2742
  name: category,
2719
2743
  stack: stacked ? "total" : undefined,
2720
2744
  barCategoryGap,
2721
- itemStyle: { color, borderRadius: radius },
2745
+ barMaxWidth: BAR_MAX_WIDTH,
2746
+ itemStyle: { color, borderRadius: radius, ...gapStyle },
2722
2747
  markPoint: markPointFor(c, solid),
2723
2748
  markLine: undefined,
2724
2749
  data: seriesData.map((value, i) => {
@@ -3014,6 +3039,14 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3014
3039
  },
3015
3040
  })
3016
3041
  }
3042
+ if (diverging) {
3043
+ markLines.push({
3044
+ name: "zero",
3045
+ [horizontalBars ? "xAxis" : "yAxis"]: 0,
3046
+ lineStyle: { type: "solid", width: 1, color: guideColor },
3047
+ label: { show: false },
3048
+ })
3049
+ }
3017
3050
  if (forecast) {
3018
3051
  const forecastSpan =
3019
3052
  plotWidth && labels.length > 1
@@ -3159,6 +3192,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3159
3192
  show: horizontalBars ? showYAxis : showXAxis,
3160
3193
  boundaryGap: kind === "bar",
3161
3194
  inverse: horizontalBars,
3195
+ // 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.
3196
+ axisLine: diverging ? { show: false, onZero: false } : undefined,
3162
3197
  axisTick: { show: false },
3163
3198
  axisLabel: {
3164
3199
  hideOverlap: true,
@@ -3261,7 +3296,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3261
3296
  // Narrow: left-aligned and wrapping above the plot, so nothing is cut off at the right edge.
3262
3297
  ...(plotWidth !== undefined && plotWidth < NARROW_PLOT_WIDTH
3263
3298
  ? { left: 0, right: 0 }
3264
- : { right: reserveActions ? ACTIONS_RESERVE : 0 }),
3299
+ : { left: 0, right: reserveActions ? ACTIONS_RESERVE : 0 }),
3265
3300
  // Plain wraps onto a second line at narrow widths instead of hiding items behind arrows.
3266
3301
  type: "plain",
3267
3302
  icon: "circle",
@@ -174,10 +174,25 @@ function useLocale(locale?: string | null): string {
174
174
  const CHART_OTHER_COLOR = "color-mix(in oklab, var(--neutral) 50%, var(--background))"
175
175
 
176
176
  /**
177
- * True inside `ChartFrame`. The frame's plot area carries the chart's single
178
- * `role="img"` and label, so `EChart` drops its own when it sees this.
177
+ * What a `ChartFrame` tells the plot inside it. A frame whose plot is an image
178
+ * (`image`) puts `role="img"` on its plot area, unless an `EChart` inside it
179
+ * `claim`s the image: then the canvas alone carries `role="img"` with this name
180
+ * and description (`claimed`), so the action buttons beside the canvas are not
181
+ * inside the image (an interactive control inside `role="img"` is a
182
+ * nested-interactive violation and is not exposed). A `group` frame never
183
+ * hands the role to the canvas. `false` outside a frame.
179
184
  */
180
- const ChartFrameContext = createContext(false)
185
+ interface ChartFramePlot {
186
+ image: boolean
187
+ /** An `EChart` inside has taken over the `role="img"`. */
188
+ claimed: boolean
189
+ /** Take over the image role; returns the release. */
190
+ claim: () => () => void
191
+ ariaLabel?: string
192
+ ariaLabelledBy?: string
193
+ ariaDescribedBy?: string
194
+ }
195
+ const ChartFrameContext = createContext<ChartFramePlot | false>(false)
181
196
 
182
197
  /** Series palette, in order. Missing tokens are skipped, not defaulted. */
183
198
  const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const
@@ -401,6 +416,8 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
401
416
  tooltip: {
402
417
  // ECharts' default 0.4s transform lags the tooltip ~300ms behind the cursor.
403
418
  transitionDuration: 0,
419
+ // Stay inside the chart: cards are often narrow and overflow-hidden.
420
+ confine: true,
404
421
  backgroundColor: tokens.popover ?? undefined,
405
422
  borderColor: line,
406
423
  borderWidth: 1,
@@ -1437,6 +1454,10 @@ function legendLines(items: readonly string[], plotWidth?: number, reserve = 0):
1437
1454
 
1438
1455
  /** Width (px) kept free at the legend's right for the PNG action buttons (two 32px buttons + gaps). */
1439
1456
  const ACTIONS_RESERVE = 80
1457
+ /** Bars stay slim on wide cards (px); stacked segments round all corners and are parted by a hairline of the surface colour (px). */
1458
+ const BAR_MAX_WIDTH = 40
1459
+ const BAR_RADIUS = 3
1460
+ const STACK_GAP = 1.5
1440
1461
 
1441
1462
  /** Room (px) one time label (`2026年1月`) needs, gap included. */
1442
1463
  const TIME_LABEL_WIDTH = 80
@@ -1650,6 +1671,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1650
1671
  const compared = cartesianSeriesValues(data, [categories[compIdx]!], "default")[0]!
1651
1672
  return categories.map((_, i) => (i === compIdx ? compared : main[mainIdx.indexOf(i)]!))
1652
1673
  })()
1674
+ // Any negative bar: the part lays out the diverging view itself (a solid zero line, category labels kept at the axis end).
1675
+ const diverging = kind === "bar" && values.some((s) => s.some((v) => typeof v === "number" && v < 0))
1653
1676
  const comparisonColor = guideColor ? withAlpha(guideColor, COMPARISON_OPACITY) : undefined
1654
1677
 
1655
1678
  /* -- x labels: the actual rows, then the forecast rows that extend them */
@@ -1859,15 +1882,17 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1859
1882
  const seriesData = pad(values[c]!)
1860
1883
  if (kind === "bar") {
1861
1884
  const rounded = horizontalBars ? [0, 4, 4, 0] : [4, 4, 0, 0]
1862
- // Stacked: only the topmost segment is rounded.
1863
- const radius = stacked && c !== categories.length - 1 ? 0 : rounded
1885
+ // Stacked: every segment is rounded, with a hairline of the surface colour between segments (the gap).
1886
+ const radius = stacked ? BAR_RADIUS : rounded
1887
+ const gapStyle = stacked && labelBackground ? { borderColor: labelBackground, borderWidth: STACK_GAP } : {}
1864
1888
  return {
1865
1889
  id: SERIES_ID_PREFIX + category,
1866
1890
  type: "bar" as const,
1867
1891
  name: category,
1868
1892
  stack: stacked ? "total" : undefined,
1869
1893
  barCategoryGap,
1870
- itemStyle: { color, borderRadius: radius },
1894
+ barMaxWidth: BAR_MAX_WIDTH,
1895
+ itemStyle: { color, borderRadius: radius, ...gapStyle },
1871
1896
  markPoint: markPointFor(c, solid),
1872
1897
  markLine: undefined,
1873
1898
  data: seriesData.map((value, i) => {
@@ -2163,6 +2188,14 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2163
2188
  },
2164
2189
  })
2165
2190
  }
2191
+ if (diverging) {
2192
+ markLines.push({
2193
+ name: "zero",
2194
+ [horizontalBars ? "xAxis" : "yAxis"]: 0,
2195
+ lineStyle: { type: "solid", width: 1, color: guideColor },
2196
+ label: { show: false },
2197
+ })
2198
+ }
2166
2199
  if (forecast) {
2167
2200
  const forecastSpan =
2168
2201
  plotWidth && labels.length > 1
@@ -2308,6 +2341,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2308
2341
  show: horizontalBars ? showYAxis : showXAxis,
2309
2342
  boundaryGap: kind === "bar",
2310
2343
  inverse: horizontalBars,
2344
+ // 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.
2345
+ axisLine: diverging ? { show: false, onZero: false } : undefined,
2311
2346
  axisTick: { show: false },
2312
2347
  axisLabel: {
2313
2348
  hideOverlap: true,
@@ -2410,7 +2445,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2410
2445
  // Narrow: left-aligned and wrapping above the plot, so nothing is cut off at the right edge.
2411
2446
  ...(plotWidth !== undefined && plotWidth < NARROW_PLOT_WIDTH
2412
2447
  ? { left: 0, right: 0 }
2413
- : { right: reserveActions ? ACTIONS_RESERVE : 0 }),
2448
+ : { left: 0, right: reserveActions ? ACTIONS_RESERVE : 0 }),
2414
2449
  // Plain wraps onto a second line at narrow widths instead of hiding items behind arrows.
2415
2450
  type: "plain",
2416
2451
  icon: "circle",