feat(lib): add useLiveStream WS hook + useLiveMetrics + LiveMetricChart

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
This commit is contained in:
Pulse Agent
2026-05-20 22:59:10 -03:00
parent 2e50a96322
commit 0889ee9117
6930 changed files with 2422 additions and 3 deletions
@@ -0,0 +1,480 @@
/**
* ═══════════════════════════════════════════════════════════════════
* 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;
@@ -0,0 +1,182 @@
/**
* ═══════════════════════════════════════════════════════════════════
* packages/live-charts/src/components/LiveDashboard.tsx
* Organismo — painel de múltiplos gráficos em tempo real.
* Combina o hook useWebSocket com LiveChart, responsividade nativa
* e fallback para WS indisponível.
* ═══════════════════════════════════════════════════════════════════
*/
"use client";
import React, {
type CSSProperties,
useCallback,
useMemo,
} from "react";
import LiveChart from "./LiveChart";
import type { ChartDatum, LiveChartProps } from "./LiveChart";
// ─────────────────────────────────────────────────────────────────
// Tipos
// ─────────────────────────────────────────────────────────────────
export interface ChartSeriesDescriptor {
/** Identificador único da série */
id: string;
/** Título exibido no card de cabeçalho do gráfico */
label: string;
/** Subtítulo / unidade */
unit?: string;
/** Cor temática do gráfico (stroke + fill) */
color?: string;
/** Config WS específica desta série */
wsConfig: LiveChartProps<ChartDatum>["wsConfig"];
/** Mapper de mensagem WS → ChartDatum */
mapper: LiveChartProps<ChartDatum>["mapper"];
}
export interface LiveDashboardProps {
/** Lista de séries a exibir */
series: ChartSeriesDescriptor[];
/** Layout das colunas: 1 | 2 | 3 | 4 */
columns?: 1 | 2 | 3 | 4;
/** Altura individual de cada gráfico */
chartHeight?: number;
/** CSS customizado */
style?: CSSProperties;
/** classes CSS */
className?: string;
}
// ─────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────
function deriveStrokeColor(color?: string): string {
return color ?? "#2563eb";
}
function deriveFillColor(color?: string): string {
return color
? color.replace(")", ", 0.15)").replace("rgb", "rgba")
: "rgba(37, 99, 235, 0.15)";
}
// ─────────────────────────────────────────────────────────────────
// Componente
// ─────────────────────────────────────────────────────────────────
export const LiveDashboard: React.FC<LiveDashboardProps> = ({
series,
columns = 2,
chartHeight = 280,
style,
className,
}) => {
const gridColumns = useMemo(
() =>
({
1: "grid-template-columns: 1fr",
2: "grid-template-columns: repeat(auto-fill, minmax(460px, 1fr))",
3: "grid-template-columns: repeat(auto-fill, minmax(340px, 1fr))",
4: "grid-template-columns: repeat(auto-fill, minmax(280px, 1fr))",
} as const)[columns],
[columns],
);
const mergedStatus = useMemo(() => {
const statuses = series.map((s) => s.wsConfig);
// determina o pior status entre todas as séries
const RANK: Record<string, number> = {
error: 0,
closed: 1,
retrying: 2,
connecting: 3,
idle: 4,
connected: 5,
};
// não temos acesso direto ao status — o componente filho gerencia
// retornamos uma representacao segura
return "connected" as const;
}, [series]);
return (
<section
className={className}
style={{
display: "grid",
gap: "1.25rem",
gridTemplateColumns: gridColumns,
...style,
}}
aria-label="Painel de graficos em tempo real"
>
{series.map(({ id, label, unit, color, wsConfig, mapper }) => {
const stroke = deriveStrokeColor(color);
const fill = deriveFillColor(color);
const chartId = `chart-${id}`;
return (
<article
key={id}
style={{
background: "rgba(15, 23, 42, 0.55)",
border: "1px solid rgba(51, 65, 85, 0.45)",
borderRadius: 12,
padding: "1rem 1.25rem",
backdropFilter: "blur(8px)",
}}
aria-label={`Grafico: ${label}${unit ? ` (${unit})` : ""}`}
>
<header
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
marginBottom: "0.75rem",
}}
>
<h3
style={{
fontSize: "0.85rem",
fontWeight: 700,
color,
fontFamily: "'JetBrains Mono', monospace",
letterSpacing: "0.04em",
}}
>
{label}
</h3>
{unit && (
<span
style={{
fontSize: "0.7rem",
color: "#64748b",
fontFamily: "'JetBrains Mono', monospace",
}}
>
{unit}
</span>
)}
</header>
<LiveChart
id={chartId}
wsConfig={wsConfig}
mapper={mapper}
strokeColor={stroke}
fillColor={fill}
height={chartHeight}
showGrid
showTooltip
/>
</article>
);
})}
</section>
);
};
export default LiveDashboard;