// AI-MONITOR · Reusable SVG chart primitives. // All charts render from a numeric array (rolling history buffer). const { useEffect, useMemo, useRef, useState, useLayoutEffect } = React; // ── useElementSize ───────────────────────────────────────────────────── function useElementSize() { const ref = useRef(null); const [size, setSize] = useState({ w: 0, h: 0 }); useLayoutEffect(() => { if (!ref.current) return; const ro = new ResizeObserver((entries) => { const e = entries[0]; if (e) setSize({ w: e.contentRect.width, h: e.contentRect.height }); }); ro.observe(ref.current); setSize({ w: ref.current.clientWidth, h: ref.current.clientHeight }); return () => ro.disconnect(); }, []); return [ref, size]; } // ── Sparkline (no axis, no labels — pure shape) ──────────────────────── function Sparkline({ data, height = 32, color = "var(--accent)", fill = true, area = true, smooth = true, style }) { const [ref, { w, h }] = useElementSize(); const gradId = useMemo(() => "g" + Math.random().toString(36).slice(2, 9), []); const H = h || height; if (!data || data.length < 2 || !w) { return
; } const min = Math.min(...data); const max = Math.max(...data); const span = max - min || 1; const stepX = w / (data.length - 1); const yOf = (v) => H - 6 - ((v - min) / span) * (H - 12); // Build smooth path with catmull-rom-to-bezier let d = ""; if (smooth) { const pts = data.map((v, i) => [i * stepX, yOf(v)]); d = `M ${pts[0][0]} ${pts[0][1]}`; for (let i = 0; i < pts.length - 1; i++) { const p0 = pts[i - 1] || pts[i]; const p1 = pts[i]; const p2 = pts[i + 1]; const p3 = pts[i + 2] || p2; const c1x = p1[0] + (p2[0] - p0[0]) / 6; const c1y = p1[1] + (p2[1] - p0[1]) / 6; const c2x = p2[0] - (p3[0] - p1[0]) / 6; const c2y = p2[1] - (p3[1] - p1[1]) / 6; d += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2[0]} ${p2[1]}`; } } else { d = data.map((v, i) => `${i ? "L" : "M"} ${i * stepX} ${yOf(v)}`).join(" "); } const last = data[data.length - 1]; const fillD = area ? `${d} L ${w} ${H} L 0 ${H} Z` : null; return (
{area && fill && } {/* leading dot */}
); } // ── Ring gauge ───────────────────────────────────────────────────────── function Ring({ value = 0, max = 100, size = 70, stroke = 6, color, label, sub }) { const v = Math.max(0, Math.min(max, value)); const r = (size - stroke) / 2; const c = 2 * Math.PI * r; const off = c * (1 - v / max); const auto = color || (v / max > 0.85 ? "var(--bad)" : v / max > 0.7 ? "var(--hot)" : v / max > 0.4 ? "var(--accent)" : "var(--ok)"); return (
{Math.round(v)}{max === 100 ? "%" : ""} {sub && ( {sub} )} {label &&
{label}
}
); } // ── Area chart (axes optional) ───────────────────────────────────────── function AreaChart({ series, height = 160, colors = ["var(--accent)"], yMax, padding = { l: 36, r: 10, t: 14, b: 22 }, ticks = 4, unit = "" }) { const [ref, { w }] = useElementSize(); const gradIds = useMemo( () => (series || [0]).map((_, i) => "ag" + Math.random().toString(36).slice(2, 8) + i), [series ? series.length : 0] ); const H = height; if (!series || !series.length || !series[0].length || !w) { return
; } const flat = series.flat(); const min = 0; const max = yMax || Math.max(1, Math.max(...flat) * 1.15); // 左 padding 自动撑大以容下最宽 y 轴标签 (整数位 + unit):9px mono ~ 6px/digit, // unit 字符 ~5.5px/字, 标签尾端距 P.l 偏左 8px → 留 12px 余量。 // 避免"l:36 + 数字 1234 t/s"那种被 viewBox 左缘截断只剩个位的窗口。 const _labelW = String(Math.round(max)).length * 6 + (unit || "").length * 5.5 + 12; const P = { ...padding, l: Math.max(padding.l || 0, Math.ceil(_labelW)) }; const innerW = w - P.l - P.r; const innerH = H - P.t - P.b; const span = max - min || 1; const stepX = (i, len) => P.l + (i / (len - 1)) * innerW; const yOf = (v) => P.t + innerH - ((v - min) / span) * innerH; function path(data) { const pts = data.map((v, i) => [stepX(i, data.length), yOf(v)]); let d = `M ${pts[0][0]} ${pts[0][1]}`; for (let i = 0; i < pts.length - 1; i++) { const p0 = pts[i - 1] || pts[i]; const p1 = pts[i]; const p2 = pts[i + 1]; const p3 = pts[i + 2] || p2; const c1x = p1[0] + (p2[0] - p0[0]) / 6; const c1y = p1[1] + (p2[1] - p0[1]) / 6; const c2x = p2[0] - (p3[0] - p1[0]) / 6; const c2y = p2[1] - (p3[1] - p1[1]) / 6; d += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2[0]} ${p2[1]}`; } return d; } const yTicks = Array.from({ length: ticks + 1 }, (_, i) => (max / ticks) * i); return (
{colors.map((c, i) => ( ))} {/* grid */} {yTicks.map((t, i) => ( {Math.round(t)}{unit} ))} {series.map((s, i) => ( ))}
); } // ── Stacked bar (horizontal) ─────────────────────────────────────────── function StackedBar({ segments, height = 6, total }) { const sum = total || segments.reduce((a, s) => a + s.value, 0); return (
{segments.map((s, i) => (
))}
); } // ── Heatmap (per-node × time) ────────────────────────────────────────── function Heatmap({ rows, columns = 30, accessor }) { // rows: [{ label, data: number[] }], values 0–100 const cellW = 100 / columns; return (
{rows.map((r, ri) => (
{r.label}
{r.data.slice(-columns).map((v, i) => { const a = Math.min(1, Math.max(0.05, v / 100)); const hue = v > 85 ? 5 : v > 65 ? 28 : v > 35 ? 210 : 145; return
; })}
{Math.round(r.data[r.data.length - 1])}%
))}
); } // ── Animated counter on view ─────────────────────────────────────────── function useCountUp(target, duration = 1400, decimals = 0) { const [val, setVal] = useState(0); const startedRef = useRef(false); const ref = useRef(null); useEffect(() => { if (!ref.current) return; const io = new IntersectionObserver((entries) => { entries.forEach((e) => { if (e.isIntersecting && !startedRef.current) { startedRef.current = true; const t0 = performance.now(); const tween = () => { const t = Math.min(1, (performance.now() - t0) / duration); // ease-out-quint const e2 = 1 - Math.pow(1 - t, 5); setVal(target * e2); if (t < 1) requestAnimationFrame(tween); }; requestAnimationFrame(tween); } }); }, { threshold: 0.2 }); io.observe(ref.current); return () => io.disconnect(); }, [target, duration]); return [ref, val]; } function CountUp({ value, decimals = 0, suffix = "" }) { const [ref, v] = useCountUp(value, 1400, decimals); return {v.toFixed(decimals)}{suffix}; } // ── i18n: hook + 地球仪语言切换器 ─────────────────────────────────── // useLang() 订阅 AII18N,语言切换时强制重渲染;返回翻译函数 t。 function useLang() { const [, force] = React.useState(0); React.useEffect(() => window.AII18N.subscribe(() => force((n) => n + 1)), []); return { t: window.AII18N.t, lang: window.AII18N.lang, setLang: window.AII18N.setLang }; } function LangSwitcher() { const { lang, setLang } = useLang(); const [open, setOpen] = React.useState(false); const LANGS = window.AII18N.LANGS; const cur = LANGS.find((l) => l.code === lang) || LANGS[0]; React.useEffect(() => { if (!open) return; const close = () => setOpen(false); window.addEventListener("click", close); return () => window.removeEventListener("click", close); }, [open]); return (
e.stopPropagation()}> {open && (
{LANGS.map((l) => ( ))}
)}
); } Object.assign(window, { Sparkline, Ring, AreaChart, StackedBar, Heatmap, useCountUp, CountUp, useElementSize, useLang, LangSwitcher });