/* =========================================================
   viz3.jsx — benches for Module V (realtime), VI (platforms)
   and the first of VII (channels): v16–v22.
   Exported as window.__VO_VIZ_3.
   ========================================================= */

/* =========================================================
   v16 · latencyLab — the end-to-end budget
   ========================================================= */
const LAT_STAGES = [
  { k: "net",  zh: "网络上行", en: "Network uplink",        min: 10, max: 200, def: 45 },
  { k: "vad",  zh: "尾点静音判定", en: "Tail-silence endpoint", min: 200, max: 1200, def: 600 },
  { k: "asr",  zh: "ASR 出终稿", en: "ASR finalises",        min: 40, max: 600, def: 140 },
  { k: "llm",  zh: "大模型首字", en: "LLM first token",      min: 120, max: 1500, def: 430 },
  { k: "tts",  zh: "TTS 首包", en: "TTS first packet",       min: 60, max: 900, def: 260 },
  { k: "play", zh: "播放缓冲", en: "Playback buffer",        min: 20, max: 300, def: 90 },
];
function LatencyViz() {
  const L = useL();
  const lang = useLang();
  const [v, setV] = React.useState(() => { const o = {}; LAT_STAGES.forEach((s) => { o[s.k] = s.def; }); return o; });
  const [prewarm, setPrewarm] = React.useState(false);
  const [firstSent, setFirstSent] = React.useState(false);
  const [playGen, setPlayGen] = React.useState(false);
  const [jit, setJit] = React.useState(0.25);

  const raw = LAT_STAGES.reduce((s, x) => s + v[x.k], 0);
  // three overlaps, all of them "start earlier" rather than "run faster"
  const savePrewarm = prewarm ? Math.min(v.asr, v.llm) * 0.62 : 0;
  const saveFirst = firstSent ? v.tts * 0.55 : 0;
  const savePlay = playGen ? v.play * 0.7 : 0;
  const p50 = Math.max(120, raw - savePrewarm - saveFirst - savePlay);
  const p95 = p50 * (1 + jit * 1.8);

  const segs = LAT_STAGES.map((s) => ({ ...s, val: v[s.k] }));
  const total = raw;
  let acc = 0;

  return (
    <div>
      <VizHead idx="RT1" title={L("一秒钟的预算,分给六个环节;能砍的其实是「重叠」而不是「更快」", "A one-second budget across six stages — and what cuts it is overlap, not speed")} />
      <div className="viz-ctrl">
        {LAT_STAGES.map((s) => (
          <Slider key={s.k} label={lang === "zh" ? s.zh : s.en} min={s.min} max={s.max} step={10} value={v[s.k]}
            onChange={(x) => setV({ ...v, [s.k]: x })} unit=" ms" />
        ))}
        <Slider label={L("网络抖动", "Jitter")} min={0} max={0.8} step={0.05} value={jit} onChange={setJit} fmt={pct} />
        <Toggle label={L("中间结果预热大模型", "Warm the LLM with partial ASR")} value={prewarm} onChange={setPrewarm} />
        <Toggle label={L("首句即合成", "Synthesise the first sentence early")} value={firstSent} onChange={setFirstSent} />
        <Toggle label={L("边合成边播", "Play while generating")} value={playGen} onChange={setPlayGen} />
      </div>

      <div className="vo-kpi-grid">
        <Kpi label={L("裸链路总延迟", "Bare pipeline")} value={ms(raw)} tone={raw > 1200 ? "warn" : "acc"} />
        <Kpi label={L("优化后 P50", "Optimised P50")} value={ms(p50)} tone={p50 < 900 ? "ok" : "warn"} hint={L(`省了 ${ms(raw - p50)}`, `saved ${ms(raw - p50)}`)} />
        <Kpi label={L("P95(顾客记住的是这个)", "P95 — what they remember")} value={ms(p95)} tone={p95 < 1500 ? "ok" : "warn"} />
        <Kpi label={L("对话感", "Conversational feel")} value={p50 < 800 ? L("自然", "natural") : p50 < 1300 ? L("能忍", "tolerable") : L("尴尬", "awkward")} tone={p50 < 800 ? "ok" : p50 < 1300 ? "acc" : "warn"} />
      </div>

      <div style={{ marginTop: 12 }}>
        <div className="vo-cap">{L("延迟瀑布:每一段从上一段结束处开始(最长的那条通常是尾点判定)", "The waterfall: each stage starts where the last ended (the longest bar is usually endpointing)")}</div>
        <svg viewBox="0 0 620 140" width="100%" style={{ display: "block", marginTop: 4 }}>
          {segs.map((s, i) => {
            const x0 = 96 + (acc / total) * 500;
            const w = (s.val / total) * 500;
            acc += s.val;
            const tone = s.k === "vad" ? "#d98a1f" : s.k === "llm" ? "var(--accent)" : "var(--primary)";
            return (
              <g key={s.k}>
                <text x="90" y={20 + i * 20} textAnchor="end" style={{ font: "500 10px var(--f-sans)", fill: "var(--muted)" }}>{lang === "zh" ? s.zh : s.en}</text>
                <rect x={x0} y={12 + i * 20} width={Math.max(2, w)} height="11" rx="3" fill={tone} opacity="0.85" />
                <text x={x0 + Math.max(2, w) + 5} y={21 + i * 20} style={{ font: "600 9px var(--f-mono)", fill: "var(--ink)" }}>{s.val}</text>
              </g>
            );
          })}
          <line x1={96 + (1000 / total) * 500} y1={6} x2={96 + (1000 / total) * 500} y2={132} stroke="#c0453f" strokeDasharray="4 3" />
          <text x={96 + (1000 / total) * 500 + 4} y={136} style={{ font: "600 9px var(--f-mono)", fill: "#c0453f" }}>{L("1 秒:人的忍耐线", "1 s: human patience")}</text>
        </svg>
      </div>

      <div style={{ marginTop: 6 }}>
        <Bar label={L("裸链路", "Bare")} value={raw} max={2600} tone="mut" valText={ms(raw)} />
        <Bar label={L("三个重叠优化之后 P50", "After three overlaps, P50")} value={p50} max={2600} tone={p50 < 900 ? "ok" : "warn"} valText={ms(p50)} />
        <Bar label={L("P95", "P95")} value={p95} max={2600} tone="acc" valText={ms(p95)} />
      </div>

      <Note mark="→" tone={p50 > 1200 ? "bad" : "on"}>
        {v.vad >= 600 && p50 > 900
          ? L(`注意最长的那一条是尾点判定 ${v.vad} ms——它比任何一个模型都贵。如果不动它,换多贵的 ASR 或大模型都跨不过一秒线;先去第三章把这个阈值调对,再谈优化模型。`,
              `Notice the longest bar is endpointing at ${v.vad} ms — more expensive than any model here. Leave it alone and no amount of money spent on ASR or the LLM gets you under a second. Tune that threshold first, then optimise models.`)
          : L(`三个优化都是「提前开始」而不是「跑得更快」:用中间结果预热大模型、大模型第一句话就送去合成、TTS 边合成边播。合起来把 ${ms(raw)} 压到 ${ms(p50)},而顾客记住的是 P95 ${ms(p95)}。`,
              `All three moves are start earlier, not run faster: warm the LLM on partials, synthesise the model's first sentence immediately, and play TTS while it generates. Together they take ${ms(raw)} to ${ms(p50)} — while what the customer remembers is the P95 of ${ms(p95)}.`)}
      </Note>
    </div>
  );
}

