Ctrl K
ComunidadGuías…

charts.jsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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 <div ref={ref} style={{ width: "100%", height: H, ...style }} />;
  }
  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 (
    <div ref={ref} style={{ width: "100%", height: H, position: "relative", ...style }}>
      <svg width="100%" height={H} viewBox={`0 0 ${w} ${H}`} preserveAspectRatio="none">
        <defs>
          <linearGradient id={gradId} x1="0" x2="0" y1="0" y2="1">
            <stop offset="0%" stopColor={color} stopOpacity={0.32} />
            <stop offset="100%" stopColor={color} stopOpacity={0} />
          </linearGradient>
        </defs>
        {area && fill && <path d={fillD} fill={`url(#${gradId})`} />}
        <path d={d} fill="none" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
        {/* leading dot */}
        <circle cx={w} cy={yOf(last)} r="2.2" fill={color}>
          <animate attributeName="r" values="2.2;3.6;2.2" dur="1.6s" repeatCount="indefinite" />
        </circle>
      </svg>
    </div>
  );
}

// ── 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 (
    <div>
      <svg width={size} height={size}>
        <circle cx={size/2} cy={size/2} r={r} fill="none" stroke="rgba(255,255,255,.08)" strokeWidth={stroke} />
        <circle cx={size/2} cy={size/2} r={r} fill="none" stroke={auto} strokeWidth={stroke}
                strokeLinecap="round"
                strokeDasharray={c}
                strokeDashoffset={off}
                transform={`rotate(-90 ${size/2} ${size/2})`}
                style={{ transition: "stroke-dashoffset .6s cubic-bezier(.2,.7,.2,1), stroke .3s" }} />
        <text x="50%" y="48%" textAnchor="middle" dominantBaseline="central"
              style={{ font: "500 14px/1 var(--display)", fill: "var(--ink)", fontVariantNumeric: "tabular-nums", letterSpacing: "-.02em" }}>
          {Math.round(v)}{max === 100 ? "%" : ""}
        </text>
        {sub && (
          <text x="50%" y="68%" textAnchor="middle" dominantBaseline="central"
                style={{ font: "500 8.5px/1 var(--mono)", fill: "var(--ink-3)", letterSpacing: ".08em", textTransform: "uppercase" }}>
            {sub}
          </text>
        )}
      </svg>
      {label && <div className="rl">{label}</div>}
    </div>
  );
}

// ── 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 <div ref={ref} style={{ width: "100%", height: H }} />;
  }
  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 (
    <div ref={ref} style={{ width: "100%", height: H }}>
      <svg width="100%" height={H} viewBox={`0 0 ${w} ${H}`}>
        <defs>
          {colors.map((c, i) => (
            <linearGradient key={i} id={gradIds[i]} x1="0" x2="0" y1="0" y2="1">
              <stop offset="0%" stopColor={c} stopOpacity={0.35} />
              <stop offset="100%" stopColor={c} stopOpacity={0} />
            </linearGradient>
          ))}
        </defs>
        {/* grid */}
        {yTicks.map((t, i) => (
          <g key={i}>
            <line x1={P.l} x2={w - P.r} y1={yOf(t)} y2={yOf(t)} stroke="var(--line)" strokeWidth="0.5" />
            <text x={P.l - 8} y={yOf(t)} textAnchor="end" dominantBaseline="central"
                  style={{ font: "500 9px/1 var(--mono)", fill: "var(--ink-4)", letterSpacing: ".06em" }}>
              {Math.round(t)}{unit}
            </text>
          </g>
        ))}
        {series.map((s, i) => (
          <g key={i}>
            <path d={`${path(s)} L ${w - P.r} ${H - P.b} L ${P.l} ${H - P.b} Z`} fill={`url(#${gradIds[i]})`} />
            <path d={path(s)} fill="none" stroke={colors[i] || colors[0]} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
            <circle cx={w - P.r} cy={yOf(s[s.length - 1])} r="2.8" fill={colors[i] || colors[0]}>
              <animate attributeName="r" values="2.8;4.5;2.8" dur="1.8s" repeatCount="indefinite" />
            </circle>
          </g>
        ))}
      </svg>
    </div>
  );
}

