/** * ═══════════════════════════════════════════════════════════════════ * 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 { /** * 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["status"], prev: WSState["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(arr: T[], max: number): T[] { return arr.length > max ? arr.slice(arr.length - max) : arr; } // ───────────────────────────────────────────────────────────────── // Componente // ───────────────────────────────────────────────────────────────── export function LiveChart( props: LiveChartProps, ): 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([]); const rafRef = useRef(0); const pendingRef = useRef([]); const lastTsRef = useRef(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({ ...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["status"], _prev as WSState["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 (
⚠ Conexao WS indisponível —{" "} {wsState.error?.message ?? "verifique o broker"}
); } return (
{/* ── Header com KPIs ── */} {stats && (
{wsState.status} {wsState.latencyMs != null && ( · {wsState.latencyMs}ms )}
)} {/* ── Recharts AreaChart ── */} {showGrid && ( )} {showTooltip && ( [yFormatter(value), ""]} /> )} {/* ── Legenda de buffer ── */}
); } // ───────────────────────────────────────────────────────────────── // Sub-componente Kpi // ───────────────────────────────────────────────────────────────── interface KpiProps { label: string; value: string; color: string; } function Kpi({ label, value, color }: KpiProps): React.ReactElement { return (
{label} {value}
); } // ───────────────────────────────────────────────────────────────── // Paleta de cores por status // ───────────────────────────────────────────────────────────────── const WS_STATUS_COLOR: Record = { idle: "#94a3b8", connecting: "#fbbf24", connected: "#34d399", retrying: "#f59e0b", error: "#f87171", closed: "#6b7280", }; export default LiveChart;