Spaces:
Paused
Paused
Download src/components/visual/charts.tsx from whassan/SensorflowStudio: direct link, hf CLI and curl.
- Browser
- Download file 15.2 kB
-
https://huggingface.co/spaces/whassan/SensorflowStudio/resolve/main/src/components/visual/charts.tsx
- Command line
-
hf download hf://spaces/whassan/SensorflowStudio/src/components/visual/charts.tsx
-
curl -L -o charts.tsx https://huggingface.co/spaces/whassan/SensorflowStudio/resolve/main/src/components/visual/charts.tsx
15.2 kB
| /** | |
| * Shared lightweight SVG chart components used across the new visual pages: | |
| * HeatmapGrid (ODD coverage, regression map), Donut (composition), DeltaBars | |
| * (paired baseline/candidate deltas), and SeriesChart (multi-line with | |
| * reference boundaries — the base of the sequential-evidence chart). | |
| * No chart library: everything is plain SVG in theme colors. | |
| */ | |
| import { useMemo, useState, type ReactNode } from 'react'; | |
| import Box from '@mui/material/Box'; | |
| import Tooltip from '@mui/material/Tooltip'; | |
| import Typography from '@mui/material/Typography'; | |
| import { tokens } from '../../theme'; | |
| // ---------------------------------------------------------------- HeatmapGrid | |
| export interface HeatCell { | |
| row: string; | |
| col: string; | |
| value: number | null; | |
| /** Fully resolved fill color; the page owns the color semantics. */ | |
| color: string; | |
| label?: string; | |
| tooltip?: ReactNode; | |
| } | |
| interface HeatmapGridProps { | |
| rows: string[]; | |
| cols: string[]; | |
| cells: HeatCell[]; | |
| onCellClick?: (cell: HeatCell) => void; | |
| selectedKey?: string | null; // `${row}|${col}` | |
| cellH?: number; | |
| minCellW?: number; | |
| rowLabelW?: number; | |
| } | |
| export function HeatmapGrid({ | |
| rows, | |
| cols, | |
| cells, | |
| onCellClick, | |
| selectedKey, | |
| cellH = 34, | |
| minCellW = 56, | |
| rowLabelW = 120, | |
| }: HeatmapGridProps) { | |
| const byKey = useMemo(() => { | |
| const m = new Map<string, HeatCell>(); | |
| cells.forEach((c) => m.set(`${c.row}|${c.col}`, c)); | |
| return m; | |
| }, [cells]); | |
| return ( | |
| <Box sx={{ overflowX: 'auto' }}> | |
| <Box sx={{ display: 'grid', gridTemplateColumns: `${rowLabelW}px repeat(${cols.length}, minmax(${minCellW}px, 1fr))`, gap: '3px', minWidth: rowLabelW + cols.length * minCellW }}> | |
| <Box /> | |
| {cols.map((c) => ( | |
| <Typography key={c} variant="caption" sx={{ color: tokens.color.textDim, textAlign: 'center', fontSize: 10.5, alignSelf: 'end', pb: 0.25, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> | |
| {c} | |
| </Typography> | |
| ))} | |
| {rows.map((r) => ( | |
| <Box key={r} sx={{ display: 'contents' }}> | |
| <Typography variant="caption" sx={{ color: tokens.color.textDim, fontSize: 10.5, alignSelf: 'center', pr: 0.5, textAlign: 'right', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> | |
| {r} | |
| </Typography> | |
| {cols.map((c) => { | |
| const cell = byKey.get(`${r}|${c}`); | |
| const selected = selectedKey === `${r}|${c}`; | |
| const body = ( | |
| <Box | |
| key={c} | |
| role={onCellClick ? 'button' : undefined} | |
| tabIndex={onCellClick && cell ? 0 : undefined} | |
| onClick={cell && onCellClick ? () => onCellClick(cell) : undefined} | |
| onKeyDown={cell && onCellClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onCellClick(cell); } : undefined} | |
| sx={{ | |
| height: cellH, | |
| borderRadius: '3px', | |
| bgcolor: cell ? cell.color : tokens.color.surfaceSunken, | |
| border: selected ? `2px solid #fff` : `1px solid rgba(0,0,0,0.35)`, | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| cursor: cell && onCellClick ? 'pointer' : 'default', | |
| transition: `transform ${tokens.motion.fast}, border-color ${tokens.motion.fast}`, | |
| '&:hover': cell && onCellClick ? { transform: 'scale(1.06)', zIndex: 1 } : undefined, | |
| '&:focus-visible': { outline: `2px solid ${tokens.color.info}`, outlineOffset: 1 }, | |
| }} | |
| > | |
| <Typography variant="caption" sx={{ fontSize: 9.5, fontFamily: 'monospace', color: 'rgba(255,255,255,0.92)', fontWeight: 600 }}> | |
| {cell?.label ?? ''} | |
| </Typography> | |
| </Box> | |
| ); | |
| return cell?.tooltip ? ( | |
| <Tooltip key={c} title={cell.tooltip} enterDelay={150} slotProps={{ tooltip: { sx: { bgcolor: '#1d242c', border: `1px solid ${tokens.color.borderStrong}`, p: 1.25, maxWidth: 360 } } }}> | |
| {body} | |
| </Tooltip> | |
| ) : ( | |
| body | |
| ); | |
| })} | |
| </Box> | |
| ))} | |
| </Box> | |
| </Box> | |
| ); | |
| } | |
| // ---------------------------------------------------------------- Donut | |
| export interface DonutSegment { | |
| label: string; | |
| value: number; | |
| color: string; | |
| } | |
| export function Donut({ segments, size = 150, thickness = 22, centerLabel, centerSub }: { segments: DonutSegment[]; size?: number; thickness?: number; centerLabel?: string; centerSub?: string }) { | |
| const total = segments.reduce((s, x) => s + x.value, 0); | |
| const r = (size - thickness) / 2; | |
| const cx = size / 2; | |
| const circumference = 2 * Math.PI * r; | |
| let acc = 0; | |
| return ( | |
| <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}> | |
| <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}> | |
| <circle cx={cx} cy={cx} r={r} fill="none" stroke={tokens.color.border} strokeWidth={thickness} /> | |
| {total > 0 | |
| ? segments.map((s) => { | |
| const frac = s.value / total; | |
| const dash = frac * circumference; | |
| const el = ( | |
| <circle | |
| key={s.label} | |
| cx={cx} | |
| cy={cx} | |
| r={r} | |
| fill="none" | |
| stroke={s.color} | |
| strokeWidth={thickness} | |
| strokeDasharray={`${dash} ${circumference - dash}`} | |
| strokeDashoffset={-acc * circumference + circumference / 4} | |
| style={{ transition: `stroke-dasharray ${tokens.motion.slow}` }} | |
| /> | |
| ); | |
| acc += frac; | |
| return el; | |
| }) | |
| : null} | |
| {centerLabel ? ( | |
| <text x={cx} y={cx + (centerSub ? -2 : 5)} textAnchor="middle" fill={tokens.color.text} fontSize={18} fontWeight={800}> | |
| {centerLabel} | |
| </text> | |
| ) : null} | |
| {centerSub ? ( | |
| <text x={cx} y={cx + 15} textAnchor="middle" fill={tokens.color.neutral} fontSize={9.5}> | |
| {centerSub} | |
| </text> | |
| ) : null} | |
| </svg> | |
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | |
| {segments.map((s) => ( | |
| <Box key={s.label} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}> | |
| <Box sx={{ width: 10, height: 10, borderRadius: '2px', bgcolor: s.color }} /> | |
| <Typography variant="caption" sx={{ color: tokens.color.textDim }}> | |
| {s.label} | |
| </Typography> | |
| <Typography variant="caption" sx={{ fontFamily: 'monospace', color: tokens.color.text }}> | |
| {s.value.toLocaleString()} | |
| {total > 0 ? ` (${((s.value / total) * 100).toFixed(1)}%)` : ''} | |
| </Typography> | |
| </Box> | |
| ))} | |
| </Box> | |
| </Box> | |
| ); | |
| } | |
| // ---------------------------------------------------------------- DeltaBars | |
| export interface DeltaBarRow { | |
| label: string; | |
| baseline: number; | |
| candidate: number; | |
| delta: number; | |
| n?: number; | |
| improved: boolean; | |
| tooltip?: ReactNode; | |
| } | |
| /** Paired horizontal bars (baseline vs candidate) with a signed delta chip. */ | |
| export function DeltaBars({ rows, max = 1, fmt = (v: number) => v.toFixed(3) }: { rows: DeltaBarRow[]; max?: number; fmt?: (v: number) => string }) { | |
| return ( | |
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> | |
| {rows.map((r) => { | |
| const body = ( | |
| <Box key={r.label} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> | |
| <Typography variant="caption" sx={{ width: 130, flexShrink: 0, color: tokens.color.textDim, textAlign: 'right', pr: 0.5, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> | |
| {r.label} | |
| {r.n !== undefined ? <span style={{ color: tokens.color.textFaint }}> · n={r.n.toLocaleString()}</span> : null} | |
| </Typography> | |
| <Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '3px' }}> | |
| <Box sx={{ height: 7, bgcolor: tokens.color.border, borderRadius: 1, overflow: 'hidden' }}> | |
| <Box sx={{ height: '100%', width: `${Math.min(100, (r.baseline / max) * 100)}%`, bgcolor: tokens.color.textFaint, transition: `width ${tokens.motion.slow}` }} /> | |
| </Box> | |
| <Box sx={{ height: 7, bgcolor: tokens.color.border, borderRadius: 1, overflow: 'hidden' }}> | |
| <Box sx={{ height: '100%', width: `${Math.min(100, (r.candidate / max) * 100)}%`, bgcolor: r.improved ? tokens.color.success : tokens.color.danger, transition: `width ${tokens.motion.slow}` }} /> | |
| </Box> | |
| </Box> | |
| <Typography | |
| variant="caption" | |
| sx={{ | |
| width: 88, | |
| flexShrink: 0, | |
| fontFamily: 'monospace', | |
| fontWeight: 700, | |
| color: r.improved ? tokens.color.success : tokens.color.danger, | |
| }} | |
| > | |
| {r.delta >= 0 ? '+' : ''} | |
| {fmt(r.delta)} | |
| </Typography> | |
| </Box> | |
| ); | |
| return r.tooltip ? ( | |
| <Tooltip key={r.label} title={r.tooltip} enterDelay={150} placement="top" slotProps={{ tooltip: { sx: { bgcolor: '#1d242c', border: `1px solid ${tokens.color.borderStrong}`, p: 1.25, maxWidth: 380 } } }}> | |
| {body} | |
| </Tooltip> | |
| ) : ( | |
| body | |
| ); | |
| })} | |
| </Box> | |
| ); | |
| } | |
| // ---------------------------------------------------------------- SeriesChart | |
| export interface Series { | |
| id: string; | |
| color: string; | |
| points: Array<{ x: number; y: number; meta?: unknown }>; | |
| width?: number; | |
| dashed?: boolean; | |
| } | |
| interface SeriesChartProps { | |
| series: Series[]; | |
| height?: number; | |
| xLabel: string; | |
| yLabel: string; | |
| refLinesY?: Array<{ y: number; label: string; color?: string; dashed?: boolean }>; | |
| /** Vertical marker, e.g. "stopped here". */ | |
| markerX?: { x: number; label: string; color?: string } | null; | |
| onHoverPoint?: (seriesId: string | null, point: { x: number; y: number; meta?: unknown } | null) => void; | |
| yDomain?: [number, number]; | |
| xLog?: boolean; | |
| } | |
| const SPAD = { l: 52, r: 14, t: 12, b: 34 }; | |
| const SVIEW_W = 720; | |
| export function SeriesChart({ series, height = 300, xLabel, yLabel, refLinesY = [], markerX = null, onHoverPoint, yDomain }: SeriesChartProps) { | |
| const [hover, setHover] = useState<{ sid: string; x: number; y: number } | null>(null); | |
| const allX = series.flatMap((s) => s.points.map((p) => p.x)); | |
| const allY = [...series.flatMap((s) => s.points.map((p) => p.y)), ...refLinesY.map((r) => r.y)]; | |
| const xLo = allX.length ? Math.min(...allX) : 0; | |
| const xHi = allX.length ? Math.max(...allX, xLo + 1) : 1; | |
| let yLo: number; | |
| let yHi: number; | |
| if (yDomain) { | |
| [yLo, yHi] = yDomain; | |
| } else { | |
| yLo = allY.length ? Math.min(...allY) : 0; | |
| yHi = allY.length ? Math.max(...allY, yLo + 1) : 1; | |
| const pad = (yHi - yLo) * 0.1 || 1; | |
| yLo -= pad; | |
| yHi += pad; | |
| } | |
| const sx = (x: number) => SPAD.l + ((x - xLo) / (xHi - xLo || 1)) * (SVIEW_W - SPAD.l - SPAD.r); | |
| const sy = (y: number) => height - SPAD.b - ((y - yLo) / (yHi - yLo || 1)) * (height - SPAD.t - SPAD.b); | |
| const xTicks = Array.from({ length: 6 }, (_, i) => xLo + ((xHi - xLo) * i) / 5); | |
| const yTicks = Array.from({ length: 5 }, (_, i) => yLo + ((yHi - yLo) * i) / 4); | |
| return ( | |
| <svg width="100%" viewBox={`0 0 ${SVIEW_W} ${height}`} style={{ display: 'block' }}> | |
| <line x1={SPAD.l} y1={height - SPAD.b} x2={SVIEW_W - SPAD.r} y2={height - SPAD.b} stroke={tokens.color.borderStrong} /> | |
| <line x1={SPAD.l} y1={SPAD.t} x2={SPAD.l} y2={height - SPAD.b} stroke={tokens.color.borderStrong} /> | |
| {xTicks.map((t) => ( | |
| <g key={`x${t}`}> | |
| <line x1={sx(t)} y1={height - SPAD.b} x2={sx(t)} y2={height - SPAD.b + 4} stroke={tokens.color.borderStrong} /> | |
| <text x={sx(t)} y={height - SPAD.b + 15} textAnchor="middle" fontSize={9.5} fill={tokens.color.neutral}> | |
| {Math.round(t).toLocaleString()} | |
| </text> | |
| </g> | |
| ))} | |
| {yTicks.map((t) => ( | |
| <g key={`y${t}`}> | |
| <line x1={SPAD.l} y1={sy(t)} x2={SVIEW_W - SPAD.r} y2={sy(t)} stroke={tokens.color.border} strokeDasharray="2 4" /> | |
| <text x={SPAD.l - 6} y={sy(t) + 3} textAnchor="end" fontSize={9.5} fill={tokens.color.neutral}> | |
| {Math.abs(t) >= 100 ? t.toFixed(0) : t.toFixed(1)} | |
| </text> | |
| </g> | |
| ))} | |
| <text x={(SPAD.l + SVIEW_W - SPAD.r) / 2} y={height - 5} textAnchor="middle" fontSize={11} fill={tokens.color.textDim}> | |
| {xLabel} | |
| </text> | |
| <text x={13} y={(SPAD.t + height - SPAD.b) / 2} textAnchor="middle" fontSize={11} fill={tokens.color.textDim} transform={`rotate(-90 13 ${(SPAD.t + height - SPAD.b) / 2})`}> | |
| {yLabel} | |
| </text> | |
| {refLinesY.map((r) => ( | |
| <g key={`ref${r.label}`}> | |
| <line x1={SPAD.l} y1={sy(r.y)} x2={SVIEW_W - SPAD.r} y2={sy(r.y)} stroke={r.color ?? tokens.color.warn} strokeDasharray={r.dashed === false ? undefined : '7 4'} strokeWidth={1.4} /> | |
| <text x={SVIEW_W - SPAD.r - 4} y={sy(r.y) - 5} textAnchor="end" fontSize={10} fontWeight={700} fill={r.color ?? tokens.color.warn}> | |
| {r.label} | |
| </text> | |
| </g> | |
| ))} | |
| {markerX ? ( | |
| <g> | |
| <line x1={sx(markerX.x)} y1={SPAD.t} x2={sx(markerX.x)} y2={height - SPAD.b} stroke={markerX.color ?? tokens.color.danger} strokeWidth={1.6} strokeDasharray="3 3" /> | |
| <text x={sx(markerX.x) + 5} y={SPAD.t + 11} fontSize={10} fontWeight={700} fill={markerX.color ?? tokens.color.danger}> | |
| {markerX.label} | |
| </text> | |
| </g> | |
| ) : null} | |
| {series.map((s) => ( | |
| <g key={s.id}> | |
| <polyline | |
| points={s.points.map((p) => `${sx(p.x)},${sy(p.y)}`).join(' ')} | |
| fill="none" | |
| stroke={s.color} | |
| strokeWidth={s.width ?? 1.8} | |
| strokeDasharray={s.dashed ? '5 4' : undefined} | |
| opacity={hover && hover.sid !== s.id ? 0.25 : 1} | |
| style={{ transition: `opacity ${tokens.motion.fast}` }} | |
| /> | |
| {s.points.map((p, i) => ( | |
| <circle | |
| key={i} | |
| cx={sx(p.x)} | |
| cy={sy(p.y)} | |
| r={hover?.sid === s.id && hover.x === p.x ? 5 : 3} | |
| fill={s.color} | |
| opacity={hover && hover.sid !== s.id ? 0.25 : 1} | |
| onPointerEnter={() => { | |
| setHover({ sid: s.id, x: p.x, y: p.y }); | |
| onHoverPoint?.(s.id, p); | |
| }} | |
| onPointerLeave={() => { | |
| setHover(null); | |
| onHoverPoint?.(null, null); | |
| }} | |
| style={{ cursor: 'pointer', transition: `r ${tokens.motion.fast}` }} | |
| /> | |
| ))} | |
| </g> | |
| ))} | |
| </svg> | |
| ); | |
| } | |