/* =========================================================
   v17 · bargeLab — interrupting while speaking
   ========================================================= */
function BargeViz() {
  const L = useL();
  const [aec, setAec] = React.useState(0.9);
  const [gate, setGate] = React.useState(220);
  const [noise, setNoise] = React.useState(0.3);
  const [semantic, setSemantic] = React.useState(false);
  const [duplex, setDuplex] = React.useState(true);

  const residual = (1 - aec) * 0.62;                               // echo left in the mic
  const noiseTrig = noise * 0.55;
  const gateFactor = clamp(1 - (gate - 120) / 700, 0.18, 1);       // longer gate filters transients
  const semFactor = semantic ? 0.3 : 1;
  const falsePerMin = duplex ? (residual * 6 + noiseTrig * 3.2) * gateFactor * semFactor : 0;
  const respDelay = duplex ? gate + 70 + (semantic ? 120 : 0) : 99999;
  const missedBarge = duplex ? clamp(gateFactor < 0.4 ? 0.18 : 0.04, 0, 1) : 1;
  const score = duplex
    ? clamp(5 - falsePerMin * 1.6 - Math.max(0, (respDelay - 320) / 260) - missedBarge * 3, 1, 5)
    : 2.2;

  const curve = [];
  for (let g = 120; g <= 800; g += 20) {
    const gf = clamp(1 - (g - 120) / 700, 0.18, 1);
    curve.push({ x: g, y: (residual * 6 + noiseTrig * 3.2) * gf * semFactor });
  }

  return (
    <div>
      <VizHead idx="RT2" title={L("一边说一边听:回声消除不干净,机器会把自己当成顾客", "Listening while speaking: with imperfect echo cancellation the machine hears itself as the customer")} />
      <div className="viz-ctrl">
        <Slider label={L("回声消除强度 AEC", "Echo cancellation")} min={0.4} max={0.995} step={0.005} value={aec} onChange={setAec} fmt={pct1} />
        <Slider label={L("打断持续时长门限", "Interrupt duration gate")} min={120} max={800} step={20} value={gate} onChange={setGate} unit=" ms" />
        <Slider label={L("门店背景噪声", "Shop background noise")} min={0} max={1} step={0.05} value={noise} onChange={setNoise} fmt={pct} />
        <Toggle label={L("全双工(边说边听)", "Full duplex")} value={duplex} onChange={setDuplex} />
        <Toggle label={L("语义二次确认(听到的是有意义的话吗)", "Semantic second gate")} value={semantic} onChange={setSemantic} />
      </div>

      <div className="vo-kpi-grid">
        <Kpi label={L("残余回声", "Residual echo")} value={pct1(residual)} tone={residual > 0.1 ? "warn" : "ok"} hint={L("自己的声音漏回麦克风", "own voice leaking into the mic")} />
        <Kpi label={L("每分钟误打断", "False interrupts per minute")} value={nf(falsePerMin, 2)} tone={falsePerMin > 0.5 ? "warn" : "ok"} />
        <Kpi label={L("真打断的响应延迟", "Response to a real interrupt")} value={duplex ? ms(respDelay) : L("不支持", "not supported")} tone={duplex && respDelay < 400 ? "ok" : "warn"} />
        <Kpi label={L("打断体验分", "Barge-in feel")} value={nf(score, 2)} unit="/5" tone={score > 3.8 ? "ok" : "warn"} />
      </div>

      <div style={{ marginTop: 10 }}>
        <Bar label={L("残余回声导致的误触发", "False triggers from residual echo")} value={residual * 6 * gateFactor * semFactor} max={4} tone="warn" valText={nf(residual * 6 * gateFactor * semFactor, 2)} />
        <Bar label={L("背景噪声导致的误触发", "False triggers from ambient noise")} value={noiseTrig * 3.2 * gateFactor * semFactor} max={4} tone="acc" valText={nf(noiseTrig * 3.2 * gateFactor * semFactor, 2)} />
        <Bar label={L("该打断却没打断", "Real interrupts missed")} value={missedBarge} max={0.4} tone={missedBarge > 0.1 ? "warn" : "ok"} valText={pct1(missedBarge)} />
      </div>

      <div style={{ marginTop: 10 }}>
        <div className="vo-cap">{L("持续时长门限越长,误打断越少,但真打断的响应也越慢(虚线为当前门限)", "A longer duration gate means fewer false interrupts and a slower response to real ones (dashed = current)")}</div>
        <MiniPlot data={curve} markIndex={Math.round((gate - 120) / 20)} yMin={0} />
      </div>

      <Note mark="→" tone={!duplex ? "bad" : falsePerMin > 0.5 ? "bad" : "on"}>
        {!duplex
          ? L("半双工的做法是:机器说话时干脆关掉麦克风。工程上最省事,体验上最差——顾客插话没有任何反应,只能等它说完,而机器最长的那句话可能有十几秒。",
              "The half-duplex shortcut is to mute the microphone while speaking. It is the cheapest thing to build and the worst to use: an interrupting customer gets no reaction at all and must wait out a sentence that may run fifteen seconds.")
          : falsePerMin > 0.5
            ? L(`每分钟 ${nf(falsePerMin, 2)} 次误打断:机器说到一半自己闭嘴,顾客莫名其妙。两个来源——回声消除没做干净(自己的声音漏回来)和门店背景噪声(前台旁边的电视、另一位顾客)。把持续时长门限调长、开语义二次确认,都能压下来。`,
                `${nf(falsePerMin, 2)} false interrupts a minute: the machine silences itself mid-sentence for no reason the customer can see. Two sources — imperfect echo cancellation leaking its own voice back, and shop ambience (the television by the desk, another customer). Lengthening the duration gate and enabling the semantic second gate both help.`)
            : L(`当前配置下每分钟 ${nf(falsePerMin, 2)} 次误打断,真打断 ${ms(respDelay)} 内响应——顾客插一句话,机器立刻停下来听,这是最接近真人的一个细节。别忘了打断之后的状态恢复:已经播过的内容不能重播,已经确认的槽位不能丢。`,
                `At this configuration false interrupts run ${nf(falsePerMin, 2)} per minute and real ones are honoured within ${ms(respDelay)} — the customer cuts in and the machine stops to listen, which is the single detail that most resembles a person. Do not forget state recovery after an interrupt: do not replay what was already said, and do not lose slots already confirmed.`)}
      </Note>
    </div>
  );
}

