Files
SpaceWanderer/components/ui/line-chart.tsx
T
2026-09-28 16:13:35 +00:00

313 lines
9.0 KiB
TypeScript

'use client'
import * as React from 'react'
import { cn } from '@/lib/utils'
export type LineChartVariant = 'DEFAULT' | 'ACTIVE' | 'WARNING' | 'CRITICAL'
export interface LineChartSeries {
id: string
label: string
data: number[]
variant?: LineChartVariant
}
export interface LineChartProps extends React.HTMLAttributes<HTMLDivElement> {
series: LineChartSeries[]
labels: string[]
title?: string
height?: number
min?: number
max?: number
showDots?: boolean
showArea?: boolean
showGrid?: boolean
showLegend?: boolean
animated?: boolean
}
const LEFT = 40
const RIGHT = 8
const TOP = 8
const BOTTOM = 24
const SERIES_COLORS = [
'var(--color-green)',
'var(--color-amber)',
'var(--color-red)',
'var(--color-blue)',
]
const VARIANT_COLORS: Record<LineChartVariant, string> = {
DEFAULT: 'var(--text-secondary)',
ACTIVE: 'var(--color-green)',
WARNING: 'var(--color-amber)',
CRITICAL: 'var(--color-red)',
}
function seriesColor(s: LineChartSeries, idx: number): string {
if (s.variant) return VARIANT_COLORS[s.variant]
return SERIES_COLORS[idx % SERIES_COLORS.length]
}
const LineChart = React.forwardRef<HTMLDivElement, LineChartProps>(
(
{
className,
series,
labels,
title,
height = 160,
min,
max,
showDots = true,
showArea = false,
showGrid = true,
showLegend,
animated = true,
style,
...props
},
ref
) => {
const containerRef = React.useRef<HTMLDivElement>(null)
const [svgW, setSvgW] = React.useState(500)
const [drawn, setDrawn] = React.useState(false)
React.useEffect(() => {
if (!containerRef.current) return
const ro = new ResizeObserver(entries => {
setSvgW(entries[0].contentRect.width)
})
ro.observe(containerRef.current)
return () => ro.disconnect()
}, [])
React.useEffect(() => {
const id = requestAnimationFrame(() => setDrawn(true))
return () => cancelAnimationFrame(id)
}, [])
const allValues = series.flatMap(s => s.data)
const dataMin = allValues.length > 0 ? Math.min(...allValues) : 0
const dataMax = allValues.length > 0 ? Math.max(...allValues) : 100
const minV = min ?? dataMin
const maxV = max ?? dataMax
const range = Math.max(maxV - minV, 1)
const plotW = svgW - LEFT - RIGHT
const plotH = height - TOP - BOTTOM
const n = labels.length
function xPos(i: number) {
return LEFT + (n <= 1 ? plotW / 2 : (i / (n - 1)) * plotW)
}
function yPos(v: number) {
return TOP + (1 - (v - minV) / range) * plotH
}
function buildLinePath(data: number[]): string {
return data
.map((v, i) => `${i === 0 ? 'M' : 'L'} ${xPos(i).toFixed(1)} ${yPos(v).toFixed(1)}`)
.join(' ')
}
function buildAreaPath(data: number[]): string {
if (data.length === 0) return ''
const line = buildLinePath(data)
const firstX = xPos(0).toFixed(1)
const lastX = xPos(data.length - 1).toFixed(1)
const bottomY = (TOP + plotH).toFixed(1)
return `${line} L ${lastX} ${bottomY} L ${firstX} ${bottomY} Z`
}
const gridFracs = [0, 1 / 3, 2 / 3, 1]
const showLeg = showLegend ?? series.length > 1
const step = Math.max(1, Math.ceil(n / 8))
const shouldAnimate = animated
return (
<div
ref={ref}
className={cn(className)}
style={{
border: '1px solid var(--border)',
background: 'var(--surface)',
fontFamily: 'var(--font-mono)',
overflow: 'hidden',
...style,
}}
{...props}
>
{title && (
<div
style={{
padding: '0.4rem 0.75rem',
borderBottom: '1px solid var(--border)',
background: 'var(--surface-raised)',
fontSize: '0.6rem',
color: 'var(--text-muted)',
letterSpacing: '0.12em',
textTransform: 'uppercase',
}}
>
{title}
</div>
)}
<div ref={containerRef} style={{ width: '100%', height: `${height}px` }}>
<svg width={svgW} height={height} style={{ display: 'block' }}>
{/* Grid */}
{showGrid && gridFracs.map((f, gi) => {
const y = TOP + f * plotH
const val = minV + (1 - f) * range
return (
<g key={gi}>
<line
x1={LEFT}
y1={y.toFixed(1)}
x2={(LEFT + plotW).toFixed(1)}
y2={y.toFixed(1)}
stroke="var(--border)"
strokeWidth={0.5}
/>
<text
x={LEFT - 4}
y={y}
textAnchor="end"
dominantBaseline="middle"
fontSize={9}
fill="var(--text-muted)"
fontFamily="var(--font-mono)"
>
{val.toFixed(0)}
</text>
</g>
)
})}
{/* X-axis labels */}
{labels.map((label, i) => {
if (i % step !== 0 && i !== n - 1) return null
return (
<text
key={i}
x={xPos(i)}
y={TOP + plotH + 14}
textAnchor="middle"
fontSize={9}
fill="var(--text-muted)"
fontFamily="var(--font-mono)"
>
{label}
</text>
)
})}
{/* Series */}
{series.map((s, si) => {
if (s.data.length === 0) return null
const color = seriesColor(s, si)
const linePath = buildLinePath(s.data)
const areaPath = buildAreaPath(s.data)
return (
<g key={s.id}>
{/* Area fill */}
{showArea && (
<path d={areaPath} fill={color} opacity={0.1} />
)}
{/* Glow layer */}
<path
d={linePath}
fill="none"
stroke={color}
strokeWidth={6}
opacity={0.12}
pathLength={100}
strokeDasharray={100}
strokeDashoffset={shouldAnimate ? (drawn ? 0 : 100) : 0}
style={shouldAnimate ? { transition: 'stroke-dashoffset 0.8s ease' } : undefined}
/>
{/* Main line */}
<path
d={linePath}
fill="none"
stroke={color}
strokeWidth={1.5}
pathLength={100}
strokeDasharray={100}
strokeDashoffset={shouldAnimate ? (drawn ? 0 : 100) : 0}
style={shouldAnimate ? { transition: 'stroke-dashoffset 0.8s ease' } : undefined}
/>
{/* Dots */}
{showDots && s.data.map((v, i) => (
<circle
key={i}
cx={xPos(i)}
cy={yPos(v)}
r={3}
fill={color}
opacity={shouldAnimate ? (drawn ? 1 : 0) : 1}
style={shouldAnimate ? { transition: 'opacity 0.8s ease 0.4s' } : undefined}
/>
))}
</g>
)
})}
</svg>
</div>
{/* Legend */}
{showLeg && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.75rem',
padding: '0.5rem 0.75rem',
borderTop: '1px solid var(--border)',
background: 'var(--surface-raised)',
}}
>
{series.map((s, si) => {
const color = seriesColor(s, si)
return (
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<div
style={{
width: '0.5rem',
height: '0.5rem',
borderRadius: '50%',
background: color,
flexShrink: 0,
}}
/>
<span
style={{
fontSize: '0.6rem',
color: 'var(--text-muted)',
letterSpacing: '0.06em',
textTransform: 'uppercase',
}}
>
{s.label}
</span>
</div>
)
})}
</div>
)}
</div>
)
}
)
LineChart.displayName = 'LineChart'
export { LineChart }