diff --git a/src/design-system/composites/AreaChart/AreaChart.module.css b/src/design-system/composites/AreaChart/AreaChart.module.css new file mode 100644 index 0000000..d098ba6 --- /dev/null +++ b/src/design-system/composites/AreaChart/AreaChart.module.css @@ -0,0 +1,157 @@ +.wrapper { + position: relative; + display: inline-flex; + flex-direction: column; + gap: 4px; +} + +.svg { + display: block; + overflow: visible; +} + +.empty { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 12px; + height: 120px; +} + +/* Grid */ +.gridLine { + stroke: var(--border-subtle); + stroke-width: 1; + stroke-dasharray: 3 3; +} + +.axisLabel { + font-family: var(--font-mono); + font-size: 9px; + fill: var(--text-faint); +} + +/* Threshold line */ +.thresholdLine { + stroke: var(--error); + stroke-width: 1.5; + stroke-dasharray: 5 3; +} + +.thresholdLabel { + font-family: var(--font-mono); + font-size: 9px; + fill: var(--error); +} + +/* Area + line */ +.area { + opacity: 0.1; +} + +.line { + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* Crosshair */ +.crosshair { + stroke: var(--text-faint); + stroke-width: 1; + stroke-dasharray: 3 3; + pointer-events: none; +} + +/* Tooltip */ +.tooltip { + position: absolute; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-md); + padding: 6px 10px; + font-size: 11px; + pointer-events: none; + z-index: 10; + white-space: nowrap; +} + +.tooltipRow { + display: flex; + align-items: center; + gap: 5px; + margin-bottom: 2px; +} + +.tooltipRow:last-child { + margin-bottom: 0; +} + +.tooltipDot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.tooltipLabel { + color: var(--text-muted); +} + +.tooltipValue { + font-family: var(--font-mono); + font-weight: 600; + color: var(--text-primary); +} + +/* Legend */ +.legend { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding-left: 48px; +} + +.legendItem { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--text-secondary); +} + +.legendDot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.legendLabel { + font-size: 11px; +} + +/* Axis labels */ +.yLabel { + font-size: 10px; + color: var(--text-muted); + writing-mode: vertical-lr; + transform: rotate(180deg); + text-align: center; + position: absolute; + left: 4px; + top: 0; + bottom: 28px; + display: flex; + align-items: center; + justify-content: center; +} + +.xLabel { + text-align: center; + font-size: 10px; + color: var(--text-muted); + padding-left: 48px; +} diff --git a/src/design-system/composites/AreaChart/AreaChart.tsx b/src/design-system/composites/AreaChart/AreaChart.tsx new file mode 100644 index 0000000..e3ea506 --- /dev/null +++ b/src/design-system/composites/AreaChart/AreaChart.tsx @@ -0,0 +1,254 @@ +import { useState } from 'react' +import styles from './AreaChart.module.css' +import { + computeYScale, + computeXScale, + seriesPoints, + seriesPath, + formatAxisLabel, + CHART_COLORS, + type ChartSeries, +} from '../_chart-utils' + +interface Threshold { + value: number + label: string +} + +interface AreaChartProps { + series: ChartSeries[] + xLabel?: string + yLabel?: string + threshold?: Threshold + height?: number + width?: number + className?: string +} + +const Y_TICK_COUNT = 4 +const DIMS = { + paddingTop: 12, + paddingRight: 16, + paddingBottom: 28, + paddingLeft: 48, +} + +export function AreaChart({ + series, + xLabel, + yLabel, + threshold, + height = 200, + width = 400, + className, +}: AreaChartProps) { + const [tooltip, setTooltip] = useState<{ + x: number + y: number + values: { label: string; value: number; color: string }[] + } | null>(null) + + const dims = { ...DIMS, width, height } + const allData = series.flatMap((s) => s.data) + + if (allData.length === 0) { + return
No data
+ } + + const { max, toY } = computeYScale(series, dims, threshold?.value) + const { toX } = computeXScale(series, dims) + const plotH = height - dims.paddingTop - dims.paddingBottom + const plotW = width - dims.paddingLeft - dims.paddingRight + const bottomY = dims.paddingTop + plotH + + // Y-axis ticks + const yTicks = Array.from({ length: Y_TICK_COUNT + 1 }, (_, i) => + Math.round((max / Y_TICK_COUNT) * i), + ) + + // X-axis ticks (first, middle, last) + const firstSeries = series[0] + const xSamples = + firstSeries && firstSeries.data.length > 0 + ? [ + firstSeries.data[0].x, + firstSeries.data[Math.floor(firstSeries.data.length / 2)]?.x, + firstSeries.data[firstSeries.data.length - 1].x, + ].filter(Boolean) + : [] + + function handleMouseMove(e: React.MouseEvent) { + const rect = e.currentTarget.getBoundingClientRect() + const mx = e.clientX - rect.left + const my = e.clientY - rect.top + + // Find closest x value + const pctX = (mx - dims.paddingLeft) / plotW + const values = series.map((s, i) => { + const idx = Math.round(pctX * (s.data.length - 1)) + const clamped = Math.max(0, Math.min(s.data.length - 1, idx)) + const pt = s.data[clamped] + return { + label: s.label, + value: pt?.y ?? 0, + color: s.color ?? CHART_COLORS[i % CHART_COLORS.length], + } + }) + + setTooltip({ x: mx, y: my, values }) + } + + return ( +
+ {yLabel &&
{yLabel}
} + setTooltip(null)} + aria-label="Area chart" + role="img" + > + {/* Grid lines */} + {yTicks.map((val) => { + const y = toY(val) + return ( + + + + {formatAxisLabel(val)} + + + ) + })} + + {/* X-axis labels */} + {xSamples.map((xVal, i) => { + const xPos = toX(xVal) + const xv = xVal instanceof Date ? xVal : new Date(xVal as number) + const label = + xVal instanceof Date || (typeof xVal === 'number' && xVal > 1e10) + ? xv.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : formatAxisLabel(xVal as number) + const anchor = i === 0 ? 'start' : i === xSamples.length - 1 ? 'end' : 'middle' + return ( + + {label} + + ) + })} + + {/* SLA threshold line */} + {threshold && ( + + + + {threshold.label} + + + )} + + {/* Area fills */} + {series.map((s, i) => { + const color = s.color ?? CHART_COLORS[i % CHART_COLORS.length] + const areaD = seriesPath(s, toX, toY, bottomY) + return ( + + ) + })} + + {/* Lines */} + {series.map((s, i) => { + const color = s.color ?? CHART_COLORS[i % CHART_COLORS.length] + const pts = seriesPoints(s, toX, toY) + return ( + + ) + })} + + {/* Crosshair */} + {tooltip && ( + + )} + + + {/* Tooltip */} + {tooltip && ( +
+ {tooltip.values.map((v) => ( +
+ + {v.label}: + {formatAxisLabel(v.value)} +
+ ))} +
+ )} + + {/* Legend */} + {series.length > 1 && ( +
+ {series.map((s, i) => ( +
+ + {s.label} +
+ ))} +
+ )} + + {xLabel &&
{xLabel}
} +
+ ) +} diff --git a/src/design-system/composites/BarChart/BarChart.module.css b/src/design-system/composites/BarChart/BarChart.module.css new file mode 100644 index 0000000..0f8f26f --- /dev/null +++ b/src/design-system/composites/BarChart/BarChart.module.css @@ -0,0 +1,143 @@ +.wrapper { + position: relative; + display: inline-flex; + flex-direction: column; + gap: 4px; +} + +.svg { + display: block; + overflow: visible; +} + +.empty { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 12px; + height: 120px; +} + +.gridLine { + stroke: var(--border-subtle); + stroke-width: 1; + stroke-dasharray: 3 3; +} + +.axisLabel { + font-family: var(--font-mono); + font-size: 9px; + fill: var(--text-faint); +} + +.catLabel { + font-family: var(--font-mono); + font-size: 9px; + fill: var(--text-faint); +} + +.bar { + cursor: pointer; + transition: opacity 0.1s; +} + +.bar:hover { + opacity: 0.8; +} + +.tooltip { + position: absolute; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-md); + padding: 6px 10px; + font-size: 11px; + pointer-events: none; + z-index: 10; + white-space: nowrap; +} + +.tooltipTitle { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; + font-size: 11px; +} + +.tooltipRow { + display: flex; + align-items: center; + gap: 5px; + margin-bottom: 2px; +} + +.tooltipRow:last-child { + margin-bottom: 0; +} + +.tooltipDot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.tooltipLabel { + color: var(--text-muted); +} + +.tooltipValue { + font-family: var(--font-mono); + font-weight: 600; + color: var(--text-primary); +} + +.legend { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding-left: 48px; +} + +.legendItem { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--text-secondary); +} + +.legendDot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.legendLabel { + font-size: 11px; +} + +.yLabel { + font-size: 10px; + color: var(--text-muted); + writing-mode: vertical-lr; + transform: rotate(180deg); + text-align: center; + position: absolute; + left: 4px; + top: 0; + bottom: 40px; + display: flex; + align-items: center; + justify-content: center; +} + +.xLabel { + text-align: center; + font-size: 10px; + color: var(--text-muted); + padding-left: 48px; +} diff --git a/src/design-system/composites/BarChart/BarChart.tsx b/src/design-system/composites/BarChart/BarChart.tsx new file mode 100644 index 0000000..47549b5 --- /dev/null +++ b/src/design-system/composites/BarChart/BarChart.tsx @@ -0,0 +1,251 @@ +import { useState } from 'react' +import styles from './BarChart.module.css' +import { formatAxisLabel, CHART_COLORS } from '../_chart-utils' + +interface BarSeries { + label: string + data: { x: string; y: number }[] + color?: string +} + +interface BarChartProps { + series: BarSeries[] + stacked?: boolean + height?: number + width?: number + xLabel?: string + yLabel?: string + className?: string +} + +const PADDING = { top: 12, right: 16, bottom: 40, left: 48 } +const Y_TICK_COUNT = 4 +const BAR_GAP = 0.2 // fraction of bar group width reserved for gaps + +export function BarChart({ + series, + stacked = false, + height = 200, + width = 400, + xLabel, + yLabel, + className, +}: BarChartProps) { + const [tooltip, setTooltip] = useState<{ + x: number + y: number + label: string + values: { series: string; value: number; color: string }[] + } | null>(null) + + if (series.length === 0 || series[0].data.length === 0) { + return
No data
+ } + + // Collect all x categories (union across all series) + const categories = Array.from(new Set(series.flatMap((s) => s.data.map((d) => d.x)))) + const numCats = categories.length + + const plotW = width - PADDING.left - PADDING.right + const plotH = height - PADDING.top - PADDING.bottom + + // Compute max Y + let maxY = 0 + if (stacked) { + for (const cat of categories) { + const sum = series.reduce((acc, s) => { + const pt = s.data.find((d) => d.x === cat) + return acc + (pt?.y ?? 0) + }, 0) + maxY = Math.max(maxY, sum) + } + } else { + maxY = Math.max(...series.flatMap((s) => s.data.map((d) => d.y))) + } + maxY = maxY || 1 + + const yTicks = Array.from({ length: Y_TICK_COUNT + 1 }, (_, i) => + Math.round((maxY / Y_TICK_COUNT) * i), + ) + const toY = (val: number) => PADDING.top + plotH - (val / maxY) * plotH + const bottomY = PADDING.top + plotH + + const catWidth = plotW / numCats + const groupGap = catWidth * BAR_GAP + const groupW = catWidth - groupGap + + function handleMouseEnter( + catLabel: string, + mx: number, + my: number, + values: { series: string; value: number; color: string }[], + ) { + setTooltip({ x: mx, y: my, label: catLabel, values }) + } + + return ( +
+ {yLabel &&
{yLabel}
} + setTooltip(null)} + aria-label="Bar chart" + role="img" + > + {/* Grid lines */} + {yTicks.map((val) => { + const y = toY(val) + return ( + + + + {formatAxisLabel(val)} + + + ) + })} + + {/* Bars */} + {categories.map((cat, ci) => { + const groupX = PADDING.left + ci * catWidth + groupGap / 2 + + if (stacked) { + let stackY = bottomY + return ( + + {series.map((s, si) => { + const pt = s.data.find((d) => d.x === cat) + const val = pt?.y ?? 0 + const barH = (val / maxY) * plotH + const color = s.color ?? CHART_COLORS[si % CHART_COLORS.length] + const y = stackY - barH + stackY -= barH + return ( + { + const rect = e.currentTarget.closest('svg')!.getBoundingClientRect() + handleMouseEnter( + cat, + e.clientX - rect.left, + e.clientY - rect.top, + series.map((ss, ssi) => ({ + series: ss.label, + value: ss.data.find((d) => d.x === cat)?.y ?? 0, + color: ss.color ?? CHART_COLORS[ssi % CHART_COLORS.length], + })), + ) + }} + /> + ) + })} + + {cat} + + + ) + } + + // Grouped + const barW = groupW / series.length + return ( + + {series.map((s, si) => { + const pt = s.data.find((d) => d.x === cat) + const val = pt?.y ?? 0 + const barH = (val / maxY) * plotH + const color = s.color ?? CHART_COLORS[si % CHART_COLORS.length] + return ( + { + const svgEl = e.currentTarget.closest('svg')!.getBoundingClientRect() + handleMouseEnter( + cat, + e.clientX - svgEl.left, + e.clientY - svgEl.top, + series.map((ss, ssi) => ({ + series: ss.label, + value: ss.data.find((d) => d.x === cat)?.y ?? 0, + color: ss.color ?? CHART_COLORS[ssi % CHART_COLORS.length], + })), + ) + }} + /> + ) + })} + + {cat} + + + ) + })} + + + {/* Tooltip */} + {tooltip && ( +
+
{tooltip.label}
+ {tooltip.values.map((v) => ( +
+ + {v.series}: + {formatAxisLabel(v.value)} +
+ ))} +
+ )} + + {/* Legend */} + {series.length > 1 && ( +
+ {series.map((s, i) => ( +
+ + {s.label} +
+ ))} +
+ )} + + {xLabel &&
{xLabel}
} +
+ ) +} diff --git a/src/design-system/composites/LineChart/LineChart.module.css b/src/design-system/composites/LineChart/LineChart.module.css new file mode 100644 index 0000000..83fe84b --- /dev/null +++ b/src/design-system/composites/LineChart/LineChart.module.css @@ -0,0 +1,146 @@ +.wrapper { + position: relative; + display: inline-flex; + flex-direction: column; + gap: 4px; +} + +.svg { + display: block; + overflow: visible; +} + +.empty { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 12px; + height: 120px; +} + +.gridLine { + stroke: var(--border-subtle); + stroke-width: 1; + stroke-dasharray: 3 3; +} + +.axisLabel { + font-family: var(--font-mono); + font-size: 9px; + fill: var(--text-faint); +} + +.thresholdLine { + stroke: var(--error); + stroke-width: 1.5; + stroke-dasharray: 5 3; +} + +.thresholdLabel { + font-family: var(--font-mono); + font-size: 9px; + fill: var(--error); +} + +.line { + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.crosshair { + stroke: var(--text-faint); + stroke-width: 1; + stroke-dasharray: 3 3; + pointer-events: none; +} + +.tooltip { + position: absolute; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-md); + padding: 6px 10px; + font-size: 11px; + pointer-events: none; + z-index: 10; + white-space: nowrap; +} + +.tooltipRow { + display: flex; + align-items: center; + gap: 5px; + margin-bottom: 2px; +} + +.tooltipRow:last-child { + margin-bottom: 0; +} + +.tooltipDot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.tooltipLabel { + color: var(--text-muted); +} + +.tooltipValue { + font-family: var(--font-mono); + font-weight: 600; + color: var(--text-primary); +} + +.legend { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding-left: 48px; +} + +.legendItem { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--text-secondary); +} + +.legendDot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.legendLabel { + font-size: 11px; +} + +.yLabel { + font-size: 10px; + color: var(--text-muted); + writing-mode: vertical-lr; + transform: rotate(180deg); + text-align: center; + position: absolute; + left: 4px; + top: 0; + bottom: 28px; + display: flex; + align-items: center; + justify-content: center; +} + +.xLabel { + text-align: center; + font-size: 10px; + color: var(--text-muted); + padding-left: 48px; +} diff --git a/src/design-system/composites/LineChart/LineChart.tsx b/src/design-system/composites/LineChart/LineChart.tsx new file mode 100644 index 0000000..e9a12d1 --- /dev/null +++ b/src/design-system/composites/LineChart/LineChart.tsx @@ -0,0 +1,233 @@ +import { useState } from 'react' +import styles from './LineChart.module.css' +import { + computeYScale, + computeXScale, + seriesPoints, + formatAxisLabel, + CHART_COLORS, + type ChartSeries, +} from '../_chart-utils' + +interface Threshold { + value: number + label: string +} + +interface LineChartProps { + series: ChartSeries[] + xLabel?: string + yLabel?: string + threshold?: Threshold + height?: number + width?: number + className?: string +} + +const Y_TICK_COUNT = 4 +const DIMS = { + paddingTop: 12, + paddingRight: 16, + paddingBottom: 28, + paddingLeft: 48, +} + +export function LineChart({ + series, + xLabel, + yLabel, + threshold, + height = 200, + width = 400, + className, +}: LineChartProps) { + const [tooltip, setTooltip] = useState<{ + x: number + y: number + values: { label: string; value: number; color: string }[] + } | null>(null) + + const dims = { ...DIMS, width, height } + const allData = series.flatMap((s) => s.data) + + if (allData.length === 0) { + return
No data
+ } + + const { max, toY } = computeYScale(series, dims, threshold?.value) + const { toX } = computeXScale(series, dims) + const plotH = height - dims.paddingTop - dims.paddingBottom + const plotW = width - dims.paddingLeft - dims.paddingRight + const bottomY = dims.paddingTop + plotH + + const yTicks = Array.from({ length: Y_TICK_COUNT + 1 }, (_, i) => + Math.round((max / Y_TICK_COUNT) * i), + ) + + const firstSeries = series[0] + const xSamples = + firstSeries && firstSeries.data.length > 0 + ? [ + firstSeries.data[0].x, + firstSeries.data[Math.floor(firstSeries.data.length / 2)]?.x, + firstSeries.data[firstSeries.data.length - 1].x, + ].filter(Boolean) + : [] + + function handleMouseMove(e: React.MouseEvent) { + const rect = e.currentTarget.getBoundingClientRect() + const mx = e.clientX - rect.left + const my = e.clientY - rect.top + const pctX = (mx - dims.paddingLeft) / plotW + + const values = series.map((s, i) => { + const idx = Math.round(pctX * (s.data.length - 1)) + const clamped = Math.max(0, Math.min(s.data.length - 1, idx)) + const pt = s.data[clamped] + return { + label: s.label, + value: pt?.y ?? 0, + color: s.color ?? CHART_COLORS[i % CHART_COLORS.length], + } + }) + + setTooltip({ x: mx, y: my, values }) + } + + return ( +
+ {yLabel &&
{yLabel}
} + setTooltip(null)} + aria-label="Line chart" + role="img" + > + {/* Grid lines */} + {yTicks.map((val) => { + const y = toY(val) + return ( + + + + {formatAxisLabel(val)} + + + ) + })} + + {/* X-axis labels */} + {xSamples.map((xVal, i) => { + const xPos = toX(xVal) + const xv = xVal instanceof Date ? xVal : new Date(xVal as number) + const label = + xVal instanceof Date || (typeof xVal === 'number' && xVal > 1e10) + ? xv.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : formatAxisLabel(xVal as number) + const anchor = i === 0 ? 'start' : i === xSamples.length - 1 ? 'end' : 'middle' + return ( + + {label} + + ) + })} + + {/* Threshold line */} + {threshold && ( + + + + {threshold.label} + + + )} + + {/* Lines only (no area fill) */} + {series.map((s, i) => { + const color = s.color ?? CHART_COLORS[i % CHART_COLORS.length] + const pts = seriesPoints(s, toX, toY) + return ( + + ) + })} + + {/* Crosshair */} + {tooltip && ( + + )} + + + {/* Tooltip */} + {tooltip && ( +
+ {tooltip.values.map((v) => ( +
+ + {v.label}: + {formatAxisLabel(v.value)} +
+ ))} +
+ )} + + {/* Legend */} + {series.length > 1 && ( +
+ {series.map((s, i) => ( +
+ + {s.label} +
+ ))} +
+ )} + + {xLabel &&
{xLabel}
} +
+ ) +} diff --git a/src/design-system/composites/_chart-utils.ts b/src/design-system/composites/_chart-utils.ts new file mode 100644 index 0000000..8add68d --- /dev/null +++ b/src/design-system/composites/_chart-utils.ts @@ -0,0 +1,101 @@ +export interface DataPoint { + x: number | Date + y: number +} + +export interface ChartSeries { + label: string + data: DataPoint[] + color?: string +} + +export interface ChartDimensions { + width: number + height: number + paddingTop: number + paddingRight: number + paddingBottom: number + paddingLeft: number +} + +export function computeYScale( + series: ChartSeries[], + dims: ChartDimensions, + threshold?: number, +) { + const allY = series.flatMap((s) => s.data.map((d) => d.y)) + if (threshold != null) allY.push(threshold) + const min = 0 + const max = Math.max(...allY, 1) + const range = max - min + + const plotH = dims.height - dims.paddingTop - dims.paddingBottom + const toY = (val: number) => dims.paddingTop + plotH - ((val - min) / range) * plotH + + return { min, max, range, toY } +} + +export function computeXScale(series: ChartSeries[], dims: ChartDimensions) { + const allX = series.flatMap((s) => + s.data.map((d) => (d.x instanceof Date ? d.x.getTime() : d.x)), + ) + const minX = Math.min(...allX) + const maxX = Math.max(...allX) + const rangeX = maxX - minX || 1 + + const plotW = dims.width - dims.paddingLeft - dims.paddingRight + const toX = (val: number | Date) => { + const v = val instanceof Date ? val.getTime() : val + return dims.paddingLeft + ((v - minX) / rangeX) * plotW + } + + return { minX, maxX, rangeX, toX } +} + +export function seriesPoints( + series: ChartSeries, + toX: (v: number | Date) => number, + toY: (v: number) => number, +): string { + return series.data.map((d) => `${toX(d.x).toFixed(1)},${toY(d.y).toFixed(1)}`).join(' ') +} + +export function seriesPath( + series: ChartSeries, + toX: (v: number | Date) => number, + toY: (v: number) => number, + bottomY: number, +): string { + if (series.data.length === 0) return '' + const pts = series.data.map((d) => `${toX(d.x).toFixed(1)},${toY(d.y).toFixed(1)}`) + const firstX = toX(series.data[0].x).toFixed(1) + const lastX = toX(series.data[series.data.length - 1].x).toFixed(1) + return `M${pts.join(' L')} L${lastX},${bottomY} L${firstX},${bottomY} Z` +} + +export function formatAxisLabel(val: number): string { + if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M` + if (val >= 1000) return `${(val / 1000).toFixed(1)}k` + if (Number.isInteger(val)) return String(val) + return val.toFixed(1) +} + +export function formatXLabel(val: number | Date, totalPoints: number): string { + if (val instanceof Date || (typeof val === 'number' && val > 1e10)) { + const d = val instanceof Date ? val : new Date(val) + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + } + return formatAxisLabel(val as number) +} + +// Default color tokens for series (fallback when no color given) +export const CHART_COLORS = [ + 'var(--chart-1)', + 'var(--chart-2)', + 'var(--chart-3)', + 'var(--chart-4)', + 'var(--chart-5)', + 'var(--chart-6)', + 'var(--chart-7)', + 'var(--chart-8)', +]