/* =========================================================
   v18 · e2eLab — cascade vs end-to-end speech model
   ========================================================= */
const E2E_DIMS = [
  { k: "lat",   zh: "延迟",           en: "Latency",            casc: 2, e2e: 5 },
  { k: "nat",   zh: "语气自然度",     en: "Naturalness",        casc: 3, e2e: 5 },
  { k: "cost",  zh: "成本可控",       en: "Cost control",       casc: 4, e2e: 2 },
  { k: "ctrl",  zh: "输出可控",       en: "Output control",     casc: 5, e2e: 2 },
  { k: "audit", zh: "可审计留痕",     en: "Auditability",       casc: 5, e2e: 2 },
  { k: "know",  zh: "知识库接入",     en: "Knowledge grounding",casc: 5, e2e: 3 },
  { k: "tool",  zh: "工具调用成熟度", en: "Tool calling",       casc: 5, e2e: 3 },
  { k: "local", zh: "可私有化",       en: "On-premise",         casc: 4, e2e: 2 },
];
function E2eViz() {
  const L = useL();
  const lang = useLang();
  const [w, setW] = React.useState(() => { const o = {}; E2E_DIMS.forEach((d) => { o[d.k] = 3; }); o.audit = 5; o.ctrl = 5; return o; });
  const [scenario, setScenario] = React.useState("booking");

  React.useEffect(() => {
    if (scenario === "booking") setW({ lat: 3, nat: 2, cost: 3, ctrl: 5, audit: 5, know: 5, tool: 5, local: 3 });
    if (scenario === "chat") setW({ lat: 5, nat: 5, cost: 3, ctrl: 2, audit: 2, know: 2, tool: 1, local: 2 });
    if (scenario === "outbound") setW({ lat: 4, nat: 4, cost: 4, ctrl: 4, audit: 5, know: 3, tool: 2, local: 3 });
  }, [scenario]);

  const tot = E2E_DIMS.reduce((s, d) => s + w[d.k], 0) || 1;
  const cascS = E2E_DIMS.reduce((s, d) => s + w[d.k] * d.casc, 0) / (tot * 5);
  const e2eS = E2E_DIMS.reduce((s, d) => s + w[d.k] * d.e2e, 0) / (tot * 5);
  const winner = cascS >= e2eS ? "casc" : "e2e";
  const gapDim = E2E_DIMS.slice().sort((a, b) => (w[b.k] * Math.abs(b.casc - b.e2e)) - (w[a.k] * Math.abs(a.casc - a.e2e)))[0];

  return (
    <div>
      <VizHead idx="RT3" title={L("两条架构,按你的权重打分:级联 vs 端到端语音模型", "Two architectures scored against your weights: cascade vs end-to-end speech")} />
      <div className="vo-seg">
        <button className={scenario === "booking" ? "on" : ""} onClick={() => setScenario("booking")}>{L("预约与报价", "Booking and quotes")}</button>
        <button className={scenario === "chat" ? "on" : ""} onClick={() => setScenario("chat")}>{L("闲聊与安抚", "Small talk and reassurance")}</button>
        <button className={scenario === "outbound" ? "on" : ""} onClick={() => setScenario("outbound")}>{L("外呼回访", "Outbound follow-up")}</button>
      </div>
      <div className="viz-ctrl" style={{ marginTop: 8 }}>
        {E2E_DIMS.map((d) => (
          <Slider key={d.k} label={lang === "zh" ? d.zh : d.en} min={0} max={5} value={w[d.k]} onChange={(x) => setW({ ...w, [d.k]: x })} />
        ))}
      </div>

      <div className="vo-kpi-grid">
        <Kpi label={L("级联匹配度", "Cascade fit")} value={pct(cascS)} tone={winner === "casc" ? "ok" : "mut"} hint="ASR → LLM → TTS" sel={winner === "casc"} />
        <Kpi label={L("端到端匹配度", "End-to-end fit")} value={pct(e2eS)} tone={winner === "e2e" ? "ok" : "mut"} hint={L("音频进,音频出", "audio in, audio out")} sel={winner === "e2e"} />
        <Kpi label={L("推荐", "Recommendation")} value={winner === "casc" ? L("级联", "Cascade") : L("端到端", "End-to-end")} tone="acc" />
        <Kpi label={L("决定因素", "Deciding factor")} value={lang === "zh" ? gapDim.zh : gapDim.en} tone="acc" />
      </div>

      <div style={{ marginTop: 10 }}>
        {E2E_DIMS.map((d) => (
          <div key={d.k} style={{ display: "grid", gridTemplateColumns: "110px 1fr 1fr", gap: 6, alignItems: "center", marginBottom: 4 }}>
            <span style={{ font: "500 11px var(--f-sans)", color: "var(--muted)" }}>{lang === "zh" ? d.zh : d.en}</span>
            <Bar label={L("级联", "cascade")} value={d.casc} max={5} tone={d.casc >= d.e2e ? "ok" : "mut"} valText={`${d.casc}`} />
            <Bar label={L("端到端", "e2e")} value={d.e2e} max={5} tone={d.e2e > d.casc ? "acc" : "mut"} valText={`${d.e2e}`} />
          </div>
        ))}
      </div>

      <Note mark="→" tone="on">
        {winner === "casc"
          ? L("级联赢在文本层:大模型吐出来的是文本,你可以在合成之前做敏感词过滤、价格校验、禁语拦截,出了纠纷每一跳都有文字可查。凡是涉及承诺(价格、时间、优惠)的环节,选级联。",
              "The cascade wins on the text layer: the model emits text, so you can filter sensitive terms, validate prices and block banned phrasing before synthesis, and every hop leaves a written record for a dispute. Anything that makes a promise — price, time, discount — goes through the cascade.")
          : L("端到端赢在体验:延迟压到几百毫秒,还能听出顾客的犹豫和不耐烦。适合闲聊、安抚、引导这类不涉及承诺的环节;但审核点变模糊、纠纷时只有音频,别拿它去报价。",
              "End-to-end wins on experience: latency in the low hundreds of milliseconds, and it can hear hesitation and impatience. Use it for small talk, reassurance and guidance — anything that promises nothing. The checkpoint blurs and a dispute leaves only audio, so do not quote prices with it.")}
      </Note>
    </div>
  );
}

