@flesler/cursor-canvas-web 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/cursor-canvas-web.css +1 -0
  2. package/dist/cursor-canvas.cjs +1 -1
  3. package/dist/cursor-canvas.js +530 -565
  4. package/dist/pie-donut-DNbA0GeH.cjs +1 -0
  5. package/dist/pie-donut-qRBFP0tT.js +63 -0
  6. package/package.json +6 -2
  7. package/src/runtime/index.tsx +64 -0
  8. package/src/shim/__tests__/barChart.test.ts +61 -0
  9. package/src/shim/__tests__/canvas.smoke.test.tsx +209 -0
  10. package/src/shim/__tests__/category-theme.test.tsx +58 -0
  11. package/src/shim/__tests__/dag.test.ts +49 -0
  12. package/src/shim/__tests__/host-tokens.test.ts +106 -0
  13. package/src/shim/__tests__/lineChart.test.ts +28 -0
  14. package/src/shim/__tests__/useCanvasState.test.tsx +35 -0
  15. package/src/shim/__tests__/useHostTheme.test.tsx +43 -0
  16. package/src/shim/__tests__/valueAxis.test.ts +39 -0
  17. package/src/shim/bar-chart.tsx +77 -0
  18. package/src/shim/canvas-hooks.tsx +57 -0
  19. package/src/shim/canvas-mantine-root.tsx +129 -0
  20. package/src/shim/chart-shared.tsx +252 -0
  21. package/src/shim/cursor-canvas.tsx +136 -0
  22. package/src/shim/dag.ts +179 -0
  23. package/src/shim/diff.tsx +161 -0
  24. package/src/shim/host-tokens.ts +614 -0
  25. package/src/shim/line-chart.tsx +62 -0
  26. package/src/shim/pie-chart.tsx +127 -0
  27. package/src/shim/pie-donut.tsx +79 -0
  28. package/src/shim/theme.ts +77 -0
  29. package/src/shim/todo.tsx +184 -0
  30. package/src/shim/ui/actions.tsx +58 -0
  31. package/src/shim/ui/card-context.tsx +11 -0
  32. package/src/shim/ui/card.tsx +79 -0
  33. package/src/shim/ui/chevron.tsx +23 -0
  34. package/src/shim/ui/display.tsx +83 -0
  35. package/src/shim/ui/form.tsx +102 -0
  36. package/src/shim/ui/layout.tsx +67 -0
  37. package/src/shim/ui/rich.tsx +136 -0
  38. package/src/shim/ui/table.tsx +71 -0
  39. package/src/shim/ui/typography.tsx +129 -0
  40. package/src/shim/use-tokens.ts +13 -0
  41. package/src/superset/chart-extra.d.ts +11 -11
  42. package/src/superset/index.d.ts +4 -4
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { act, renderHook } from '@testing-library/react'
3
+ import { useCanvasState } from 'cursor/canvas'
4
+
5
+ describe('useCanvasState', () => {
6
+ it('returns the initial value when storage is empty', () => {
7
+ const { result } = renderHook(() => useCanvasState('count', 1))
8
+ expect(result.current[0]).toBe(1)
9
+ })
10
+
11
+ it('persists updates to localStorage under the canvas namespace', () => {
12
+ const { result } = renderHook(() => useCanvasState('count', 1))
13
+
14
+ act(() => result.current[1](42))
15
+
16
+ expect(result.current[0]).toBe(42)
17
+ expect(localStorage.getItem('canvas:count')).toBe('42')
18
+ })
19
+
20
+ it('supports functional updates', () => {
21
+ const { result } = renderHook(() => useCanvasState('count', 1))
22
+
23
+ act(() => result.current[1](prev => prev + 9))
24
+
25
+ expect(result.current[0]).toBe(10)
26
+ })
27
+
28
+ it('rehydrates a previously stored value on mount', () => {
29
+ localStorage.setItem('canvas:flag', JSON.stringify(true))
30
+
31
+ const { result } = renderHook(() => useCanvasState('flag', false))
32
+
33
+ expect(result.current[0]).toBe(true)
34
+ })
35
+ })
@@ -0,0 +1,43 @@
1
+ import { renderHook } from '@testing-library/react'
2
+ import { useHostTheme } from 'cursor/canvas'
3
+ import type { ReactNode } from 'react'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { CanvasMantineRoot } from '../canvas-mantine-root'
6
+
7
+ function wrapper({ children }: { children: ReactNode }) {
8
+ return (
9
+ <CanvasMantineRoot defaultColorScheme="dark">{children}</CanvasMantineRoot>
10
+ )
11
+ }
12
+
13
+ describe('useHostTheme', () => {
14
+ it('exposes category, chart palette, fill, and kind (dark)', () => {
15
+ const { result } = renderHook(() => useHostTheme(), { wrapper })
16
+ const theme = result.current
17
+ expect(theme.kind).toBe('dark')
18
+ expect(theme.brand).toBe('cursor')
19
+ expect(theme.category.orange).toMatch(/^#/)
20
+ expect(theme.category.gray).toBe(theme.text.tertiary)
21
+ expect(theme.tokens.category.orange).toBe(theme.category.orange)
22
+ expect(theme.chart.palette.lightGreen).toBe('#52B896E0')
23
+ expect(theme.chart.sequence.length).toBeGreaterThan(0)
24
+ expect(theme.fill.secondary).toMatch(/^#/)
25
+ expect(theme.stroke.focused).toMatch(/^#/)
26
+ expect(theme.radius.md).toBe(6)
27
+ expect(theme.typography.body.fontSize).toBe('14px')
28
+ })
29
+
30
+ it('switches category table for light scheme', () => {
31
+ const lightWrap = ({ children }: { children: ReactNode }) => (
32
+ <CanvasMantineRoot defaultColorScheme="light">{children}</CanvasMantineRoot>
33
+ )
34
+ const dark = renderHook(() => useHostTheme(), { wrapper }).result.current
35
+ const light = renderHook(() => useHostTheme(), {
36
+ wrapper: lightWrap,
37
+ }).result.current
38
+ expect(light.kind).toBe('light')
39
+ expect(light.category.orange).toBe(light.tokens.category.orange)
40
+ expect(light.category.orange).not.toBe(dark.category.orange)
41
+ expect(light.category.gray).not.toBe(dark.category.gray)
42
+ })
43
+ })
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { valueAxisChartProps } from '../chart-shared'
3
+
4
+ describe('valueAxisChartProps', () => {
5
+ it('keeps the SDK zero-based domain without extra', () => {
6
+ expect(valueAxisChartProps({})).toEqual({
7
+ yAxisProps: { domain: [0, 'auto'] },
8
+ referenceLines: undefined,
9
+ })
10
+ })
11
+
12
+ it('merges extra.valueAxis over the SDK domain', () => {
13
+ const axis = valueAxisChartProps({ valueAxis: { tickCount: 3, domain: [5, 50] } })
14
+ expect(axis.yAxisProps).toEqual({ tickCount: 3, domain: [5, 50] })
15
+ })
16
+
17
+ it('defaults non-linear scales to a [1, dataMax] domain', () => {
18
+ const axis = valueAxisChartProps({ valueAxis: { scale: 'log' } })
19
+ expect(axis.yAxisProps).toEqual({
20
+ scale: 'log',
21
+ type: 'number',
22
+ allowDataOverflow: true,
23
+ allowDecimals: true,
24
+ domain: [1, 'dataMax'],
25
+ })
26
+ })
27
+
28
+ it('targets the X axis on horizontal bars', () => {
29
+ const axis = valueAxisChartProps({ horizontal: true, valueAxis: { unit: '%' } })
30
+ expect(axis.xAxisProps).toEqual({ domain: [0, 'auto'], unit: '%' })
31
+ expect(axis.yAxisProps).toBeUndefined()
32
+ })
33
+
34
+ it('applies extra on stacked charts, which have no SDK domain', () => {
35
+ expect(valueAxisChartProps({ stacked: true })).toEqual({ referenceLines: undefined })
36
+ const axis = valueAxisChartProps({ stacked: true, valueAxis: { tickCount: 4 } })
37
+ expect(axis.yAxisProps).toEqual({ tickCount: 4 })
38
+ })
39
+ })
@@ -0,0 +1,77 @@
1
+ import { BarChart as MantineBarChart } from '@mantine/charts'
2
+ import '@mantine/charts/styles.css'
3
+ import type { BarChartProps } from '../superset/index'
4
+ import { barValueLabelProps, CHART_LEGEND_PROPS, CHART_LEGEND_STYLES, defaultShowBarValues, horizontalBarProps, horizontalBarValueLabelProps, reshapeLineChartData, toMantineSeries, useChartColorProps, useSeriesColor, valueAxisChartProps, valueFormatter, verticalBarLabelProps, withPerCategoryBarColors } from './chart-shared'
5
+
6
+ export function BarChart({
7
+ categories,
8
+ series,
9
+ height = 240,
10
+ stacked,
11
+ horizontal,
12
+ normalized,
13
+ valueSuffix,
14
+ valuePrefix,
15
+ showValues,
16
+ beginAtZero,
17
+ yMin,
18
+ yMax,
19
+ referenceLines,
20
+ style,
21
+ extra,
22
+ }: BarChartProps) {
23
+ const seriesColor = useSeriesColor()
24
+ const chartColors = useChartColorProps()
25
+ const data = withPerCategoryBarColors(
26
+ reshapeLineChartData(categories, series),
27
+ series,
28
+ seriesColor,
29
+ )
30
+ const type = normalized ? 'percent' : stacked ? 'stacked' : 'default'
31
+ const axis = valueAxisChartProps({
32
+ beginAtZero,
33
+ yMin,
34
+ yMax,
35
+ referenceLines,
36
+ horizontal,
37
+ stacked,
38
+ normalized,
39
+ valueAxis: extra?.valueAxis,
40
+ })
41
+ const withBarValueLabel =
42
+ !stacked &&
43
+ !normalized &&
44
+ defaultShowBarValues(showValues, series, categories)
45
+ return (
46
+ <MantineBarChart
47
+ h={height}
48
+ data={data}
49
+ dataKey="category"
50
+ series={toMantineSeries(series, seriesColor)}
51
+ type={type}
52
+ orientation={horizontal ? 'vertical' : 'horizontal'}
53
+ withLegend={series.length > 1}
54
+ legendProps={CHART_LEGEND_PROPS}
55
+ valueFormatter={valueFormatter(valueSuffix, valuePrefix)}
56
+ withBarValueLabel={withBarValueLabel}
57
+ {...(withBarValueLabel
58
+ ? {
59
+ valueLabelProps: horizontal
60
+ ? horizontalBarValueLabelProps(chartColors.barLabelColor)
61
+ : barValueLabelProps(chartColors.barLabelColor),
62
+ }
63
+ : {})}
64
+ {...(horizontal
65
+ ? horizontalBarProps(extra?.categoryAxis?.width, withBarValueLabel)
66
+ : withBarValueLabel
67
+ ? verticalBarLabelProps()
68
+ : {})}
69
+ styles={{
70
+ ...CHART_LEGEND_STYLES,
71
+ legendItemName: { color: chartColors.textColor },
72
+ }}
73
+ {...axis}
74
+ style={style}
75
+ />
76
+ )
77
+ }
@@ -0,0 +1,57 @@
1
+ import { useCallback, useState, type CSSProperties } from 'react'
2
+ import type { CanvasAction, SetCanvasState } from '../vendor/canvas-sdk/hooks.js'
3
+
4
+ export function useCanvasState<T>(
5
+ key: string,
6
+ defaultValue: T,
7
+ ): [T, SetCanvasState<T>] {
8
+ const storageKey = `canvas:${key}`
9
+
10
+ const [state, setState] = useState<T>(() => {
11
+ if (typeof localStorage === 'undefined') return defaultValue
12
+ try {
13
+ const raw = localStorage.getItem(storageKey)
14
+ return raw === null ? defaultValue : (JSON.parse(raw) as T)
15
+ } catch {
16
+ return defaultValue
17
+ }
18
+ })
19
+
20
+ const set = useCallback<SetCanvasState<T>>(
21
+ (action) => {
22
+ setState((prev) => {
23
+ const next =
24
+ typeof action === 'function'
25
+ ? (action as (prev: T) => T)(prev)
26
+ : action
27
+ try {
28
+ localStorage?.setItem(storageKey, JSON.stringify(next))
29
+ } catch {
30
+ // Ignore quota / unavailable storage; state still updates in memory.
31
+ }
32
+ return next
33
+ })
34
+ },
35
+ [storageKey],
36
+ )
37
+
38
+ return [state, set]
39
+ }
40
+
41
+ export function useCanvasAction(): (action: CanvasAction) => void {
42
+ return useCallback((action: CanvasAction) => {
43
+ if (import.meta.env?.DEV) {
44
+ console.info(
45
+ '[cursor/canvas shim] useCanvasAction (no-op on web):',
46
+ action,
47
+ )
48
+ }
49
+ }, [])
50
+ }
51
+
52
+ export function mergeStyle(
53
+ base: CSSProperties,
54
+ override?: CSSProperties,
55
+ ): CSSProperties {
56
+ return { ...base, ...override }
57
+ }
@@ -0,0 +1,129 @@
1
+ import { MantineProvider, createTheme, type MantineColorScheme, type MantineColorsTuple, type MantineThemeOverride } from '@mantine/core'
2
+ import { useMemo, useSyncExternalStore, type ReactNode } from 'react'
3
+ import { resolveThemeForColorScheme, type ShimHostTheme } from './host-tokens'
4
+
5
+ export interface CanvasMantineRootProps {
6
+ children: ReactNode;
7
+ defaultColorScheme?: MantineColorScheme
8
+ }
9
+
10
+ function solidScale(hex: string): MantineColorsTuple {
11
+ return [hex, hex, hex, hex, hex, hex, hex, hex, hex, hex]
12
+ }
13
+
14
+ /** Maps `useHostTheme()` / brand onto Mantine `theme` (primary, radii, headings). */
15
+ export function createMantineThemeFromHost(host: ShimHostTheme): MantineThemeOverride {
16
+ const { typography: type, radius } = host
17
+ return createTheme({
18
+ primaryColor: 'accent',
19
+ colors: {
20
+ accent: solidScale(host.palette.accent),
21
+ },
22
+ fontFamily: type.fontFamily,
23
+ defaultRadius: `${radius.md}`,
24
+ radius: {
25
+ xs: `${radius.xs}px`,
26
+ sm: `${radius.sm}px`,
27
+ md: `${radius.md}px`,
28
+ lg: `${radius.lg}px`,
29
+ xl: `${radius.xl}px`,
30
+ },
31
+ headings: {
32
+ fontFamily: type.fontFamily,
33
+ fontWeight: String(type.h1.fontWeight),
34
+ sizes: {
35
+ h1: {
36
+ fontSize: type.h1.fontSize,
37
+ lineHeight: type.h1.lineHeight,
38
+ fontWeight: String(type.h1.fontWeight),
39
+ },
40
+ h2: {
41
+ fontSize: type.h2.fontSize,
42
+ lineHeight: type.h2.lineHeight,
43
+ fontWeight: String(type.h2.fontWeight),
44
+ },
45
+ h3: {
46
+ fontSize: type.h3.fontSize,
47
+ lineHeight: type.h3.lineHeight,
48
+ fontWeight: String(type.h3.fontWeight),
49
+ },
50
+ h4: {
51
+ fontSize: type.body.fontSize,
52
+ lineHeight: type.body.lineHeight,
53
+ fontWeight: String(type.body.fontWeight),
54
+ },
55
+ },
56
+ },
57
+ })
58
+ }
59
+
60
+ function subscribeSystemScheme(onChange: () => void): () => void {
61
+ const mq = window.matchMedia('(prefers-color-scheme: dark)')
62
+ mq.addEventListener('change', onChange)
63
+ return () => mq.removeEventListener('change', onChange)
64
+ }
65
+
66
+ function getSystemScheme(): 'light' | 'dark' {
67
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
68
+ ? 'dark'
69
+ : 'light'
70
+ }
71
+
72
+ function useResolvedPolarity(
73
+ defaultColorScheme: MantineColorScheme,
74
+ ): 'light' | 'dark' {
75
+ const system = useSyncExternalStore<'light' | 'dark'>(
76
+ subscribeSystemScheme,
77
+ getSystemScheme,
78
+ () => 'light',
79
+ )
80
+ if (defaultColorScheme === 'dark') {
81
+ return 'dark'
82
+ }
83
+ if (defaultColorScheme === 'light') {
84
+ return 'light'
85
+ }
86
+ return system
87
+ }
88
+
89
+ /**
90
+ * Mantine + canvas host tokens (brand, Grok radii/typography, accent primary).
91
+ * Not part of the `cursor/canvas` barrel.
92
+ */
93
+ export function CanvasMantineRoot({
94
+ children,
95
+ defaultColorScheme = 'auto',
96
+ }: CanvasMantineRootProps) {
97
+ const polarity = useResolvedPolarity(defaultColorScheme)
98
+ const host = useMemo(
99
+ () => resolveThemeForColorScheme(polarity),
100
+ [polarity],
101
+ )
102
+ const mantineTheme = useMemo(() => createMantineThemeFromHost(host), [host])
103
+
104
+ return (
105
+ <MantineProvider
106
+ theme={mantineTheme}
107
+ defaultColorScheme={defaultColorScheme}
108
+ forceColorScheme={polarity}
109
+ >
110
+ <div
111
+ data-cursor-canvas-host
112
+ style={{
113
+ minHeight: 'inherit',
114
+ background: host.bg.editor,
115
+ color: host.text.primary,
116
+ fontFamily: host.typography.fontFamily,
117
+ }}
118
+ >
119
+ {/* Canvas-authored responsive SVGs (e.g. DAG): IDE scales to width; do not clamp max-height. */}
120
+ <style>{`
121
+ [data-cursor-canvas-host] svg[viewBox][width='100%'] {
122
+ max-height: unset !important;
123
+ }
124
+ `}</style>
125
+ {children}
126
+ </div>
127
+ </MantineProvider>
128
+ )
129
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Shared chart data shaping and axis helpers (no @mantine/charts component imports).
3
+ */
4
+ import type { ChartReferenceLineProps } from '@mantine/charts'
5
+ import type { XAxisProps, YAxisProps } from 'recharts'
6
+ import type { CanvasChartValueAxisExtra } from '../superset/chart-extra'
7
+ import type { BarChartProps } from '../superset/index'
8
+ import { toneHex } from './theme'
9
+ import { useHostTheme } from './use-tokens'
10
+
11
+ export type {
12
+ CanvasBarChartExtra,
13
+ CanvasChartExtra,
14
+ CanvasChartScale,
15
+ CanvasChartValueAxisExtra,
16
+ CanvasLineChartExtra,
17
+ } from '../superset/chart-extra'
18
+ export type { BarChartProps, LineChartProps } from '../superset/index'
19
+ export type {
20
+ ChartDataPoint,
21
+ ChartReferenceLine,
22
+ ChartSeries,
23
+ ChartTone,
24
+ PieChartProps,
25
+ } from '../vendor/canvas-sdk/chart-primitives.js'
26
+
27
+ import type { ChartReferenceLine, ChartSeries, ChartTone } from '../vendor/canvas-sdk/chart-primitives.js'
28
+
29
+ export const CHART_LEGEND_PROPS = {
30
+ verticalAlign: 'bottom' as const,
31
+ align: 'center' as const,
32
+ }
33
+
34
+ export const CHART_LEGEND_STYLES = {
35
+ legend: { justifyContent: 'center' },
36
+ } as const
37
+
38
+ export function useSeriesColor(): (
39
+ tone: ChartTone | undefined,
40
+ index: number,
41
+ ) => string {
42
+ const theme = useHostTheme()
43
+ const sequence = theme.chart.sequence
44
+ return (tone, index) =>
45
+ tone ? toneHex[tone] : sequence[index % sequence.length]
46
+ }
47
+
48
+ export function useChartColorProps(): { textColor: string; barLabelColor: string } {
49
+ const theme = useHostTheme()
50
+ const text = theme.text.primary
51
+ return { textColor: text, barLabelColor: text }
52
+ }
53
+
54
+ export function reshapeLineChartData(
55
+ categories: string[],
56
+ series: ChartSeries[],
57
+ ): Record<string, string | number>[] {
58
+ return categories.map((category, index) => {
59
+ const row: Record<string, string | number> = { category }
60
+ for (const s of series) {
61
+ row[s.name] = s.data[index] ?? 0
62
+ }
63
+ return row
64
+ })
65
+ }
66
+
67
+ export function toMantineSeries(
68
+ series: ChartSeries[],
69
+ seriesColor: (tone: ChartTone | undefined, index: number) => string,
70
+ ) {
71
+ return series.map((s, index) => ({
72
+ name: s.name,
73
+ color: seriesColor(s.tone, index),
74
+ }))
75
+ }
76
+
77
+ export function valueFormatter(valueSuffix?: string, valuePrefix?: string) {
78
+ return (value: number) => `${valuePrefix ?? ''}${value}${valueSuffix ?? ''}`
79
+ }
80
+
81
+ export function mantineReferenceLines(
82
+ lines: ChartReferenceLine[] | undefined,
83
+ horizontal: boolean,
84
+ ): ChartReferenceLineProps[] | undefined {
85
+ if (!lines?.length) {
86
+ return undefined
87
+ }
88
+ return lines.map(line => ({
89
+ ...(horizontal ? { x: line.value } : { y: line.value }),
90
+ label: line.label,
91
+ color: line.tone ? toneHex[line.tone] : toneHex.neutral,
92
+ }))
93
+ }
94
+
95
+ type ValueAxisProps = Pick<
96
+ BarChartProps,
97
+ 'beginAtZero' | 'yMin' | 'yMax' | 'referenceLines'
98
+ >
99
+
100
+ type AxisChartProps = {
101
+ yAxisProps?: Omit<YAxisProps, 'ref'>;
102
+ xAxisProps?: Omit<XAxisProps, 'ref'>;
103
+ referenceLines?: ChartReferenceLineProps[]
104
+ }
105
+
106
+ const NON_LINEAR_SCALES = new Set(['log', 'symlog', 'pow', 'sqrt'])
107
+
108
+ function normalizeValueAxisExtra(
109
+ valueAxis: CanvasChartValueAxisExtra,
110
+ ): CanvasChartValueAxisExtra {
111
+ const { scale } = valueAxis
112
+ if (typeof scale !== 'string' || !NON_LINEAR_SCALES.has(scale)) {
113
+ return valueAxis
114
+ }
115
+ return {
116
+ ...valueAxis,
117
+ type: valueAxis.type ?? 'number',
118
+ allowDataOverflow: valueAxis.allowDataOverflow ?? true,
119
+ allowDecimals: valueAxis.allowDecimals ?? true,
120
+ domain: valueAxis.domain ?? [1, 'dataMax'],
121
+ }
122
+ }
123
+
124
+ export function valueAxisChartProps(
125
+ opts: ValueAxisProps & {
126
+ horizontal?: boolean;
127
+ stacked?: boolean;
128
+ normalized?: boolean;
129
+ valueAxis?: CanvasChartValueAxisExtra
130
+ },
131
+ ): AxisChartProps {
132
+ const ref = mantineReferenceLines(opts.referenceLines, !!opts.horizontal)
133
+ let domain: [number | string, number | string] | undefined
134
+ if (!opts.stacked && !opts.normalized) {
135
+ const beginAtZero = opts.beginAtZero !== false
136
+ domain = [opts.yMin ?? (beginAtZero ? 0 : 'auto'), opts.yMax ?? 'auto']
137
+ }
138
+ const extra = opts.valueAxis ? normalizeValueAxisExtra(opts.valueAxis) : undefined
139
+ if (!domain && !extra) {
140
+ return { referenceLines: ref }
141
+ }
142
+ const axisProps = { ...(domain && { domain }), ...extra }
143
+ return opts.horizontal
144
+ ? { xAxisProps: axisProps as Omit<XAxisProps, 'ref'>, referenceLines: ref }
145
+ : { yAxisProps: axisProps, referenceLines: ref }
146
+ }
147
+
148
+ export function defaultShowBarValues(
149
+ showValues: boolean | undefined,
150
+ series: ChartSeries[],
151
+ categories: string[],
152
+ ): boolean {
153
+ if (showValues !== undefined) {
154
+ return showValues
155
+ }
156
+ return series.length === 1 && categories.length <= 8
157
+ }
158
+
159
+ export function withPerCategoryBarColors(
160
+ data: Record<string, string | number>[],
161
+ series: ChartSeries[],
162
+ seriesColor: (tone: ChartTone | undefined, index: number) => string,
163
+ ): Record<string, string | number>[] {
164
+ if (series.length !== 1 || series[0].tone) {
165
+ return data
166
+ }
167
+ return data.map((row, index) => ({
168
+ ...row,
169
+ color: seriesColor(undefined, index),
170
+ }))
171
+ }
172
+
173
+ const HORIZONTAL_CATEGORY_AXIS_WIDTH = 96
174
+ const CATEGORY_LABEL_CHAR_WIDTH = 6.5
175
+
176
+ function truncateCategoryAxisLabel(text: string, max: number): string {
177
+ if (text.length <= max) {
178
+ return text
179
+ }
180
+ return `${text.slice(0, Math.max(0, max - 1))}…`
181
+ }
182
+
183
+ function horizontalCategoryAxisTick(
184
+ labelMax: number,
185
+ props: {
186
+ x?: string | number;
187
+ y?: string | number;
188
+ payload?: { value?: string | number }
189
+ },
190
+ ) {
191
+ const x = typeof props.x === 'number' ? props.x : Number(props.x) || 0
192
+ const y = typeof props.y === 'number' ? props.y : Number(props.y) || 0
193
+ const full = String(props.payload?.value ?? '')
194
+ const display = truncateCategoryAxisLabel(full, labelMax)
195
+ return (
196
+ <g transform={`translate(${x},${y})`}>
197
+ <text
198
+ x={-10}
199
+ y={0}
200
+ dy={4}
201
+ textAnchor="end"
202
+ fill="currentColor"
203
+ fontSize={12}
204
+ >
205
+ <title>{full}</title>
206
+ {display}
207
+ </text>
208
+ </g>
209
+ )
210
+ }
211
+
212
+ function barValueLabelProps(barLabelColor: string) {
213
+ return {
214
+ fontWeight: 700,
215
+ fill: barLabelColor,
216
+ }
217
+ }
218
+
219
+ function horizontalBarValueLabelProps(barLabelColor: string) {
220
+ return {
221
+ ...barValueLabelProps(barLabelColor),
222
+ position: 'right' as const,
223
+ }
224
+ }
225
+
226
+ export function horizontalBarProps(
227
+ width = HORIZONTAL_CATEGORY_AXIS_WIDTH,
228
+ withValueLabel = false,
229
+ ) {
230
+ const labelMax = Math.floor(width / CATEGORY_LABEL_CHAR_WIDTH)
231
+ return {
232
+ yAxisProps: {
233
+ tick: (props: Parameters<typeof horizontalCategoryAxisTick>[1]) =>
234
+ horizontalCategoryAxisTick(labelMax, props),
235
+ width,
236
+ },
237
+ barChartProps: {
238
+ margin: { right: withValueLabel ? 48 : 32 },
239
+ },
240
+ gridAxis: 'y' as const,
241
+ }
242
+ }
243
+
244
+ function verticalBarLabelProps() {
245
+ return {
246
+ barChartProps: {
247
+ margin: { top: 22 },
248
+ },
249
+ }
250
+ }
251
+
252
+ export { barValueLabelProps, horizontalBarValueLabelProps, verticalBarLabelProps }