You need to sign in before continuing.

Commit 7b447776a1bb51ec6bb8bcad6daf1eb5867d10f9

Authored by yibo
0 parents

Add conversion funnel modeling page

3D Three.js funnel visualizing the product's growth funnel (traffic
entry through viral loop) with an editable channel/feature breakdown
and target-based red/yellow/blue/green conversion-rate alerts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing 1 changed file with 984 additions and 0 deletions
index.html 0 → 100644
  1 +++ a/index.html
  1 +<!doctype html>
  2 +<html lang="zh-CN">
  3 +<head>
  4 +<meta charset="utf-8">
  5 +<meta name="viewport" content="width=device-width, initial-scale=1">
  6 +<title>转化漏斗建模台</title>
  7 +<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📉</text></svg>">
  8 +<link rel="preconnect" href="https://fonts.googleapis.com">
  9 +<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  10 +<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Sans+SC:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
  11 +<style>
  12 + *{ box-sizing:border-box; }
  13 + html{ color-scheme: light; }
  14 + body{ margin: 0; }
  15 + :root{
  16 + color-scheme: light;
  17 + --page: #f9f9f7;
  18 + --surface: #fcfcfb;
  19 + --ink: #0b0b0b;
  20 + --ink-2: #52514e;
  21 + --muted: #898781;
  22 + --grid: #e1e0d9;
  23 + --border: rgba(11,11,11,0.10);
  24 + --good: #0ca30c;
  25 + --critical: #d03b3b;
  26 + --accent: #2a78d6;
  27 + --spout: #0d366b;
  28 + --alert-red: #d03b3b;
  29 + --alert-yellow: #eda100;
  30 + --alert-blue: #2a78d6;
  31 + --alert-green: #008300;
  32 + --shadow: 0 1px 2px rgba(11,11,11,0.05), 0 8px 24px -12px rgba(11,11,11,0.18);
  33 + }
  34 + @media (prefers-color-scheme: dark){
  35 + :root:not([data-theme="light"]){
  36 + color-scheme: dark;
  37 + --page: #0d0d0d;
  38 + --surface: #1a1a19;
  39 + --ink: #ffffff;
  40 + --ink-2: #c3c2b7;
  41 + --muted: #898781;
  42 + --grid: #2c2c2a;
  43 + --border: rgba(255,255,255,0.10);
  44 + --good: #0ca30c;
  45 + --critical: #d03b3b;
  46 + --accent: #3987e5;
  47 + --spout: #cde2fb;
  48 + --alert-red: #d03b3b;
  49 + --alert-yellow: #c98500;
  50 + --alert-blue: #3987e5;
  51 + --alert-green: #008300;
  52 + --shadow: 0 1px 2px rgba(0,0,0,0.4), 0 8px 28px -12px rgba(0,0,0,0.6);
  53 + }
  54 + }
  55 + :root[data-theme="dark"]{
  56 + color-scheme: dark;
  57 + --page: #0d0d0d;
  58 + --surface: #1a1a19;
  59 + --ink: #ffffff;
  60 + --ink-2: #c3c2b7;
  61 + --muted: #898781;
  62 + --grid: #2c2c2a;
  63 + --border: rgba(255,255,255,0.10);
  64 + --good: #0ca30c;
  65 + --critical: #d03b3b;
  66 + --accent: #3987e5;
  67 + --spout: #cde2fb;
  68 + --alert-red: #d03b3b;
  69 + --alert-yellow: #c98500;
  70 + --alert-blue: #3987e5;
  71 + --alert-green: #008300;
  72 + --shadow: 0 1px 2px rgba(0,0,0,0.4), 0 8px 28px -12px rgba(0,0,0,0.6);
  73 + }
  74 +
  75 + body{
  76 + background: var(--page);
  77 + color: var(--ink);
  78 + font-family: "IBM Plex Sans", "IBM Plex Sans SC", system-ui, -apple-system, "Segoe UI", sans-serif;
  79 + }
  80 + .wrap{
  81 + max-width: 1220px;
  82 + margin: 0 auto;
  83 + padding-inline: clamp(16px, 4vw, 32px);
  84 + padding-block: clamp(20px, 4vw, 40px);
  85 + }
  86 + .mono{ font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; }
  87 + .tab{ font-variant-numeric: tabular-nums; }
  88 +
  89 + header{
  90 + display:flex;
  91 + flex-wrap: wrap;
  92 + justify-content: space-between;
  93 + align-items: flex-end;
  94 + gap: 16px;
  95 + margin-bottom: 28px;
  96 + padding-bottom: 20px;
  97 + border-bottom: 1px solid var(--border);
  98 + }
  99 + header h1{
  100 + margin:0;
  101 + font-size: clamp(22px, 3vw, 28px);
  102 + font-weight: 700;
  103 + letter-spacing: -0.01em;
  104 + text-wrap: balance;
  105 + }
  106 + header p{
  107 + margin: 6px 0 0;
  108 + color: var(--ink-2);
  109 + font-size: 13.5px;
  110 + max-width: 60ch;
  111 + line-height: 1.5;
  112 + }
  113 + .eyebrow{
  114 + display:block;
  115 + font-size: 11px;
  116 + font-weight: 600;
  117 + letter-spacing: 0.08em;
  118 + text-transform: uppercase;
  119 + color: var(--accent);
  120 + margin-bottom: 6px;
  121 + }
  122 + .reset-btn{
  123 + appearance:none;
  124 + border: 1px solid var(--border);
  125 + background: var(--surface);
  126 + color: var(--ink-2);
  127 + font: inherit;
  128 + font-size: 13px;
  129 + font-weight: 500;
  130 + padding: 9px 14px;
  131 + border-radius: 8px;
  132 + cursor: pointer;
  133 + white-space: nowrap;
  134 + }
  135 + .reset-btn:hover{ color: var(--ink); border-color: var(--ink-2); }
  136 + .reset-btn:focus-visible{ outline: 2px solid var(--accent); outline-offset: 2px; }
  137 +
  138 + .stats{
  139 + display:grid;
  140 + grid-template-columns: repeat(4, 1fr);
  141 + gap: 12px;
  142 + margin-bottom: 24px;
  143 + }
  144 + @media (max-width: 720px){ .stats{ grid-template-columns: repeat(2, 1fr); } }
  145 + .stat{
  146 + background: var(--surface);
  147 + border: 1px solid var(--border);
  148 + border-radius: 12px;
  149 + padding: 14px 16px;
  150 + }
  151 + .stat .label{
  152 + font-size: 11px;
  153 + font-weight: 600;
  154 + letter-spacing: 0.04em;
  155 + text-transform: uppercase;
  156 + color: var(--muted);
  157 + margin-bottom: 8px;
  158 + }
  159 + .stat .value{
  160 + font-size: 21px;
  161 + font-weight: 600;
  162 + letter-spacing: -0.01em;
  163 + }
  164 + .stat .sub{
  165 + margin-top: 4px;
  166 + font-size: 12px;
  167 + color: var(--ink-2);
  168 + }
  169 + .badge{
  170 + display:inline-flex;
  171 + align-items:center;
  172 + gap: 4px;
  173 + font-size: 11.5px;
  174 + font-weight: 600;
  175 + padding: 2px 7px;
  176 + border-radius: 999px;
  177 + }
  178 + .badge.red{ color: var(--alert-red); background: color-mix(in srgb, var(--alert-red) 14%, transparent); }
  179 + .badge.yellow{ color: var(--alert-yellow); background: color-mix(in srgb, var(--alert-yellow) 18%, transparent); }
  180 + .badge.blue{ color: var(--alert-blue); background: color-mix(in srgb, var(--alert-blue) 14%, transparent); }
  181 + .badge.green{ color: var(--alert-green); background: color-mix(in srgb, var(--alert-green) 14%, transparent); }
  182 +
  183 + .alert-legend{
  184 + display:flex;
  185 + flex-wrap: wrap;
  186 + gap: 16px;
  187 + margin: -10px 0 20px;
  188 + font-size: 11.5px;
  189 + color: var(--ink-2);
  190 + }
  191 + .alert-legend .item{ display:flex; align-items:center; gap:6px; }
  192 +
  193 + .status-line{
  194 + font-size: 11.5px;
  195 + font-weight: 600;
  196 + margin: 0 12px 10px 0;
  197 + }
  198 +
  199 + .board{
  200 + display:grid;
  201 + grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
  202 + gap: 20px;
  203 + align-items: start;
  204 + }
  205 + @media (max-width: 900px){ .board{ grid-template-columns: 1fr; } }
  206 +
  207 + .panel{
  208 + background: var(--surface);
  209 + border: 1px solid var(--border);
  210 + border-radius: 14px;
  211 + box-shadow: var(--shadow);
  212 + overflow: hidden;
  213 + }
  214 + .panel-head{
  215 + display:flex;
  216 + justify-content: space-between;
  217 + align-items: baseline;
  218 + gap: 10px;
  219 + padding: 16px 18px 0;
  220 + }
  221 + .panel-head h2{
  222 + margin:0;
  223 + font-size: 14px;
  224 + font-weight: 600;
  225 + }
  226 + .panel-head span{
  227 + font-size: 11.5px;
  228 + color: var(--muted);
  229 + }
  230 +
  231 + .funnel-stage{
  232 + position: relative;
  233 + aspect-ratio: 4 / 4.4;
  234 + max-width: 100%;
  235 + cursor: grab;
  236 + }
  237 + .funnel-stage canvas{ display:block; width:100%; height:100%; touch-action: none; }
  238 + .labels{
  239 + position: absolute;
  240 + inset: 0;
  241 + pointer-events: none;
  242 + }
  243 + .flabel{
  244 + position:absolute;
  245 + transform: translate(0, -50%);
  246 + display:flex;
  247 + flex-direction: column;
  248 + gap: 2px;
  249 + padding: 4px 8px;
  250 + border-radius: 8px;
  251 + background: color-mix(in srgb, var(--surface) 82%, transparent);
  252 + border: 1px solid var(--border);
  253 + backdrop-filter: blur(2px);
  254 + font-size: 11px;
  255 + line-height: 1.3;
  256 + white-space: nowrap;
  257 + transition: opacity 120ms linear;
  258 + }
  259 + .flabel.left{ transform: translate(-100%, -50%); }
  260 + .flabel .name{ font-weight: 600; color: var(--ink); }
  261 + .flabel .val{ color: var(--ink-2); }
  262 + .flabel .conv{ font-weight: 600; }
  263 + .funnel-hint{
  264 + padding: 8px 18px 14px;
  265 + font-size: 11.5px;
  266 + color: var(--muted);
  267 + }
  268 +
  269 + .tree{
  270 + max-height: 620px;
  271 + overflow-y: auto;
  272 + padding: 6px 6px 14px;
  273 + }
  274 + .tnode{ position: relative; }
  275 + .trow{
  276 + display:flex;
  277 + align-items:center;
  278 + gap: 8px;
  279 + padding: 7px 12px 7px 0;
  280 + border-radius: 8px;
  281 + font-size: 13px;
  282 + }
  283 + .trow.flash{ animation: flashBg 900ms ease; }
  284 + @keyframes flashBg{
  285 + 0%{ background: color-mix(in srgb, var(--accent) 22%, transparent); }
  286 + 100%{ background: transparent; }
  287 + }
  288 + .trow-toggle{
  289 + appearance:none;
  290 + border:none;
  291 + background:none;
  292 + color: var(--muted);
  293 + width: 16px;
  294 + height: 16px;
  295 + flex-shrink:0;
  296 + font-size: 10px;
  297 + cursor:pointer;
  298 + display:flex;
  299 + align-items:center;
  300 + justify-content:center;
  301 + }
  302 + .trow-toggle:focus-visible{ outline: 2px solid var(--accent); border-radius: 4px; }
  303 + .trow-toggle-spacer{ width:16px; height:16px; flex-shrink:0; display:inline-block; }
  304 + .dot{
  305 + width: 9px; height: 9px;
  306 + border-radius: 3px;
  307 + flex-shrink:0;
  308 + }
  309 + .dot-muted{ background: var(--grid); border: 1px solid var(--border); }
  310 + .trow-name{
  311 + flex: 1 1 auto;
  312 + min-width: 0;
  313 + overflow: hidden;
  314 + text-overflow: ellipsis;
  315 + white-space: nowrap;
  316 + font-weight: 500;
  317 + }
  318 + .tnode[data-depth="0"] > .trow .trow-name{ font-weight: 600; }
  319 + .trow-right{
  320 + display:flex;
  321 + align-items:center;
  322 + gap: 10px;
  323 + flex-shrink:0;
  324 + }
  325 + .trow-val{ font-size: 13px; }
  326 + .trow-val.strong{ font-weight: 600; }
  327 + .trow-pct{
  328 + font-size: 11.5px;
  329 + color: var(--muted);
  330 + width: 34px;
  331 + text-align: right;
  332 + }
  333 + input.val-input{
  334 + width: 96px;
  335 + text-align: right;
  336 + border: 1px solid var(--border);
  337 + background: var(--page);
  338 + color: var(--ink);
  339 + border-radius: 6px;
  340 + padding: 5px 7px;
  341 + font: inherit;
  342 + font-family: "IBM Plex Mono", ui-monospace, monospace;
  343 + font-size: 12.5px;
  344 + font-variant-numeric: tabular-nums;
  345 + }
  346 + input.val-input:focus-visible{ outline: 2px solid var(--accent); outline-offset: 1px; }
  347 + .trow-bar-track{
  348 + height: 3px;
  349 + border-radius: 2px;
  350 + background: var(--grid);
  351 + margin: 0 12px 2px 0;
  352 + overflow: hidden;
  353 + }
  354 + .trow-bar-fill{ height: 100%; border-radius: 2px; }
  355 + .notes-row{
  356 + display:flex;
  357 + flex-wrap:wrap;
  358 + gap: 6px;
  359 + margin: 0 12px 8px 0;
  360 + }
  361 + .chip{
  362 + font-size: 11px;
  363 + color: var(--ink-2);
  364 + background: var(--page);
  365 + border: 1px solid var(--border);
  366 + border-radius: 999px;
  367 + padding: 2px 9px;
  368 + }
  369 + .loop-note{
  370 + font-size: 11.5px;
  371 + color: var(--accent);
  372 + margin: 0 12px 10px 0;
  373 + }
  374 + .tchildren{ position: relative; }
  375 +
  376 + footer{
  377 + margin-top: 22px;
  378 + font-size: 12px;
  379 + color: var(--muted);
  380 + line-height: 1.6;
  381 + }
  382 +</style>
  383 +</head>
  384 +<body>
  385 +<div class="wrap">
  386 + <header>
  387 + <div>
  388 + <span class="eyebrow">产品增长漏斗模型</span>
  389 + <h1>转化漏斗建模台</h1>
  390 + <p>流量入口 → 网站访问 → 注册 → 使用 → 付费点 → 选购积分 → 订单支付 → 支付状态 → 传播裂变。点击右侧箭头展开各级渠道明细,数值可编辑。</p>
  391 + </div>
  392 + <button class="reset-btn" id="resetBtn" type="button">重置为示例数据</button>
  393 + </header>
  394 +
  395 + <div class="stats" id="statsRow"></div>
  396 + <div class="alert-legend" id="alertLegend"></div>
  397 +
  398 + <div class="board">
  399 + <section class="panel">
  400 + <div class="panel-head">
  401 + <h2>漏斗模型(3D)</h2>
  402 + <span>拖动旋转 · 滚轮缩放 · 点击定位明细</span>
  403 + </div>
  404 + <div class="funnel-stage" id="funnelStage">
  405 + <canvas id="funnelCanvas"></canvas>
  406 + <div class="labels" id="labelLayer"></div>
  407 + </div>
  408 + <p class="funnel-hint">柱体宽度按各级流量的平方根缩放,颜色按本级转化率相对目标值分级:低于目标一半为红色,低于目标为黄色,达到目标为蓝色,达到 2 倍目标为绿色。支付状态一级以「支付成功」延续漏斗,「支付失败」计为本级流失。</p>
  409 + </section>
  410 +
  411 + <section class="panel">
  412 + <div class="panel-head">
  413 + <h2>各级明细</h2>
  414 + <span>可编辑</span>
  415 + </div>
  416 + <div class="tree" id="treeLayer"></div>
  417 + </section>
  418 + </div>
  419 +
  420 + <footer>数值为示例数据,可编辑替换为真实业务口径;带子项的行为下级之和,仅末级数值可编辑;灰色标签为该环节涉及的配置维度(非独立计数)。</footer>
  421 +</div>
  422 +
  423 +<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.min.js"></script>
  424 +<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
  425 +<script>
  426 +(function(){
  427 + "use strict";
  428 +
  429 + // ---------- data model ----------
  430 + // A node is either a leaf ({name, value[, notes, status]}) or a branch
  431 + // ({name, children[...]}) whose value is the sum of its children.
  432 + var TREE = [
  433 + { name: "流量入口", children: [
  434 + { name: "自有社媒平台", children: [
  435 + { name: "TikTok", value: 28000 },
  436 + { name: "YouTube", value: 21000 },
  437 + { name: "Instagram", value: 9000 },
  438 + { name: "Facebook", value: 6000 },
  439 + { name: "Trea", value: 3000 },
  440 + { name: "Discord", value: 4000 },
  441 + { name: "Reddit", value: 5000 }
  442 + ]
  443 + },
  444 + { name: "广告投放", value: 55000 },
  445 + { name: "邮件投放", children: [
  446 + { name: "LemonAI 邮件列表", value: 18000 }
  447 + ]
  448 + },
  449 + { name: "KOL / KOC 社媒平台", value: 30000 },
  450 + { name: "SEO / GEO", value: 15000 },
  451 + { name: "榜单平台", children: [
  452 + { name: "Product Hunt", value: 4000 },
  453 + { name: "产品集合站", value: 2000 }
  454 + ]
  455 + }
  456 + ]
  457 + },
  458 + { name: "网站访问(看视频)", value: 96000 },
  459 + { name: "网站注册", value: 38400 },
  460 + { name: "网站使用(游玩游戏)", children: [
  461 + { name: "文字输入", value: 10000 },
  462 + { name: "语音输入", value: 4200 },
  463 + { name: "视频生成(切片)", value: 6800 },
  464 + { name: "视频生成(全局)", value: 3880 },
  465 + { name: "游戏生成(Free)", value: 2000 }
  466 + ]
  467 + },
  468 + { name: "网站付费点", children: [
  469 + { name: "剧情改写费", value: 5200 },
  470 + { name: "视频生成费", value: 2864 }
  471 + ]
  472 + },
  473 + { name: "选购积分", children: [
  474 + { name: "每日登录领取积分", value: 2000 },
  475 + { name: "购买积分页面", value: 3645,
  476 + notes: ["多个档位", "多种订阅方式", "自动充值开关"] }
  477 + ]
  478 + },
  479 + { name: "订单支付", value: 3200,
  480 + notes: ["支付方式选择", "支付银行卡选择", "优惠券输入"] },
  481 + { name: "支付状态", children: [
  482 + { name: "支付失败", value: 640, status: "critical" },
  483 + { name: "支付成功", status: "good", children: [
  484 + { name: "订阅成功(首次)", value: 1400 },
  485 + { name: "订阅续费", value: 900 },
  486 + { name: "自动充值", value: 260 }
  487 + ]
  488 + }
  489 + ]
  490 + },
  491 + { name: "用户传播裂变", children: [
  492 + { name: "分享按钮", children: [
  493 + { name: "X", value: 300 },
  494 + { name: "YouTube", value: 150 },
  495 + { name: "TikTok", value: 220 },
  496 + { name: "Instagram", value: 180 },
  497 + { name: "Facebook", value: 130 }
  498 + ]
  499 + },
  500 + { name: "下载视频", value: 640 },
  501 + { name: "新用户邀请积分", value: 410 }
  502 + ]
  503 + }
  504 + ];
  505 +
  506 + var nf = new Intl.NumberFormat("zh-CN");
  507 + var pf = new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 1, minimumFractionDigits: 1 });
  508 + function fmtPct(x){ return pf.format(x * 100) + "%"; }
  509 +
  510 + function isLeaf(node){ return !(node.children && node.children.length); }
  511 + function getValue(node){
  512 + if (isLeaf(node)) return node.value || 0;
  513 + return node.children.reduce(function(sum, c){ return sum + getValue(c); }, 0);
  514 + }
  515 + function forEachNode(nodes, fn){
  516 + nodes.forEach(function(n){ fn(n); if (n.children) forEachNode(n.children, fn); });
  517 + }
  518 +
  519 + var idIndex = {};
  520 + (function walk(nodes, prefix, depth, parent){
  521 + nodes.forEach(function(node, i){
  522 + var id = prefix ? prefix + "-" + i : String(i);
  523 + node.__id = id;
  524 + node.__depth = depth;
  525 + node.__parent = parent || null;
  526 + if (isLeaf(node)) node.__defaultValue = node.value;
  527 + idIndex[id] = node;
  528 + if (node.children) walk(node.children, id, depth + 1, node);
  529 + });
  530 + })(TREE, "", 0, null);
  531 +
  532 + var payStatusNode = TREE[7];
  533 + var paySuccessNode = payStatusNode.children.filter(function(c){ return c.name === "支付成功"; })[0];
  534 + var payFailNode = payStatusNode.children.filter(function(c){ return c.name === "支付失败"; })[0];
  535 +
  536 + function funnelValues(){
  537 + return TREE.map(function(node){
  538 + return node === payStatusNode ? getValue(paySuccessNode) : getValue(node);
  539 + });
  540 + }
  541 +
  542 + // ---------- target-based alert levels ----------
  543 + // Estimated target conversion rate INTO each stage (index aligned to TREE);
  544 + // index 0 (流量入口) has no incoming rate. Replace with real business targets
  545 + // once available.
  546 + var TARGETS = [null, 0.50, 0.25, 0.60, 0.20, 0.50, 0.40, 0.90, 0.20];
  547 + var ALERT_TEXT = {
  548 + red: "红色 · 低于目标一半",
  549 + yellow: "黄色 · 低于目标",
  550 + blue: "蓝色 · 已达标",
  551 + green: "绿色 · 超额达标(≥2×目标)"
  552 + };
  553 + var ALERT_SHORT = { red: "警戒", yellow: "待改善", blue: "达标", green: "超额" };
  554 +
  555 + function stageStatus(i, vals){
  556 + var target = TARGETS[i];
  557 + if (target == null) return { rate: null, target: null, level: null };
  558 + var rate = vals[i - 1] > 0 ? vals[i] / vals[i - 1] : 0;
  559 + var level;
  560 + if (rate < target / 2) level = "red";
  561 + else if (rate < target) level = "yellow";
  562 + else if (rate < target * 2) level = "blue";
  563 + else level = "green";
  564 + return { rate: rate, target: target, level: level };
  565 + }
  566 +
  567 + function alertVar(level){
  568 + return level === "red" ? "--alert-red"
  569 + : level === "yellow" ? "--alert-yellow"
  570 + : level === "green" ? "--alert-green"
  571 + : level === "blue" ? "--alert-blue"
  572 + : "--muted";
  573 + }
  574 +
  575 + function renderLegend(){
  576 + document.getElementById("alertLegend").innerHTML =
  577 + '<span class="item"><span class="dot" style="background:var(--alert-red)"></span>低于目标一半</span>' +
  578 + '<span class="item"><span class="dot" style="background:var(--alert-yellow)"></span>低于目标</span>' +
  579 + '<span class="item"><span class="dot" style="background:var(--alert-blue)"></span>达到目标</span>' +
  580 + '<span class="item"><span class="dot" style="background:var(--alert-green)"></span>达到 2 倍目标</span>';
  581 + }
  582 +
  583 + var expanded = {};
  584 + TREE.forEach(function(n){ expanded[n.__id] = true; });
  585 +
  586 + function resetToDefaults(){
  587 + forEachNode(TREE, function(n){ if (isLeaf(n)) n.value = n.__defaultValue; });
  588 + renderAll();
  589 + }
  590 + document.getElementById("resetBtn").addEventListener("click", resetToDefaults);
  591 +
  592 + // ---------- stats row ----------
  593 + var statsRow = document.getElementById("statsRow");
  594 +
  595 + function renderStats(){
  596 + var vals = funnelValues();
  597 + var top = vals[0];
  598 + var overall = top > 0 ? vals[vals.length - 1] / top : 0;
  599 +
  600 + var steps = [];
  601 + for (var i = 1; i < vals.length; i++){
  602 + var st = stageStatus(i, vals);
  603 + steps.push({
  604 + label: TREE[i - 1].name + " → " + TREE[i].name,
  605 + st: st,
  606 + ratio: st.target > 0 ? st.rate / st.target : 0
  607 + });
  608 + }
  609 + var passCount = steps.filter(function(s){ return s.st.level === "blue" || s.st.level === "green"; }).length;
  610 + var worst = steps.reduce(function(a, b){ return b.ratio < a.ratio ? b : a; });
  611 +
  612 + statsRow.innerHTML =
  613 + '<div class="stat"><div class="label">顶部流量</div>' +
  614 + '<div class="value mono tab">' + nf.format(top) + "</div>" +
  615 + '<div class="sub">' + TREE[0].name + "</div></div>" +
  616 + '<div class="stat"><div class="label">整体转化率</div>' +
  617 + '<div class="value mono tab">' + fmtPct(overall) + "</div>" +
  618 + '<div class="sub">' + TREE[0].name + " → " + TREE[TREE.length - 1].name + "</div></div>" +
  619 + '<div class="stat"><div class="label">达标环节</div>' +
  620 + '<div class="value mono tab">' + passCount + " / " + steps.length + "</div>" +
  621 + '<div class="sub">蓝色或绿色(≥目标)环节数</div></div>' +
  622 + '<div class="stat"><div class="label">最需关注</div>' +
  623 + '<div class="value mono tab" style="color:var(' + alertVar(worst.st.level) + ')">' + fmtPct(worst.st.rate) + "</div>" +
  624 + '<div class="sub"><span class="badge ' + worst.st.level + '">● ' + ALERT_SHORT[worst.st.level] + "</span>&nbsp;" + worst.label + "</div></div>";
  625 + }
  626 +
  627 + function tokenHex(name){
  628 + return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
  629 + }
  630 +
  631 + function stageColorHex(i, vals){
  632 + if (i === 0) return tokenHex("--muted");
  633 + return tokenHex(alertVar(stageStatus(i, vals).level));
  634 + }
  635 +
  636 + // ---------- editable tree panel ----------
  637 + var treeLayer = document.getElementById("treeLayer");
  638 +
  639 + function renderNode(node, topIndex, vals){
  640 + var depth = node.__depth;
  641 + var hasChildren = !isLeaf(node);
  642 + var wrapper = document.createElement("div");
  643 + wrapper.className = "tnode";
  644 + wrapper.setAttribute("data-depth", depth);
  645 +
  646 + var row = document.createElement("div");
  647 + row.className = "trow";
  648 + row.style.paddingLeft = (14 + depth * 18) + "px";
  649 + if (depth === 0) row.id = "stage-row-" + topIndex;
  650 +
  651 + var toggleHtml = hasChildren
  652 + ? '<button class="trow-toggle" type="button" aria-label="展开或收起' + node.name + '" data-id="' + node.__id + '">' + (expanded[node.__id] ? "▾" : "▸") + "</button>"
  653 + : '<span class="trow-toggle-spacer"></span>';
  654 +
  655 + var dotHtml;
  656 + if (depth === 0){
  657 + dotHtml = '<span class="dot" style="background:' + stageColorHex(topIndex, vals) + '"></span>';
  658 + } else if (node.status === "critical"){
  659 + dotHtml = '<span class="dot" style="background:var(--critical)"></span>';
  660 + } else if (node.status === "good"){
  661 + dotHtml = '<span class="dot" style="background:var(--good)"></span>';
  662 + } else {
  663 + dotHtml = '<span class="dot dot-muted"></span>';
  664 + }
  665 +
  666 + var val = getValue(node);
  667 + var parentVal = node.__parent ? getValue(node.__parent) : null;
  668 + var pct = parentVal ? (val / parentVal * 100) : null;
  669 +
  670 + var valueHtml = hasChildren
  671 + ? '<span class="trow-val mono tab strong">' + nf.format(val) + "</span>"
  672 + : '<input class="val-input mono" type="number" min="0" step="1" value="' + val + '" data-id="' + node.__id + '" aria-label="' + node.name + ' 数值">';
  673 +
  674 + row.innerHTML = toggleHtml + dotHtml +
  675 + '<span class="trow-name">' + node.name + "</span>" +
  676 + '<span class="trow-right">' + valueHtml +
  677 + (pct !== null ? '<span class="trow-pct mono tab">' + pct.toFixed(0) + "%</span>" : "") +
  678 + "</span>";
  679 + wrapper.appendChild(row);
  680 +
  681 + if (pct !== null){
  682 + var bar = document.createElement("div");
  683 + bar.className = "trow-bar-track";
  684 + bar.style.marginLeft = (14 + depth * 18 + 22) + "px";
  685 + bar.innerHTML = '<div class="trow-bar-fill" style="width:' + Math.min(100, pct) + '%; background:var(--muted)"></div>';
  686 + wrapper.appendChild(bar);
  687 + }
  688 +
  689 + if (node.notes && node.notes.length){
  690 + var notesRow = document.createElement("div");
  691 + notesRow.className = "notes-row";
  692 + notesRow.style.marginLeft = (14 + depth * 18 + 22) + "px";
  693 + notesRow.innerHTML = node.notes.map(function(t){ return '<span class="chip">' + t + "</span>"; }).join("");
  694 + wrapper.appendChild(notesRow);
  695 + }
  696 +
  697 + if (node === payStatusNode){
  698 + var failVal = getValue(payFailNode);
  699 + var note = document.createElement("div");
  700 + note.className = "loop-note";
  701 + note.style.color = "var(--critical)";
  702 + note.style.marginLeft = (14 + depth * 18 + 22) + "px";
  703 + note.textContent = "本级流失(支付失败):" + nf.format(failVal) + ",漏斗以「支付成功」延续";
  704 + wrapper.appendChild(note);
  705 + }
  706 + if (node.name === "用户传播裂变"){
  707 + var loop = document.createElement("div");
  708 + loop.className = "loop-note";
  709 + loop.style.marginLeft = (14 + depth * 18 + 22) + "px";
  710 + loop.textContent = "↻ 邀请奖励反哺「流量入口」,形成增长闭环";
  711 + wrapper.appendChild(loop);
  712 + }
  713 +
  714 + if (depth === 0 && topIndex >= 1){
  715 + var st = stageStatus(topIndex, vals);
  716 + var statusLine = document.createElement("div");
  717 + statusLine.className = "status-line";
  718 + statusLine.style.marginLeft = (14 + depth * 18 + 22) + "px";
  719 + statusLine.style.color = "var(" + alertVar(st.level) + ")";
  720 + statusLine.textContent = "目标 " + fmtPct(st.target) + " · 实际 " + fmtPct(st.rate) + " · " + ALERT_TEXT[st.level];
  721 + wrapper.appendChild(statusLine);
  722 + }
  723 +
  724 + if (hasChildren && expanded[node.__id]){
  725 + var childWrap = document.createElement("div");
  726 + childWrap.className = "tchildren";
  727 + node.children.forEach(function(c){ childWrap.appendChild(renderNode(c, topIndex, vals)); });
  728 + wrapper.appendChild(childWrap);
  729 + }
  730 +
  731 + return wrapper;
  732 + }
  733 +
  734 + function renderTree(){
  735 + var vals = funnelValues();
  736 + treeLayer.innerHTML = "";
  737 + TREE.forEach(function(node, i){ treeLayer.appendChild(renderNode(node, i, vals)); });
  738 + }
  739 +
  740 + treeLayer.addEventListener("click", function(e){
  741 + var btn = e.target.closest(".trow-toggle");
  742 + if (!btn) return;
  743 + var id = btn.getAttribute("data-id");
  744 + expanded[id] = !expanded[id];
  745 + renderTree();
  746 + });
  747 +
  748 + treeLayer.addEventListener("input", function(e){
  749 + if (!e.target.classList.contains("val-input")) return;
  750 + var id = e.target.getAttribute("data-id");
  751 + var node = idIndex[id];
  752 + var caret = e.target.selectionStart;
  753 + var v = parseFloat(e.target.value);
  754 + if (isNaN(v) || v < 0) v = 0;
  755 + node.value = v;
  756 + renderStats();
  757 + renderTree();
  758 + rebuildFunnel();
  759 + var again = treeLayer.querySelector('[data-id="' + id + '"]');
  760 + if (again){
  761 + again.focus();
  762 + try { again.setSelectionRange(caret, caret); } catch (err) {}
  763 + }
  764 + });
  765 +
  766 + function highlightStage(idx){
  767 + var row = document.getElementById("stage-row-" + idx);
  768 + if (!row) return;
  769 + row.scrollIntoView({ behavior: "smooth", block: "center" });
  770 + row.classList.remove("flash");
  771 + void row.offsetWidth;
  772 + row.classList.add("flash");
  773 + }
  774 +
  775 + // ---------- three.js funnel ----------
  776 + var stageEl = document.getElementById("funnelStage");
  777 + var canvas = document.getElementById("funnelCanvas");
  778 + var labelLayer = document.getElementById("labelLayer");
  779 +
  780 + var renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true, alpha: true });
  781 + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
  782 +
  783 + var scene = new THREE.Scene();
  784 + var camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
  785 + camera.position.set(0.3, 1.6, 13.5);
  786 +
  787 + var controls = new THREE.OrbitControls(camera, renderer.domElement);
  788 + controls.enableDamping = true;
  789 + controls.dampingFactor = 0.08;
  790 + controls.enablePan = false;
  791 + controls.minDistance = 8;
  792 + controls.maxDistance = 19;
  793 + controls.target.set(0, 0.2, 0);
  794 + controls.update();
  795 +
  796 + scene.add(new THREE.AmbientLight(0xffffff, 0.65));
  797 + var key = new THREE.DirectionalLight(0xffffff, 0.9);
  798 + key.position.set(4, 6, 6);
  799 + scene.add(key);
  800 + var fill = new THREE.DirectionalLight(0xffffff, 0.35);
  801 + fill.position.set(-5, -2, 3);
  802 + scene.add(fill);
  803 +
  804 + var funnelGroup = new THREE.Group();
  805 + scene.add(funnelGroup);
  806 +
  807 + var TOTAL_HEIGHT = 8.6;
  808 + var MIN_R = 0.26;
  809 + var MAX_R = 2.7;
  810 + var SPOUT_H = 0.7;
  811 +
  812 + var segments = [];
  813 +
  814 + function radiusFor(v, maxV){
  815 + if (maxV <= 0) return MIN_R;
  816 + var frac = Math.max(0, v) / maxV;
  817 + return MIN_R + (MAX_R - MIN_R) * Math.sqrt(Math.min(1, frac));
  818 + }
  819 +
  820 + function buildFunnel(){
  821 + while (funnelGroup.children.length) funnelGroup.remove(funnelGroup.children[0]);
  822 + segments = [];
  823 +
  824 + var vals = funnelValues();
  825 + var n = vals.length;
  826 + var maxV = Math.max.apply(null, vals);
  827 + var segH = TOTAL_HEIGHT / n;
  828 + var topY = TOTAL_HEIGHT / 2;
  829 +
  830 + for (var i = 0; i < n; i++){
  831 + var rTop = radiusFor(vals[i], maxV);
  832 + var rBottom = (i < n - 1) ? radiusFor(vals[i + 1], maxV) : rTop * 0.6;
  833 + var y0 = topY - i * segH;
  834 + var yc = y0 - segH / 2;
  835 +
  836 + var geo = new THREE.CylinderGeometry(rTop, rBottom, segH, 48, 1, true);
  837 + var mat = new THREE.MeshStandardMaterial({
  838 + color: new THREE.Color(stageColorHex(i, vals)),
  839 + metalness: 0.12,
  840 + roughness: 0.55,
  841 + side: THREE.DoubleSide
  842 + });
  843 + var mesh = new THREE.Mesh(geo, mat);
  844 + mesh.position.y = yc;
  845 + funnelGroup.add(mesh);
  846 +
  847 + var edgeGeo = new THREE.EdgesGeometry(geo, 25);
  848 + var edgeMat = new THREE.LineBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.18 });
  849 + var edges = new THREE.LineSegments(edgeGeo, edgeMat);
  850 + edges.position.y = yc;
  851 + funnelGroup.add(edges);
  852 +
  853 + var side = i % 2 === 0 ? 1 : -1;
  854 + var anchor = new THREE.Object3D();
  855 + var rMid = (rTop + rBottom) / 2;
  856 + anchor.position.set(side * (rMid + 0.4), yc, 0);
  857 + anchor.userData.side = side;
  858 + funnelGroup.add(anchor);
  859 +
  860 + segments.push({ mesh: mesh, mat: mat, anchor: anchor, side: side });
  861 + }
  862 +
  863 + var lastR = radiusFor(vals[n - 1], maxV) * 0.6;
  864 + var spoutGeo = new THREE.CylinderGeometry(lastR, lastR * 0.85, SPOUT_H, 32, 1, true);
  865 + var spoutMat = new THREE.MeshStandardMaterial({
  866 + color: new THREE.Color(tokenHex("--spout") || "#0d366b"),
  867 + metalness: 0.2,
  868 + roughness: 0.4,
  869 + side: THREE.DoubleSide
  870 + });
  871 + var spout = new THREE.Mesh(spoutGeo, spoutMat);
  872 + spout.position.y = -TOTAL_HEIGHT / 2 - SPOUT_H / 2;
  873 + funnelGroup.add(spout);
  874 +
  875 + buildLabels(vals);
  876 + }
  877 +
  878 + function updateFunnelColors(){
  879 + var vals = funnelValues();
  880 + segments.forEach(function(seg, i){ seg.mat.color.set(stageColorHex(i, vals)); });
  881 + }
  882 +
  883 + // ---------- projected HTML labels ----------
  884 + var labelEls = [];
  885 +
  886 + function buildLabels(vals){
  887 + labelLayer.innerHTML = "";
  888 + labelEls = [];
  889 + TREE.forEach(function(node, i){
  890 + var el = document.createElement("div");
  891 + el.className = "flabel" + (segments[i] && segments[i].side < 0 ? " left" : "");
  892 + var st = i === 0 ? null : stageStatus(i, vals);
  893 + var convHtml = st === null
  894 + ? '<span class="conv" style="color:var(--muted)">起点</span>'
  895 + : '<span class="conv" style="color:var(' + alertVar(st.level) + ')">' + fmtPct(st.rate) + " / 目标 " + fmtPct(st.target) + "</span>";
  896 + var extra = node === payStatusNode ? ' <span style="color:var(--critical)">(失败 ' + nf.format(getValue(payFailNode)) + ")</span>" : "";
  897 + el.innerHTML =
  898 + '<span class="name">' + node.name + "</span>" +
  899 + '<span class="val mono tab">' + nf.format(vals[i]) + extra + "</span>" +
  900 + convHtml;
  901 + labelLayer.appendChild(el);
  902 + labelEls.push(el);
  903 + });
  904 + }
  905 +
  906 + var _v = new THREE.Vector3();
  907 + function updateLabelPositions(){
  908 + if (!segments.length) return;
  909 + var rect = stageEl.getBoundingClientRect();
  910 + var w = rect.width, h = rect.height;
  911 + segments.forEach(function(seg, i){
  912 + var el = labelEls[i];
  913 + if (!el) return;
  914 + seg.anchor.getWorldPosition(_v);
  915 + _v.project(camera);
  916 + var visible = _v.z < 1;
  917 + var x = (_v.x * 0.5 + 0.5) * w;
  918 + var y = (-_v.y * 0.5 + 0.5) * h;
  919 + el.style.left = x + "px";
  920 + el.style.top = y + "px";
  921 + el.style.opacity = visible ? "1" : "0";
  922 + });
  923 + }
  924 +
  925 + function rebuildFunnel(){ buildFunnel(); }
  926 +
  927 + function resizeRenderer(){
  928 + var rect = stageEl.getBoundingClientRect();
  929 + var w = Math.max(1, rect.width);
  930 + var h = Math.max(1, rect.height);
  931 + renderer.setSize(w, h, false);
  932 + camera.aspect = w / h;
  933 + camera.updateProjectionMatrix();
  934 + }
  935 +
  936 + var ro = new ResizeObserver(resizeRenderer);
  937 + ro.observe(stageEl);
  938 +
  939 + var raycaster = new THREE.Raycaster();
  940 + var downPos = null;
  941 + canvas.addEventListener("pointerdown", function(e){ downPos = { x: e.clientX, y: e.clientY }; });
  942 + canvas.addEventListener("pointerup", function(e){
  943 + if (!downPos) return;
  944 + var dx = e.clientX - downPos.x, dy = e.clientY - downPos.y;
  945 + downPos = null;
  946 + if (Math.hypot(dx, dy) > 4) return;
  947 + var rect = canvas.getBoundingClientRect();
  948 + var ndc = new THREE.Vector2(
  949 + ((e.clientX - rect.left) / rect.width) * 2 - 1,
  950 + -((e.clientY - rect.top) / rect.height) * 2 + 1
  951 + );
  952 + raycaster.setFromCamera(ndc, camera);
  953 + var meshes = segments.map(function(s){ return s.mesh; });
  954 + var hits = raycaster.intersectObjects(meshes);
  955 + if (hits.length) highlightStage(meshes.indexOf(hits[0].object));
  956 + });
  957 +
  958 + function tick(){
  959 + controls.update();
  960 + renderer.render(scene, camera);
  961 + updateLabelPositions();
  962 + requestAnimationFrame(tick);
  963 + }
  964 +
  965 + var media = window.matchMedia("(prefers-color-scheme: dark)");
  966 + function onThemeChange(){ updateFunnelColors(); }
  967 + if (media.addEventListener) media.addEventListener("change", onThemeChange);
  968 + new MutationObserver(onThemeChange).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
  969 +
  970 + function renderAll(){
  971 + renderStats();
  972 + renderTree();
  973 + rebuildFunnel();
  974 + }
  975 +
  976 + renderLegend();
  977 + resizeRenderer();
  978 + renderAll();
  979 + requestAnimationFrame(tick);
  980 + window.addEventListener("resize", resizeRenderer);
  981 +})();
  982 +</script>
  983 +</body>
  984 +</html>