0889ee9117
feat(hooks): add useLiveStream generic WebSocket hook - supports websocket/sse/polling transports - exponential backoff reconnect with jitter - circular buffer with configurable size - typed filter callback per use case - manual disconnect + reconnect + error state feat(hooks): add useLiveMetrics derived hook - sliding time-window cut - moving average (configurable window) - current / avg / min / max / ratePerSecond - zero allocations per tick (memoized) feat(charts): add LiveMetricChart molecule (Recharts) - line + area variants, grid + tooltip - moving-average overlay (dashed) - ConnectionStatus atom in header - status bar + compact mode - 100% responsive, GPU via SVG ViewBox feat(atoms): add ConnectionStatus indicator - 5 states: disconnected/connecting/connected/reconnecting/error - animated pulse, JetBrains Mono, pill style - exported helpers: formatLatency / formatBytes docs(pkg): bump v0.1.0 → v0.2.0, add recharts peerDep
481 lines
15 KiB
TypeScript
481 lines
15 KiB
TypeScript
/**
|
||
* ═══════════════════════════════════════════════════════════════════
|
||
* packages/live-charts/src/components/LiveChart.tsx
|
||
* Molécula de gráfico em tempo real — consome useWebSocket.
|
||
* Renderiza em Canvas SVG via Recharts, thread-safe:
|
||
* - atualizações em lote de 60fps via requestAnimationFrame
|
||
* - sem re-renders do React por ponto individual
|
||
* - buffer acotovelado na origem (useWebSocket)
|
||
* ═══════════════════════════════════════════════════════════════════
|
||
*/
|
||
|
||
"use client";
|
||
|
||
import React, {
|
||
type CSSProperties,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
|
||
import {
|
||
Area,
|
||
AreaChart,
|
||
CartesianGrid,
|
||
ResponsiveContainer,
|
||
Tooltip,
|
||
XAxis,
|
||
YAxis,
|
||
} from "recharts";
|
||
|
||
import { useWebSocket } from "@pulse-libs/use-websocket";
|
||
import type { WSConfig, WSState } from "@pulse-libs/shared";
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// Tipos locais
|
||
// ─────────────────────────────────────────────────────────────────
|
||
|
||
export interface ChartDatum {
|
||
/** Eixo X — timestamp legível ou label */
|
||
x: string | number;
|
||
/** Eixo Y — valor numérico */
|
||
y: number;
|
||
/** Valor original cru antes de agregação */
|
||
raw?: unknown;
|
||
/** Opacity customizada por ponto (0-1) */
|
||
opacity?: number;
|
||
}
|
||
|
||
export interface LiveChartProps<T extends ChartDatum = ChartDatum> {
|
||
/**
|
||
* Configuração do WebSocket — obrigatória.
|
||
* O hook gerencia a conexão, buffet e reconexão internamente.
|
||
*/
|
||
wsConfig: WSConfig;
|
||
|
||
/**
|
||
* Transforma a mensagem WS bruta em pontos do gráfico.
|
||
* Retorne um array para múltiplas séries, ou um ponto único.
|
||
*/
|
||
mapper: (msg: unknown) => T | T[];
|
||
|
||
/**
|
||
* Mapeia `x` do ChartDatum para o label do eixo X.
|
||
* Default: timestamp → HH:mm:ss.
|
||
*/
|
||
xFormatter?: (v: number | string) => string;
|
||
|
||
/**
|
||
* Mapeia `y` do ChartDatum para tooltip.
|
||
*/
|
||
yFormatter?: (v: number) => string;
|
||
|
||
/**
|
||
* Máximo de pontos visíveis na janela deslizante.
|
||
* Mais antigos são removidos automaticamente.
|
||
* Default: 80 (aprox. 80 × intervalo do WM).
|
||
*/
|
||
visiblePoints?: number;
|
||
|
||
/**
|
||
* Intervalo mínimo entre renders do React em ms.
|
||
* Usa requestAnimationFrame internamente para não bloquear a thread.
|
||
* Default: 16 ≈ 60fps.
|
||
*/
|
||
renderIntervalMs?: number;
|
||
|
||
/** Cor da área preenchida. */
|
||
fillColor?: string;
|
||
/** Cor da linha de traço. */
|
||
strokeColor?: string;
|
||
/** Exibir grade do gráfico. */
|
||
showGrid?: boolean;
|
||
/** Exibir tooltip. */
|
||
showTooltip?: boolean;
|
||
/** Altura do container. */
|
||
height?: number;
|
||
/** CSS customizado. */
|
||
style?: CSSProperties;
|
||
/** classes CSS. */
|
||
className?: string;
|
||
|
||
/**
|
||
* Callback quando um novo dado é recebido via WS.
|
||
* Útil para acionar alertas ou atualizar KPIs externos.
|
||
*/
|
||
onDataPoint?: (datum: T) => void;
|
||
|
||
/**
|
||
* Callback para erros do WS.
|
||
*/
|
||
onError?: (err: Error) => void;
|
||
|
||
/**
|
||
* Callback para mudança de status da conexão.
|
||
* Útil para exibir indicadores de saúde no dashboard.
|
||
*/
|
||
onStatusChange?: (
|
||
status: WSState<T>["status"],
|
||
prev: WSState<T>["status"],
|
||
) => void;
|
||
|
||
/**
|
||
* Se `true`, limpa os dados ao desmontar (última snapshot).
|
||
* Default: `false` — mantém estado entre suspensões.
|
||
*/
|
||
resetOnUnmount?: boolean;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// Constantes
|
||
// ─────────────────────────────────────────────────────────────────
|
||
|
||
const DEFAULT_VISIBLE_POINTS = 80;
|
||
const DEFAULT_RENDER_INTERVAL = 16; // ~60fps
|
||
const DEFAULT_HEIGHT = 320;
|
||
|
||
const FILL_COLOR = "rgba(37, 99, 235, 0.15)";
|
||
const STROKE_COLOR = "#2563eb";
|
||
const GRID_STROKE = "rgba(51, 65, 85, 0.4)";
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// Helpers
|
||
// ─────────────────────────────────────────────────────────────────
|
||
|
||
function formatTimestamp(ts: number): string {
|
||
const d = new Date(ts);
|
||
return `${d.getHours().toString().padStart(2, "0")}:${d
|
||
.getMinutes()
|
||
.toString()
|
||
.padStart(2, "0")}:${d
|
||
.getSeconds()
|
||
.toString()
|
||
.padStart(2, "0")}`;
|
||
}
|
||
|
||
function slideWindow<T>(arr: T[], max: number): T[] {
|
||
return arr.length > max ? arr.slice(arr.length - max) : arr;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// Componente
|
||
// ─────────────────────────────────────────────────────────────────
|
||
|
||
export function LiveChart<T extends ChartDatum = ChartDatum>(
|
||
props: LiveChartProps<T>,
|
||
): React.ReactElement {
|
||
const {
|
||
wsConfig,
|
||
mapper,
|
||
xFormatter = formatTimestamp,
|
||
yFormatter = (v) => v.toFixed(2),
|
||
visiblePoints = DEFAULT_VISIBLE_POINTS,
|
||
renderIntervalMs = DEFAULT_RENDER_INTERVAL,
|
||
fillColor = FILL_COLOR,
|
||
strokeColor = STROKE_COLOR,
|
||
showGrid = true,
|
||
showTooltip = true,
|
||
height = DEFAULT_HEIGHT,
|
||
style,
|
||
className,
|
||
onDataPoint,
|
||
onError,
|
||
onStatusChange,
|
||
resetOnUnmount = false,
|
||
} = props;
|
||
|
||
const [displayData, setDisplayData] = useState<T[]>([]);
|
||
const rafRef = useRef<number>(0);
|
||
const pendingRef = useRef<T[]>([]);
|
||
const lastTsRef = useRef<number>(0);
|
||
|
||
// Bridge WS → displayData via rAF (GPU-friendly, single re-render/frame)
|
||
const pushPending = useCallback((incoming: T[]) => {
|
||
pendingRef.current = [...pendingRef.current, ...incoming];
|
||
}, []);
|
||
|
||
// rAF loop — consome pendingRef e atualiza displayData
|
||
useEffect(() => {
|
||
let running = true;
|
||
|
||
function tick(now: number) {
|
||
if (!running) return;
|
||
if (now - lastTsRef.current >= renderIntervalMs) {
|
||
lastTsRef.current = now;
|
||
const chunk = pendingRef.current;
|
||
if (chunk.length > 0) {
|
||
pendingRef.current = [];
|
||
setDisplayData((prev) =>
|
||
slideWindow([...prev, ...chunk], visiblePoints),
|
||
);
|
||
}
|
||
}
|
||
rafRef.current = requestAnimationFrame(tick);
|
||
}
|
||
|
||
rafRef.current = requestAnimationFrame(tick);
|
||
return () => {
|
||
running = false;
|
||
cancelAnimationFrame(rafRef.current);
|
||
};
|
||
}, [renderIntervalMs, visiblePoints]);
|
||
|
||
// Hook WebSocket
|
||
const wsState = useWebSocket<T & { raw?: unknown }>({
|
||
...wsConfig,
|
||
onMessage: useCallback(
|
||
(msg) => {
|
||
const mapped = mapper(msg.payload);
|
||
const arr = Array.isArray(mapped) ? mapped : [mapped];
|
||
arr.forEach((d) => onDataPoint?.(d as T));
|
||
pushPending(arr as T[]);
|
||
},
|
||
[mapper, onDataPoint, pushPending],
|
||
),
|
||
onError: useCallback(
|
||
(err) => { onError?.(err); },
|
||
[onError],
|
||
),
|
||
onStatus: useCallback(
|
||
(status, _prev) => {
|
||
onStatusChange?.(status as WSState<T>["status"], _prev as WSState<T>["status"]);
|
||
},
|
||
[onStatusChange],
|
||
),
|
||
});
|
||
|
||
// cleanup no unmount
|
||
useEffect(
|
||
() => () => {
|
||
if (resetOnUnmount) setDisplayData([]);
|
||
pendingRef.current = [];
|
||
},
|
||
[resetOnUnmount],
|
||
);
|
||
|
||
// ── derived stats ───────────────────────────────────────────────
|
||
const stats = useMemo(() => {
|
||
if (displayData.length === 0) return null;
|
||
const values = displayData.map((d) => d.y);
|
||
const sum = values.reduce((a, b) => a + b, 0);
|
||
return {
|
||
current: values[values.length - 1]!,
|
||
min: Math.min(...values),
|
||
max: Math.max(...values),
|
||
average: sum / values.length,
|
||
total: sum,
|
||
};
|
||
}, [displayData]);
|
||
|
||
// ── ChartData formatada para Recharts ───────────────────────────
|
||
const chartData = useMemo(
|
||
() =>
|
||
displayData.map((d) => ({
|
||
x: typeof d.x === "number" ? xFormatter(d.x) : d.x,
|
||
y: d.y,
|
||
opacity: d.opacity ?? 1,
|
||
})),
|
||
[displayData, xFormatter],
|
||
);
|
||
|
||
// ── Renders ─────────────────────────────────────────────────────
|
||
if (wsState.status === "error" || wsState.status === "closed") {
|
||
return (
|
||
<div
|
||
className={className}
|
||
style={{
|
||
height,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
background: "rgba(220, 38, 38, 0.05)",
|
||
border: "1px solid rgba(220, 38, 38, 0.2)",
|
||
borderRadius: 12,
|
||
color: "#f87171",
|
||
fontSize: "0.875rem",
|
||
fontFamily: "JetBrains Mono, monospace",
|
||
...style,
|
||
}}
|
||
>
|
||
⚠ Conexao WS indisponível —{" "}
|
||
{wsState.error?.message ?? "verifique o broker"}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={className}
|
||
role="group"
|
||
aria-label="Grafico em tempo real"
|
||
aria-live="polite"
|
||
style={{ ...style }}
|
||
>
|
||
{/* ── Header com KPIs ── */}
|
||
{stats && (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
gap: "1rem",
|
||
marginBottom: "0.75rem",
|
||
flexWrap: "wrap",
|
||
fontFamily: "'JetBrains Mono', monospace",
|
||
fontSize: "0.75rem",
|
||
}}
|
||
aria-label="Estatisticas do grafico"
|
||
>
|
||
<Kpi label="Atual" value={yFormatter(stats.current)} color="#60a5fa" />
|
||
<Kpi label="Min" value={yFormatter(stats.min)} color="#34d399" />
|
||
<Kpi label="Max" value={yFormatter(stats.max)} color="#fbbf24" />
|
||
<Kpi label="Media" value={yFormatter(stats.average)} color="#a78bfa" />
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "0.4rem",
|
||
color: WS_STATUS_COLOR[wsState.status],
|
||
}}
|
||
aria-label={`Status WS: ${wsState.status}`}
|
||
>
|
||
<span
|
||
style={{
|
||
width: 8,
|
||
height: 8,
|
||
borderRadius: "50%",
|
||
background: WS_STATUS_COLOR[wsState.status],
|
||
display: "inline-block",
|
||
}}
|
||
/>
|
||
{wsState.status}
|
||
{wsState.latencyMs != null && (
|
||
<span style={{ color: "#94a3b8" }}>
|
||
· {wsState.latencyMs}ms
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Recharts AreaChart ── */}
|
||
<ResponsiveContainer width="100%" height={height}>
|
||
<AreaChart
|
||
data={chartData}
|
||
margin={{ top: 8, right: 16, bottom: 0, left: -12 }}
|
||
syncId="live-chart-sync"
|
||
aria-label="Grafico de linha em tempo real"
|
||
>
|
||
{showGrid && (
|
||
<CartesianGrid
|
||
stroke={GRID_STROKE}
|
||
strokeDasharray="3 3"
|
||
vertical={false}
|
||
/>
|
||
)}
|
||
<XAxis
|
||
dataKey="x"
|
||
tick={{ fill: "#64748b", fontSize: 10, fontFamily: "JetBrains Mono" }}
|
||
axisLine={{ stroke: GRID_STROKE }}
|
||
tickLine={false}
|
||
minTickGap={50}
|
||
/>
|
||
<YAxis
|
||
tick={{ fill: "#64748b", fontSize: 10, fontFamily: "JetBrains Mono" }}
|
||
axisLine={{ stroke: GRID_STROKE }}
|
||
tickLine={false}
|
||
width={44}
|
||
domain={["auto", "auto"]}
|
||
tickFormatter={yFormatter}
|
||
/>
|
||
{showTooltip && (
|
||
<Tooltip
|
||
contentStyle={{
|
||
background: "rgba(15, 23, 42, 0.9)",
|
||
border: "1px solid rgba(51, 65, 85, 0.6)",
|
||
borderRadius: 8,
|
||
fontSize: 12,
|
||
fontFamily: "JetBrains Mono, monospace",
|
||
color: "#e4e4e7",
|
||
}}
|
||
itemStyle={{ color: strokeColor }}
|
||
formatter={(value: number) => [yFormatter(value), ""]}
|
||
/>
|
||
)}
|
||
<Area
|
||
type="monotone"
|
||
dataKey="y"
|
||
stroke={strokeColor}
|
||
strokeWidth={2}
|
||
fill={fillColor}
|
||
dot={false}
|
||
activeDot={{
|
||
r: 4,
|
||
fill: strokeColor,
|
||
stroke: "#fff",
|
||
strokeWidth: 2,
|
||
}}
|
||
isAnimationActive={false} // desativa animação Recharts; rAF cuida do smooth
|
||
/>
|
||
</AreaChart>
|
||
</ResponsiveContainer>
|
||
|
||
{/* ── Legenda de buffer ── */}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
marginTop: "0.5rem",
|
||
fontSize: "0.7rem",
|
||
color: "#475569",
|
||
fontFamily: "JetBrains Mono, monospace",
|
||
}}
|
||
aria-hidden="true"
|
||
>
|
||
<span>{displayData.length} pontos</span>
|
||
<span>buffer {wsState.bufferSize} / {wsConfig.maxBufferSize ?? 512}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// Sub-componente Kpi
|
||
// ─────────────────────────────────────────────────────────────────
|
||
|
||
interface KpiProps {
|
||
label: string;
|
||
value: string;
|
||
color: string;
|
||
}
|
||
|
||
function Kpi({ label, value, color }: KpiProps): React.ReactElement {
|
||
return (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
color,
|
||
}}
|
||
>
|
||
<span style={{ color: "#64748b", marginBottom: 2 }}>{label}</span>
|
||
<span style={{ fontWeight: 700, fontSize: "0.85rem" }}>{value}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// Paleta de cores por status
|
||
// ─────────────────────────────────────────────────────────────────
|
||
|
||
const WS_STATUS_COLOR: Record<string, string> = {
|
||
idle: "#94a3b8",
|
||
connecting: "#fbbf24",
|
||
connected: "#34d399",
|
||
retrying: "#f59e0b",
|
||
error: "#f87171",
|
||
closed: "#6b7280",
|
||
};
|
||
|
||
export default LiveChart;
|