// ── Stacked bar (horizontal) ───────────────────────────────────────────
function StackedBar({ segments, height = 6, total }) {
  const sum = total || segments.reduce((a, s) => a + s.value, 0);
  return (
    <div style={{ display: "flex", height, borderRadius: 3, overflow: "hidden", background: "rgba(255,255,255,.06)" }}>
      {segments.map((s, i) => (
        <div key={i} title={`${s.label}: ${s.value.toFixed(1)}`}
             style={{ width: `${(s.value / sum) * 100}%`, background: s.color, transition: "width .6s cubic-bezier(.2,.7,.2,1)" }} />
      ))}
    </div>
  );
}

// ── Heatmap (per-node × time) ──────────────────────────────────────────
function Heatmap({ rows, columns = 30, accessor }) {
  // rows: [{ label, data: number[] }], values 0–100
  const cellW = 100 / columns;
  return (
    <div style={{ display: "grid", gap: 6 }}>
      {rows.map((r, ri) => (
        <div key={ri} style={{ display: "grid", gridTemplateColumns: "84px 1fr 36px", gap: 10, alignItems: "center" }}>
          <div style={{ fontFamily: "var(--mono)", fontSize: 10.5, color: "var(--ink-3)" }}>{r.label}</div>
          <div style={{ display: "flex", gap: 1.5 }}>
            {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 <div key={i} style={{
                flex: 1, height: 14, borderRadius: 2,
                background: `oklch(0.62 0.18 ${hue} / ${a})`,
                transition: "background .25s",
              }} />;
            })}
          </div>
          <div className="num" style={{ fontSize: 11, color: "var(--ink-2)", textAlign: "right" }}>
            {Math.round(r.data[r.data.length - 1])}%
          </div>
        </div>
      ))}
    </div>
  );
}

// ── 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 <span ref={ref} className="num">{v.toFixed(decimals)}{suffix}</span>;
}

// ── 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 (
    <div style={{ position: "relative" }} onClick={(e) => e.stopPropagation()}>
      <button onClick={() => setOpen((v) => !v)} title="Language · 语言 · 語言"
        style={{
          display: "inline-flex", alignItems: "center", gap: 6,
          background: "rgba(255,255,255,.04)", border: "0.5px solid var(--line)",
          color: "var(--ink-2)", font: "500 11px var(--mono)",
          padding: "5px 9px", borderRadius: 7, cursor: "pointer",
        }}>
        <span style={{ fontSize: 13, lineHeight: 1 }}>🌐</span>
        <span>{cur.flag}</span>
      </button>
      {open && (
        <div style={{
          position: "absolute", top: "calc(100% + 6px)", right: 0, zIndex: 50,
          background: "var(--bg-2, #131316)", border: "0.5px solid var(--line-2)",
          borderRadius: 9, padding: 5, minWidth: 132,
          boxShadow: "0 8px 28px rgba(0,0,0,.5)",
        }}>
          {LANGS.map((l) => (
            <button key={l.code} onClick={() => { setLang(l.code); setOpen(false); }}
              style={{
                display: "flex", alignItems: "center", gap: 9, width: "100%",
                background: l.code === lang ? "rgba(255,255,255,.06)" : "transparent",
                border: 0, color: l.code === lang ? "var(--ink)" : "var(--ink-2)",
                font: "500 12px var(--display)", padding: "7px 9px",
                borderRadius: 6, cursor: "pointer", textAlign: "left",
              }}>
              <span style={{ fontSize: 14 }}>{l.flag}</span>{l.label}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

Object.assign(window, { Sparkline, Ring, AreaChart, StackedBar, Heatmap, useCountUp, CountUp, useElementSize, useLang, LangSwitcher });