/* =========================================================
   v19 · platformLab — weighted vendor selection matrix
   ========================================================= */
const PF_CRIT = [
  { k: "zh8k",  zh: "中文电话 8 kHz", en: "Mandarin telephony" },
  { k: "dial",  zh: "方言与口音",     en: "Dialects and accents" },
  { k: "hot",   zh: "热词与定制",     en: "Hotwords and customisation" },
  { k: "strm",  zh: "流式与实时",     en: "Streaming and realtime" },
  { k: "clone", zh: "音色复刻",       en: "Voice cloning" },
  { k: "local", zh: "可私有化部署",   en: "On-premise" },
  { k: "price", zh: "单价便宜",       en: "Low unit price" },
  { k: "comp",  zh: "合规与数据境内", en: "Compliance and data residency" },
];
const PF_VENDORS = [
  { n: "阿里云 智能语音交互 / Alibaba Cloud", s: { zh8k: 5, dial: 4, hot: 5, strm: 5, clone: 4, local: 3, price: 4, comp: 5 } },
  { n: "腾讯云 语音 / Tencent Cloud",         s: { zh8k: 5, dial: 4, hot: 4, strm: 5, clone: 4, local: 3, price: 4, comp: 5 } },
  { n: "科大讯飞 / iFlytek",                  s: { zh8k: 5, dial: 5, hot: 5, strm: 4, clone: 4, local: 4, price: 3, comp: 5 } },
  { n: "火山引擎 / Volcano Engine",           s: { zh8k: 4, dial: 4, hot: 4, strm: 5, clone: 5, local: 3, price: 4, comp: 5 } },
  { n: "百度智能云 / Baidu AI Cloud",         s: { zh8k: 4, dial: 3, hot: 4, strm: 4, clone: 3, local: 3, price: 5, comp: 5 } },
  { n: "Microsoft Azure Speech",              s: { zh8k: 4, dial: 3, hot: 4, strm: 5, clone: 4, local: 3, price: 3, comp: 3 } },
  { n: "Deepgram",                            s: { zh8k: 3, dial: 2, hot: 4, strm: 5, clone: 0, local: 3, price: 5, comp: 2 } },
  { n: "AssemblyAI",                          s: { zh8k: 2, dial: 2, hot: 4, strm: 4, clone: 0, local: 2, price: 4, comp: 2 } },
  { n: "OpenAI Realtime / transcribe",        s: { zh8k: 4, dial: 3, hot: 3, strm: 5, clone: 2, local: 0, price: 2, comp: 1 } },
  { n: "ElevenLabs (TTS)",                    s: { zh8k: 0, dial: 0, hot: 2, strm: 5, clone: 5, local: 1, price: 2, comp: 2 } },
  { n: "Twilio ConversationRelay", s: { zh8k: 3, dial: 2, hot: 3, strm: 5, clone: 2, local: 0, price: 3, comp: 2 } },
  { n: "自建 FunASR + CosyVoice", s: { zh8k: 4, dial: 3, hot: 5, strm: 4, clone: 5, local: 5, price: 5, comp: 5 } },
  { n: "自建 Whisper + GPT-SoVITS", s: { zh8k: 3, dial: 3, hot: 3, strm: 2, clone: 5, local: 5, price: 5, comp: 5 } },
];
function PlatformViz() {
  const L = useL();
  const lang = useLang();
  const [w, setW] = React.useState({ zh8k: 5, dial: 3, hot: 4, strm: 5, clone: 2, local: 2, price: 3, comp: 4 });
  const tot = PF_CRIT.reduce((s, c) => s + w[c.k], 0) || 1;
  const scored = PF_VENDORS.map((v) => ({
    ...v,
    fit: PF_CRIT.reduce((s, c) => s + w[c.k] * v.s[c.k], 0) / (tot * 5),
  })).sort((a, b) => b.fit - a.fit);
  const top = scored[0];
  const decisive = PF_CRIT.slice().sort((a, b) => (w[b.k] * (top.s[b.k] - (scored[scored.length - 1].s[b.k]))) - (w[a.k] * (top.s[a.k] - scored[scored.length - 1].s[a.k])))[0];

  return (
    <div>
      <VizHead idx="PF1" title={L("十二个候选,八项需求:把模糊的偏好变成可加权的分数", "Twelve candidates, eight requirements: turning vague preference into a weighted score")} />
      <div className="viz-ctrl">
        {PF_CRIT.map((c) => (
          <Slider key={c.k} label={lang === "zh" ? c.zh : c.en} min={0} max={5} value={w[c.k]} onChange={(x) => setW({ ...w, [c.k]: x })} />
        ))}
      </div>

      <div className="vo-kpi-grid">
        <Kpi label={L("第一名", "Best fit")} value={top.n.split(" / ")[lang === "zh" ? 0 : 1] || top.n} tone="ok" hint={pct(top.fit)} />
        <Kpi label={L("第二名", "Runner-up")} value={scored[1].n.split(" / ")[lang === "zh" ? 0 : 1] || scored[1].n} tone="acc" hint={pct(scored[1].fit)} />
        <Kpi label={L("第三名", "Third")} value={scored[2].n.split(" / ")[lang === "zh" ? 0 : 1] || scored[2].n} tone="acc" hint={pct(scored[2].fit)} />
        <Kpi label={L("决定性需求", "Deciding requirement")} value={lang === "zh" ? decisive.zh : decisive.en} tone="acc" />
      </div>

      <div style={{ marginTop: 10 }}>
        {scored.map((v) => (
          <Bar key={v.n} label={v.n.split(" / ")[lang === "zh" ? 0 : 1] || v.n} value={v.fit} max={1}
            tone={v === top ? "ok" : v.fit > 0.7 ? "acc" : "mut"} valText={pct(v.fit)} />
        ))}
      </div>

      <Note mark="→" tone="on">
        {L("这张表是帮你问对问题,不是替你签合同:厂商能力和价格变化很快,以官网为准,并且一定要用自己门店的真实录音做一次盲测——每家在自家 demo 上都很好听。把权重按你的实际约束调:如果数据必须留在店里,可私有化那一项直接拉到 5,榜单会立刻重排。",
              "This table helps you ask the right questions; it does not sign the contract. Capabilities and prices move fast, so check the vendor's own pages — and always run a blind test on your own shop's recordings, because every vendor sounds excellent in their own demo. Set the weights to your real constraints: if data must stay in the building, push on-premise to 5 and watch the ranking rearrange itself.")}
      </Note>
    </div>
  );
}

