feat: add LineChart, AreaChart, BarChart wrapper components
Wrap ThemedChart with convenient series-based API that transforms ChartSeries[] into the flat record format ThemedChart expects. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
107
src/design-system/composites/AreaChart/AreaChart.tsx
Normal file
107
src/design-system/composites/AreaChart/AreaChart.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Area, ReferenceLine } from 'recharts'
|
||||
import { ThemedChart } from '../ThemedChart/ThemedChart'
|
||||
import { CHART_COLORS } from '../../utils/rechartsTheme'
|
||||
|
||||
export interface DataPoint {
|
||||
x: any
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface ChartSeries {
|
||||
label: string
|
||||
data: DataPoint[]
|
||||
color?: string
|
||||
}
|
||||
|
||||
interface AreaChartProps {
|
||||
series: ChartSeries[]
|
||||
height?: number
|
||||
width?: number
|
||||
yLabel?: string
|
||||
xLabel?: string
|
||||
thresholdValue?: number
|
||||
thresholdLabel?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function formatTime(d: Date): string {
|
||||
const h = String(d.getHours()).padStart(2, '0')
|
||||
const m = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${h}:${m}`
|
||||
}
|
||||
|
||||
export function AreaChart({
|
||||
series,
|
||||
height,
|
||||
width,
|
||||
yLabel,
|
||||
xLabel,
|
||||
thresholdValue,
|
||||
thresholdLabel,
|
||||
className,
|
||||
}: AreaChartProps) {
|
||||
const { data, hasDateX } = useMemo(() => {
|
||||
const map = new Map<string, Record<string, any>>()
|
||||
let dateDetected = false
|
||||
|
||||
for (const s of series) {
|
||||
for (const pt of s.data) {
|
||||
const isDate = pt.x instanceof Date
|
||||
if (isDate) dateDetected = true
|
||||
const key = isDate ? pt.x.getTime().toString() : String(pt.x)
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { _x: isDate ? formatTime(pt.x) : pt.x })
|
||||
}
|
||||
map.get(key)![s.label] = pt.y
|
||||
}
|
||||
}
|
||||
|
||||
return { data: Array.from(map.values()), hasDateX: dateDetected }
|
||||
}, [series])
|
||||
|
||||
const chart = (
|
||||
<ThemedChart
|
||||
data={data}
|
||||
height={height}
|
||||
xDataKey="_x"
|
||||
xType={hasDateX ? 'category' : 'category'}
|
||||
xTickFormatter={hasDateX ? (v: any) => String(v) : undefined}
|
||||
yLabel={yLabel}
|
||||
className={className}
|
||||
>
|
||||
{series.map((s, i) => {
|
||||
const color = s.color ?? CHART_COLORS[i % CHART_COLORS.length]
|
||||
return (
|
||||
<Area
|
||||
key={s.label}
|
||||
type="monotone"
|
||||
dataKey={s.label}
|
||||
stroke={color}
|
||||
fill={color}
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{thresholdValue != null && (
|
||||
<ReferenceLine
|
||||
y={thresholdValue}
|
||||
stroke="var(--text-muted)"
|
||||
strokeDasharray="4 4"
|
||||
label={thresholdLabel ? {
|
||||
value: thresholdLabel,
|
||||
position: 'insideTopRight',
|
||||
style: { fontSize: 10, fill: 'var(--text-muted)' },
|
||||
} : undefined}
|
||||
/>
|
||||
)}
|
||||
</ThemedChart>
|
||||
)
|
||||
|
||||
if (width) {
|
||||
return <div style={{ width }}>{chart}</div>
|
||||
}
|
||||
|
||||
return chart
|
||||
}
|
||||
88
src/design-system/composites/BarChart/BarChart.tsx
Normal file
88
src/design-system/composites/BarChart/BarChart.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Bar } from 'recharts'
|
||||
import { ThemedChart } from '../ThemedChart/ThemedChart'
|
||||
import { CHART_COLORS } from '../../utils/rechartsTheme'
|
||||
|
||||
export interface DataPoint {
|
||||
x: any
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface ChartSeries {
|
||||
label: string
|
||||
data: DataPoint[]
|
||||
color?: string
|
||||
}
|
||||
|
||||
interface BarChartProps {
|
||||
series: ChartSeries[]
|
||||
height?: number
|
||||
width?: number
|
||||
yLabel?: string
|
||||
xLabel?: string
|
||||
stacked?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
function formatTime(d: Date): string {
|
||||
const h = String(d.getHours()).padStart(2, '0')
|
||||
const m = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${h}:${m}`
|
||||
}
|
||||
|
||||
export function BarChart({
|
||||
series,
|
||||
height,
|
||||
width,
|
||||
yLabel,
|
||||
xLabel,
|
||||
stacked,
|
||||
className,
|
||||
}: BarChartProps) {
|
||||
const { data, hasDateX } = useMemo(() => {
|
||||
const map = new Map<string, Record<string, any>>()
|
||||
let dateDetected = false
|
||||
|
||||
for (const s of series) {
|
||||
for (const pt of s.data) {
|
||||
const isDate = pt.x instanceof Date
|
||||
if (isDate) dateDetected = true
|
||||
const key = isDate ? pt.x.getTime().toString() : String(pt.x)
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { _x: isDate ? formatTime(pt.x) : pt.x })
|
||||
}
|
||||
map.get(key)![s.label] = pt.y
|
||||
}
|
||||
}
|
||||
|
||||
return { data: Array.from(map.values()), hasDateX: dateDetected }
|
||||
}, [series])
|
||||
|
||||
const chart = (
|
||||
<ThemedChart
|
||||
data={data}
|
||||
height={height}
|
||||
xDataKey="_x"
|
||||
xType="category"
|
||||
xTickFormatter={hasDateX ? (v: any) => String(v) : undefined}
|
||||
yLabel={yLabel}
|
||||
className={className}
|
||||
>
|
||||
{series.map((s, i) => (
|
||||
<Bar
|
||||
key={s.label}
|
||||
dataKey={s.label}
|
||||
fill={s.color ?? CHART_COLORS[i % CHART_COLORS.length]}
|
||||
radius={[2, 2, 0, 0]}
|
||||
{...(stacked ? { stackId: 'stack' } : {})}
|
||||
/>
|
||||
))}
|
||||
</ThemedChart>
|
||||
)
|
||||
|
||||
if (width) {
|
||||
return <div style={{ width }}>{chart}</div>
|
||||
}
|
||||
|
||||
return chart
|
||||
}
|
||||
101
src/design-system/composites/LineChart/LineChart.tsx
Normal file
101
src/design-system/composites/LineChart/LineChart.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Line, ReferenceLine } from 'recharts'
|
||||
import { ThemedChart } from '../ThemedChart/ThemedChart'
|
||||
import { CHART_COLORS } from '../../utils/rechartsTheme'
|
||||
|
||||
export interface DataPoint {
|
||||
x: any
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface ChartSeries {
|
||||
label: string
|
||||
data: DataPoint[]
|
||||
color?: string
|
||||
}
|
||||
|
||||
interface LineChartProps {
|
||||
series: ChartSeries[]
|
||||
height?: number
|
||||
width?: number
|
||||
yLabel?: string
|
||||
xLabel?: string
|
||||
threshold?: { value: number; label: string }
|
||||
className?: string
|
||||
}
|
||||
|
||||
function formatTime(d: Date): string {
|
||||
const h = String(d.getHours()).padStart(2, '0')
|
||||
const m = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${h}:${m}`
|
||||
}
|
||||
|
||||
export function LineChart({
|
||||
series,
|
||||
height,
|
||||
width,
|
||||
yLabel,
|
||||
xLabel,
|
||||
threshold,
|
||||
className,
|
||||
}: LineChartProps) {
|
||||
const { data, hasDateX } = useMemo(() => {
|
||||
const map = new Map<string, Record<string, any>>()
|
||||
let dateDetected = false
|
||||
|
||||
for (const s of series) {
|
||||
for (const pt of s.data) {
|
||||
const isDate = pt.x instanceof Date
|
||||
if (isDate) dateDetected = true
|
||||
const key = isDate ? pt.x.getTime().toString() : String(pt.x)
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { _x: isDate ? formatTime(pt.x) : pt.x })
|
||||
}
|
||||
map.get(key)![s.label] = pt.y
|
||||
}
|
||||
}
|
||||
|
||||
return { data: Array.from(map.values()), hasDateX: dateDetected }
|
||||
}, [series])
|
||||
|
||||
const chart = (
|
||||
<ThemedChart
|
||||
data={data}
|
||||
height={height}
|
||||
xDataKey="_x"
|
||||
xType={hasDateX ? 'category' : 'category'}
|
||||
xTickFormatter={hasDateX ? (v: any) => String(v) : undefined}
|
||||
yLabel={yLabel}
|
||||
className={className}
|
||||
>
|
||||
{series.map((s, i) => (
|
||||
<Line
|
||||
key={s.label}
|
||||
type="monotone"
|
||||
dataKey={s.label}
|
||||
stroke={s.color ?? CHART_COLORS[i % CHART_COLORS.length]}
|
||||
dot={false}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
{threshold && (
|
||||
<ReferenceLine
|
||||
y={threshold.value}
|
||||
stroke="var(--text-muted)"
|
||||
strokeDasharray="4 4"
|
||||
label={{
|
||||
value: threshold.label,
|
||||
position: 'insideTopRight',
|
||||
style: { fontSize: 10, fill: 'var(--text-muted)' },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ThemedChart>
|
||||
)
|
||||
|
||||
if (width) {
|
||||
return <div style={{ width }}>{chart}</div>
|
||||
}
|
||||
|
||||
return chart
|
||||
}
|
||||
@@ -42,6 +42,10 @@ export { TreeView } from './TreeView/TreeView'
|
||||
|
||||
// Charts — ThemedChart wrapper + Recharts re-exports
|
||||
export { ThemedChart } from './ThemedChart/ThemedChart'
|
||||
export { LineChart } from './LineChart/LineChart'
|
||||
export { AreaChart } from './AreaChart/AreaChart'
|
||||
export { BarChart } from './BarChart/BarChart'
|
||||
export type { ChartSeries, DataPoint } from './LineChart/LineChart'
|
||||
export { CHART_COLORS, rechartsTheme } from '../utils/rechartsTheme'
|
||||
export {
|
||||
Line, Area, Bar,
|
||||
|
||||
Reference in New Issue
Block a user