Files
design-system/src/design-system/composites/AreaChart/AreaChart.tsx
hsiegeln f88e83dd0a feat: AreaChart, LineChart, BarChart SVG composites
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:58:03 +01:00

255 lines
6.9 KiB
TypeScript

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 <div className={`${styles.empty} ${className ?? ''}`}>No data</div>
}
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<SVGSVGElement>) {
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 (
<div className={`${styles.wrapper} ${className ?? ''}`}>
{yLabel && <div className={styles.yLabel}>{yLabel}</div>}
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className={styles.svg}
onMouseMove={handleMouseMove}
onMouseLeave={() => setTooltip(null)}
aria-label="Area chart"
role="img"
>
{/* Grid lines */}
{yTicks.map((val) => {
const y = toY(val)
return (
<g key={val}>
<line
x1={dims.paddingLeft}
y1={y}
x2={width - dims.paddingRight}
y2={y}
className={styles.gridLine}
/>
<text x={dims.paddingLeft - 6} y={y + 4} className={styles.axisLabel} textAnchor="end">
{formatAxisLabel(val)}
</text>
</g>
)
})}
{/* 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 (
<text
key={i}
x={xPos}
y={height - dims.paddingBottom + 16}
className={styles.axisLabel}
textAnchor={anchor}
>
{label}
</text>
)
})}
{/* SLA threshold line */}
{threshold && (
<g>
<line
x1={dims.paddingLeft}
y1={toY(threshold.value)}
x2={width - dims.paddingRight}
y2={toY(threshold.value)}
className={styles.thresholdLine}
/>
<text
x={width - dims.paddingRight - 4}
y={toY(threshold.value) - 4}
className={styles.thresholdLabel}
textAnchor="end"
>
{threshold.label}
</text>
</g>
)}
{/* Area fills */}
{series.map((s, i) => {
const color = s.color ?? CHART_COLORS[i % CHART_COLORS.length]
const areaD = seriesPath(s, toX, toY, bottomY)
return (
<path
key={`area-${i}`}
d={areaD}
fill={color}
className={styles.area}
/>
)
})}
{/* Lines */}
{series.map((s, i) => {
const color = s.color ?? CHART_COLORS[i % CHART_COLORS.length]
const pts = seriesPoints(s, toX, toY)
return (
<polyline
key={`line-${i}`}
points={pts}
fill="none"
stroke={color}
className={styles.line}
/>
)
})}
{/* Crosshair */}
{tooltip && (
<line
x1={tooltip.x}
y1={dims.paddingTop}
x2={tooltip.x}
y2={bottomY}
className={styles.crosshair}
/>
)}
</svg>
{/* Tooltip */}
{tooltip && (
<div
className={styles.tooltip}
style={{
left: tooltip.x + 12,
top: tooltip.y,
}}
>
{tooltip.values.map((v) => (
<div key={v.label} className={styles.tooltipRow}>
<span className={styles.tooltipDot} style={{ background: v.color }} />
<span className={styles.tooltipLabel}>{v.label}:</span>
<span className={styles.tooltipValue}>{formatAxisLabel(v.value)}</span>
</div>
))}
</div>
)}
{/* Legend */}
{series.length > 1 && (
<div className={styles.legend}>
{series.map((s, i) => (
<div key={s.label} className={styles.legendItem}>
<span
className={styles.legendDot}
style={{ background: s.color ?? CHART_COLORS[i % CHART_COLORS.length] }}
/>
<span className={styles.legendLabel}>{s.label}</span>
</div>
))}
</div>
)}
{xLabel && <div className={styles.xLabel}>{xLabel}</div>}
</div>
)
}