/* =========================================================
   v20 · buildLab — self-host vs cloud break-even
   ========================================================= */
function BuildViz() {
  const L = useL();
  const [conc, setConc] = React.useState(10);
  const [minsMonth, setMinsMonth] = React.useState(60000);
  const [unit, setUnit] = React.useState(0.055);     // yuan per minute, all-in cloud
  const [perCard, setPerCard] = React.useState(16);  // concurrent sessions per GPU
  const [cardMonth, setCardMonth] = React.useState(1800); // depreciation or rent per month
  const [opsDays, setOpsDays] = React.useState(2);   // engineer days per month

  const cards = Math.max(1, Math.ceil(conc / perCard));
  const opsCost = opsDays * 1200;
  const buildCost = cards * cardMonth + opsCost + cards * 120; // power
  const cloudCost = minsMonth * unit;
  const breakMins = buildCost / unit;
  const cheaper = cloudCost < buildCost ? "cloud" : "build";

  const curve = [], curve2 = [];
  for (let m = 0; m <= 300000; m += 10000) {
    curve.push({ x: m, y: m * unit });
    curve2.push({ x: m, y: buildCost });
  }

  return (
    <div>
      <VizHead idx="PF2" title={L("云是一条过原点的直线,自建是一条阶梯加一笔固定人力", "The cloud is a line through the origin; self-hosting is a step function plus a fixed staffing line")} />
      <div className="viz-ctrl">
        <Slider label={L("高峰并发路数", "Peak concurrent sessions")} min={1} max={80} value={conc} onChange={setConc} />
        <Slider label={L("每月通话分钟", "Minutes per month")} min={5000} max={300000} step={5000} value={minsMonth} onChange={setMinsMonth} fmt={(v) => big(v)} />
        <Slider label={L("云服务综合单价", "Cloud all-in unit price")} min={0.02} max={0.2} step={0.005} value={unit} onChange={setUnit} fmt={(v) => `¥${nf(v, 3)}/min`} />
        <Slider label={L("单卡并发能力", "Sessions per GPU")} min={4} max={40} value={perCard} onChange={setPerCard} />
        <Slider label={L("单卡月成本(折旧或租用)", "GPU per month")} min={600} max={6000} step={100} value={cardMonth} onChange={setCardMonth} fmt={(v) => yuan(v)} />
        <Slider label={L("每月运维人力", "Ops engineer days / month")} min={0} max={10} step={0.5} value={opsDays} onChange={setOpsDays} unit={L(" 人天", " d")} />
      </div>

      <div className="vo-kpi-grid">
        <Kpi label={L("需要显卡", "GPUs needed")} value={cards} unit={L(" 张", "")} tone="acc" />
        <Kpi label={L("自建月成本", "Self-host monthly")} value={yuan(buildCost)} tone={cheaper === "build" ? "ok" : "warn"} hint={L(`其中运维 ${yuan(opsCost)}`, `ops ${yuan(opsCost)} of it`)} />
        <Kpi label={L("云服务月成本", "Cloud monthly")} value={yuan(cloudCost)} tone={cheaper === "cloud" ? "ok" : "warn"} />
        <Kpi label={L("成本交叉点", "Break-even")} value={big(breakMins)} unit={L(" 分钟/月", " min/mo")} tone="acc" hint={minsMonth > breakMins ? L("你在自建划算的一侧", "you are on the build side") : L("你在买划算的一侧", "you are on the buy side")} />
      </div>

      <div style={{ marginTop: 10 }}>
        <Bar label={L("云服务", "Cloud")} value={cloudCost} max={Math.max(cloudCost, buildCost) * 1.15} tone={cheaper === "cloud" ? "ok" : "mut"} valText={yuan(cloudCost)} />
        <Bar label={L("自建(卡 + 电 + 人)", "Self-host (cards + power + people)")} value={buildCost} max={Math.max(cloudCost, buildCost) * 1.15} tone={cheaper === "build" ? "ok" : "mut"} valText={yuan(buildCost)} />
        <Bar label={L("其中:被低估的运维人力", "of which: the underestimated ops line")} value={opsCost} max={Math.max(cloudCost, buildCost) * 1.15} tone="warn" valText={yuan(opsCost)} />
      </div>

      <div style={{ marginTop: 10 }}>
        <div className="vo-cap">{L("云成本随用量线性上升,自建在这段量级里是一条水平线(虚线为当前用量)", "Cloud cost rises linearly with usage; self-hosting is flat across this range (dashed = current usage)")}</div>
        <MiniPlot data={curve} markIndex={Math.round(minsMonth / 10000)} yMin={0} />
      </div>

      <Note mark="→" tone="on">
        {cheaper === "cloud"
          ? L(`当前用量 ${big(minsMonth)} 分钟/月,买比建便宜 ${yuan(buildCost - cloudCost)}。交叉点在 ${big(breakMins)} 分钟/月——单店基本永远到不了,所以单店应该买。注意自建成本里最大的一笔往往不是显卡,是那 ${opsDays} 个人天:一个会部署模型、能在半夜服务挂了时爬起来的人。`,
              `At ${big(minsMonth)} minutes a month, buying beats building by ${yuan(buildCost - cloudCost)}. The crossing sits at ${big(breakMins)} minutes a month, which a single shop essentially never reaches — so a single shop buys. Note the biggest line in the build cost is usually not the card but those ${opsDays} engineer days: someone who can deploy models and get up when it dies at 2 a.m.`)
          : L(`当前用量已经越过交叉点 ${big(breakMins)} 分钟/月,自建更便宜。但在下决定之前先问三个问题:高峰并发能不能扛(现在算出来要 ${cards} 张卡)、有没有那个会运维的人、以及模型升级谁来跟。一个常见的折中是混合部署:日常量自建,高峰溢出到云。`,
              `Usage has crossed the ${big(breakMins)} minutes-a-month break-even, so self-hosting is cheaper. Before deciding, answer three questions: can you carry the peak (this configuration needs ${cards} cards), do you have the person to operate it, and who tracks model upgrades. A common compromise is hybrid: serve the baseline yourself and overflow the peak to the cloud.`)}
      </Note>
    </div>
  );
}

/* =========================================================
   v21 · costLab — the per-call cost waterfall
   ========================================================= */
function CostViz() {
  const L = useL();
  const [dur, setDur] = React.useState(3);        // minutes
  const [turns, setTurns] = React.useState(9);
  const [ctxK, setCtxK] = React.useState(2.4);    // input tokens per turn, thousands
  const [asrMin, setAsrMin] = React.useState(0.04);
  const [telMin, setTelMin] = React.useState(0.08);
  const [ttsWan, setTtsWan] = React.useState(3.0); // yuan per 10k characters
  const [salary, setSalary] = React.useState(5200);

  const asr = dur * asrMin;
  const tel = dur * telMin;
  const inTok = turns * ctxK * 1000, outTok = turns * 140;
  const llm = (inTok / 1e6) * 2.4 + (outTok / 1e6) * 9.6;
  const chars = turns * 42;
  const tts = (chars / 10000) * ttsWan;
  const total = asr + tel + llm + tts;

  const humanCallsMonth = 22 * 8 * 60 / (dur * 1.6);  // an agent is ~60% utilised
  const humanCost = (salary * 1.35) / humanCallsMonth;

  const parts = [
    { k: "tel", l: L("电话线路", "Telephony"), v: tel, c: "#d98a1f" },
    { k: "llm", l: L("大模型 token", "LLM tokens"), v: llm, c: "var(--accent)" },
    { k: "asr", l: L("语音识别", "Recognition"), v: asr, c: "var(--primary)" },
    { k: "tts", l: L("语音合成", "Synthesis"), v: tts, c: "#2e9e6b" },
  ].sort((a, b) => b.v - a.v);
  const biggest = parts[0];

  return (
    <div>
      <VizHead idx="PF3" title={L("一通三分钟的电话,账单分四段——最贵的那段往往不是 AI", "A three-minute call bills in four lines, and the biggest is usually not the AI")} />
      <div className="viz-ctrl">
        <Slider label={L("通话时长", "Call duration")} min={0.5} max={10} step={0.5} value={dur} onChange={setDur} unit={L(" 分钟", " min")} />
        <Slider label={L("对话轮次", "Turns")} min={3} max={24} value={turns} onChange={setTurns} />
        <Slider label={L("每轮输入上下文", "Input context per turn")} min={0.4} max={8} step={0.2} value={ctxK} onChange={setCtxK} unit="k tok" />
        <Slider label={L("识别单价", "ASR price")} min={0.01} max={0.15} step={0.005} value={asrMin} onChange={setAsrMin} fmt={(v) => `¥${nf(v, 3)}/min`} />
        <Slider label={L("线路单价", "Telephony price")} min={0.02} max={0.3} step={0.01} value={telMin} onChange={setTelMin} fmt={(v) => `¥${nf(v, 2)}/min`} />
        <Slider label={L("合成单价", "TTS price")} min={0.5} max={12} step={0.5} value={ttsWan} onChange={setTtsWan} fmt={(v) => `¥${nf(v, 1)}/${L("万字", "10k chars")}`} />
        <Slider label={L("前台月薪", "Front-desk salary")} min={3000} max={9000} step={200} value={salary} onChange={setSalary} fmt={(v) => yuan(v)} />
      </div>

      <div className="vo-kpi-grid">
        <Kpi label={L("单通 AI 成本", "AI cost per call")} value={`¥${nf(total, 3)}`} tone="ok" />
        <Kpi label={L("最贵的一段", "Biggest line")} value={biggest.l} tone="warn" hint={`¥${nf(biggest.v, 3)} · ${pct(biggest.v / total)}`} />
        <Kpi label={L("单通人工成本", "Human cost per call")} value={`¥${nf(humanCost, 2)}`} tone="acc" hint={L(`按每月 ${nf(humanCallsMonth, 0)} 通折算`, `over ${nf(humanCallsMonth, 0)} calls/month`)} />
        <Kpi label={L("成本比", "Ratio")} value={`1 : ${nf(humanCost / total, 0)}`} tone="ok" hint={L("AI : 人工", "AI : human")} />
      </div>

      <div style={{ marginTop: 12 }}>
        <div className="vo-cap">{L("成本瀑布(按占比排序)", "The cost waterfall, largest first")}</div>
        <svg viewBox="0 0 620 96" width="100%" style={{ display: "block", marginTop: 4 }}>
          {(() => { let x = 10; return parts.map((p) => {
            const w = (p.v / total) * 600;
            const el = (
              <g key={p.k}>
                <rect x={x} y={14} width={Math.max(2, w)} height="30" rx="4" fill={p.c} opacity="0.88" />
                <text x={x + Math.max(2, w) / 2} y={33} textAnchor="middle" style={{ font: "600 10px var(--f-mono)", fill: "#fff" }}>{pct(p.v / total)}</text>
                <text x={x + Math.max(2, w) / 2} y={60} textAnchor="middle" style={{ font: "500 9.5px var(--f-sans)", fill: "var(--muted)" }}>{p.l}</text>
                <text x={x + Math.max(2, w) / 2} y={74} textAnchor="middle" style={{ font: "600 9.5px var(--f-mono)", fill: "var(--ink)" }}>{`¥${nf(p.v, 3)}`}</text>
              </g>
            );
            x += Math.max(2, w) + 3;
            return el;
          }); })()}
          <text x="10" y="92" style={{ font: "500 9px var(--f-mono)", fill: "var(--muted)" }}>{L(`单价随厂商与合约变化,请以官网为准`, "unit prices vary by vendor and contract — check their own pages")}</text>
        </svg>
      </div>

      <div style={{ marginTop: 6 }}>
        <Bar label={L("压缩上下文:检索 top-8 → top-3 + 历史摘要", "Compress context: top-8 → top-3 plus summarised history")} value={llm * 0.45} max={total} tone="ok" valText={`¥${nf(llm * 0.45, 3)}`} />
        <Bar label={L("当前大模型成本", "LLM cost now")} value={llm} max={total} tone="acc" valText={`¥${nf(llm, 3)}`} />
      </div>

      <Note mark="→" tone="on">
        {L(`单通 ¥${nf(total, 3)},人工 ¥${nf(humanCost, 2)},差 ${nf(humanCost / total, 0)} 倍。但真正有用的结论是结构性的:最贵的一段是「${biggest.l}」,而输入 token 几乎总是第二贵且最容易优化——把检索从 top-8 降到 top-3、把对话历史做摘要,这一项能直接腰斩。另外别忘了把成本放回收益里看:在客单价两百多的生意里,一通把顾客约到店的电话,花两块还是两毛,差别没有想象中大。`,
              `¥${nf(total, 3)} per call against ¥${nf(humanCost, 2)} for a human, a factor of ${nf(humanCost / total, 0)}. The useful conclusion is structural: the biggest line is ${biggest.l}, and input tokens are almost always second and by far the most optimisable — dropping retrieval from top-8 to top-3 and summarising history halves that line. And keep cost next to revenue: in a business with a 200-plus yuan ticket, whether a booking call costs two yuan or twenty cents matters less than it feels.`)}
      </Note>
    </div>
  );
}

/* =========================================================
   v22 · telLab — SIP concurrency and the outbound red line
   ========================================================= */
function TelViz() {
  const L = useL();
  const [mode, setMode] = React.useState("in");
  const [chans, setChans] = React.useState(4);
  const [calls, setCalls] = React.useState(120);
  const [aht, setAht] = React.useState(150);
  const [peak, setPeak] = React.useState(0.42);
  const [freq, setFreq] = React.useState(2);       // outbound attempts per contact per month
  const [listQ, setListQ] = React.useState(0.6);   // share of the list that actually consented

  const peakRate = (calls * peak) / 3;
  const a = peakRate * (aht / 3600);
  const block = erlangB(chans, a);
  const answered = calls * (1 - block);

  const connect = clamp(0.42 * (0.6 + 0.4 * listQ) * Math.pow(0.88, Math.max(0, freq - 1)), 0, 1);
  const conv = connect * (0.12 * listQ);
  const complaint = clamp(0.0009 * Math.pow(freq, 2.1) * (1.8 - listQ), 0, 0.25);
  const risk = complaint > 0.012 ? "high" : complaint > 0.005 ? "mid" : "low";

  return (
    <div>
      <VizHead idx="CH1" title={L("呼入看并发线路,呼出看合规红线——两件完全不同的事", "Inbound is a concurrency problem; outbound is a compliance problem")} />
      <div className="vo-seg">
        <button className={mode === "in" ? "on" : ""} onClick={() => setMode("in")}>{L("呼入:线路与呼损", "Inbound: channels and blocking")}</button>
        <button className={mode === "out" ? "on" : ""} onClick={() => setMode("out")}>{L("外呼:频次与投诉", "Outbound: frequency and complaints")}</button>
      </div>

      {mode === "in" ? (
        <div>
          <div className="viz-ctrl" style={{ marginTop: 8 }}>
            <Slider label={L("SIP 并发线路数", "SIP concurrent channels")} min={1} max={30} value={chans} onChange={setChans} />
            <Slider label={L("日来电量", "Calls per day")} min={40} max={400} step={10} value={calls} onChange={setCalls} />
            <Slider label={L("平均通话时长", "Average handle time")} min={60} max={300} step={10} value={aht} onChange={setAht} unit=" s" />
            <Slider label={L("高峰 3 小时占比", "Share in 3 peak hours")} min={0.25} max={0.65} step={0.01} value={peak} onChange={setPeak} fmt={pct} />
          </div>
          <div className="vo-kpi-grid">
            <Kpi label={L("高峰话务强度", "Peak traffic")} value={nf(a, 2)} unit=" Erl" tone="acc" />
            <Kpi label={L("呼损率", "Blocking")} value={pct1(block)} tone={block > 0.02 ? "warn" : "ok"} hint={L("听到忙音的比例", "share hearing a busy tone")} />
            <Kpi label={L("每天接通", "Answered per day")} value={nf(answered, 0)} tone="ok" />
            <Kpi label={L("要做到 1% 呼损需要", "Channels for 1% blocking")} value={(() => { let n = 1; while (erlangB(n, a) > 0.01 && n < 80) n++; return n; })()} unit={L(" 路", "")} tone="acc" />
          </div>
          <div style={{ marginTop: 10 }}>
            <Boxes items={Array.from({ length: Math.min(chans, 24) }, (_, i) => ({ label: `L${i + 1}`, state: i < Math.min(Math.round(a), chans) ? "live" : "idle" }))} />
            <div className="vo-cap" style={{ marginTop: 6 }}>{L("深色是高峰时段平均占用的线路;买多少路就只能同时通多少路,超了就是忙音。", "Dark lines are those busy on average at peak. You can only carry as many simultaneous calls as you bought; beyond that, busy tone.")}</div>
          </div>
          <Note mark="→" tone={block > 0.02 ? "bad" : "on"}>
            {block > 0.02
              ? L(`呼损 ${pct1(block)}。线路和坐席是两回事:就算 AI 能同时接一百通,只买了 ${chans} 路中继,第 ${chans + 1} 个顾客照样听忙音。按 1% 呼损的行业惯例反推所需线路数,再留一点冗余。`,
                  `Blocking is ${pct1(block)}. Channels and agents are different things: even if the AI can hold a hundred conversations, with ${chans} trunk channels the ${chans + 1}-th caller still hears busy. Size channels from a 1% blocking target and keep some headroom.`)
              : L(`呼损 ${pct1(block)},线路够用。顺带注意链路编码:中继上通常是 8 kHz 的 G.711 或 G.729,回到第四章讲的窄带问题——这就是为什么电话渠道的字错率天生比小程序高。`,
                  `Blocking is ${pct1(block)}, so channels are sufficient. Note the codec while you are here: trunks usually carry 8 kHz G.711 or G.729, which returns you to chapter four's narrowband problem — the reason telephony error rates are structurally higher than a mini-program's.`)}
          </Note>
        </div>
      ) : (
        <div>
          <div className="viz-ctrl" style={{ marginTop: 8 }}>
            <Slider label={L("每人每月拨打次数", "Attempts per contact per month")} min={1} max={12} value={freq} onChange={setFreq} />
            <Slider label={L("名单质量(事先同意的比例)", "List quality (share who consented)")} min={0.1} max={1} step={0.05} value={listQ} onChange={setListQ} fmt={pct} />
          </div>
          <div className="vo-kpi-grid">
            <Kpi label={L("接通率", "Connect rate")} value={pct1(connect)} tone={connect > 0.3 ? "ok" : "warn"} />
            <Kpi label={L("转化率", "Conversion")} value={pct1(conv)} tone="acc" hint={L("早就见顶了", "plateaued long ago")} />
            <Kpi label={L("投诉率", "Complaint rate")} value={pct2(complaint)} tone={risk === "high" ? "warn" : risk === "mid" ? "acc" : "ok"} />
            <Kpi label={L("号码风险", "Number at risk")} value={risk === "high" ? L("高", "high") : risk === "mid" ? L("中", "medium") : L("低", "low")} tone={risk === "high" ? "warn" : "ok"} />
          </div>
          <div style={{ marginTop: 10 }}>
            <Bar label={L("接通率", "Connect rate")} value={connect} max={0.6} tone="ok" valText={pct1(connect)} />
            <Bar label={L("转化率", "Conversion")} value={conv} max={0.6} tone="acc" valText={pct1(conv)} />
            <Bar label={L("投诉率(注意刻度不同)", "Complaint rate (note the scale)")} value={complaint} max={0.05} tone="warn" valText={pct2(complaint)} />
          </div>
          <Note mark="⚠" tone={risk === "low" ? "on" : "bad"}>
            {L(`频次拉到每月 ${freq} 次:转化率早就见顶,投诉率却在超线性上涨。外呼的规则很硬——商业性语音呼叫必须事先取得接收方同意,用户明确拒绝后不得再拨,时段与频次都有约束,并且要提供便捷的拒收方式。做过头的后果不只是被投诉:号码可能被运营商限制甚至停用,而一家门店的号码是资产。把名单质量提上去(只打真正同意过的会员)比把频次提上去划算得多。`,
                  `At ${freq} attempts a month, conversion plateaued long ago while complaints climb super-linearly. The outbound rules are hard: commercial voice calls require prior consent, must stop permanently once refused, are bounded in time of day and frequency, and must offer an easy opt-out. Overdoing it costs more than complaints — the carrier can restrict or cut off the number, and a shop's number is an asset. Raising list quality (call only members who genuinely consented) pays far better than raising frequency.`)}
          </Note>
        </div>
      )}
    </div>
  );
}

window.__VO_VIZ_3 = { latencyLab: LatencyViz, bargeLab: BargeViz, e2eLab: E2eViz, platformLab: PlatformViz, buildLab: BuildViz, costLab: CostViz, telLab: TelViz };
