zhang hai 2 semanas
pai
achega
4a361f56f3

+ 1 - 0
visualization/.superpowers/brainstorm/.last-port

@@ -0,0 +1 @@
+55457

+ 1 - 0
visualization/.superpowers/brainstorm/.last-token

@@ -0,0 +1 @@
+a3be5e873ced090c1982c9ef16baa747bed1265f6e70aff99737433ae1887e6c

+ 153 - 0
visualization/.superpowers/brainstorm/52647-1784195885/content/deep-tree-global-heat-options.html

@@ -0,0 +1,153 @@
+<style>
+  .intro{max-width:1080px;margin-bottom:22px}.intro p{line-height:1.7}
+  .principle{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px}
+  .principle span{padding:7px 10px;border:1px solid #dbe3ec;border-radius:9px;background:#fff;color:#526174;font-size:11px}
+  .options{display:grid;gap:24px}
+  .option{overflow:hidden;padding:0!important;border:1px solid #dbe3ec!important;border-radius:18px!important;background:#fff!important;box-shadow:0 16px 42px rgba(15,23,42,.07)!important}
+  .option.selected{border-color:#2563eb!important;box-shadow:0 0 0 4px rgba(37,99,235,.13),0 16px 42px rgba(15,23,42,.08)!important}
+  .head{display:flex;align-items:flex-start;gap:14px;padding:18px 20px;border-bottom:1px solid #e2e8f0}
+  .letter{display:grid;place-items:center;flex:0 0 auto;width:36px;height:36px;border-radius:11px;background:#1d4ed8;color:#fff;font-weight:800}
+  .head h3{margin:0 0 5px;font-size:18px}.head p{margin:0;color:#64748b;font-size:12px}
+  .recommended{margin-left:auto;padding:5px 8px;border-radius:6px;background:#dbeafe;color:#1d4ed8;font-size:10px;font-weight:800}
+  .screen{padding:16px;background:#f8fafc}
+  .appbar{display:flex;align-items:center;justify-content:space-between;padding:12px 15px;border-radius:12px 12px 0 0;background:#172554;color:#fff}
+  .appbar b{font-size:13px}.appbar small{color:#bfdbfe;font-size:8px}
+  .legend{display:flex;gap:8px;font-size:8px}.legend span{display:flex;align-items:center;gap:3px}.legend i{display:inline-block;width:8px;height:8px;border-radius:2px}
+  .body{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(280px,.5fr);min-height:420px;border:1px solid #dbe3ec;border-top:0;border-radius:0 0 12px 12px;background:#fff}
+  .viz{padding:14px;min-width:0}.viz-title{display:flex;justify-content:space-between;margin-bottom:10px}.viz-title strong{font-size:11px}.viz-title span{color:#64748b;font-size:8px}
+  .side{padding:14px;border-left:1px solid #e2e8f0;background:#fbfdff}.side h4{margin:0 0 4px;font-size:12px}.side>p{margin:0 0 11px;color:#64748b;font-size:8px;line-height:1.5}
+  .demand{margin-bottom:8px;padding:9px;border:1px solid #e2e8f0;border-radius:9px;background:#fff}.demand strong{display:block;font-size:9px;line-height:1.4}.demand p{margin:4px 0 0;color:#64748b;font-size:7px;line-height:1.55}.demand em{display:inline-block;margin-top:5px;padding:2px 4px;border-radius:4px;background:#fff7ed;color:#9a3412;font-size:6px;font-style:normal}
+  .foot{padding:12px 18px 15px;color:#59677a;font-size:10px;line-height:1.65}
+  .icicle{position:relative;height:330px;overflow:hidden;border:1px solid #dbe3ec;border-radius:10px;background:#f8fafc}
+  .depths{position:absolute;inset:0;display:grid;grid-template-columns:repeat(8,1fr);pointer-events:none}.depths i{border-right:1px solid rgba(148,163,184,.18)}
+  .layer-labels{display:grid;grid-template-columns:repeat(8,1fr);margin-bottom:5px;color:#94a3b8;font-size:7px;text-align:center}
+  .block{position:absolute;border:1px solid rgba(255,255,255,.9);border-radius:3px;overflow:hidden;color:#fff;font-size:7px;font-weight:700;line-height:1.25;padding:3px;box-shadow:inset 0 0 0 1px rgba(15,23,42,.04)}
+  .block.cold{color:#475569}.hot{background:#ef4444}.rise{background:#f59e0b}.steady{background:#eab308}.mild{background:#60a5fa}.cold{background:#dbeafe}.zero{background:#f1f5f9;color:#64748b!important}
+  .focus-ring{outline:3px solid #4338ca;outline-offset:-3px;z-index:4}
+  .callout{position:absolute;right:9px;top:9px;width:170px;padding:8px;border:1px solid #bfdbfe;border-radius:8px;background:rgba(255,255,255,.94);color:#334155;font-size:7px;line-height:1.5;z-index:8}.callout b{display:block;color:#1d4ed8;font-size:8px}
+  .matrix{display:grid;grid-template-columns:110px repeat(8,1fr);gap:3px;height:328px}.mhead,.rhead,.cell{display:flex;align-items:center;justify-content:center;border-radius:4px;font-size:7px}.mhead{color:#94a3b8}.rhead{justify-content:flex-start;padding-left:7px;background:#f8fafc;color:#334155;font-weight:700}.cell{position:relative;overflow:hidden}.cell small{position:relative;color:inherit;font-size:6px}.cell:after{content:"";position:absolute;inset:0;background:linear-gradient(135deg,transparent 40%,rgba(255,255,255,.22))}
+  .radial-wrap{display:flex;align-items:center;justify-content:center;height:330px}.radial{position:relative;width:315px;height:315px;border-radius:50%;background:conic-gradient(#ef4444 0 10deg,#f59e0b 10deg 32deg,#dbeafe 32deg 70deg,#60a5fa 70deg 100deg,#f1f5f9 100deg 135deg,#eab308 135deg 175deg,#dbeafe 175deg 230deg,#f59e0b 230deg 260deg,#60a5fa 260deg 295deg,#f1f5f9 295deg 360deg);box-shadow:inset 0 0 0 28px rgba(255,255,255,.75),inset 0 0 0 29px #fff,inset 0 0 0 62px rgba(255,255,255,.68),inset 0 0 0 63px #fff,inset 0 0 0 96px rgba(255,255,255,.6),inset 0 0 0 97px #fff}
+  .radial:after{content:"全局需求树\\A 2,205 节点";white-space:pre;position:absolute;inset:112px;display:grid;place-items:center;text-align:center;border-radius:50%;background:#1e3a8a;color:#fff;font-size:10px;font-weight:700;line-height:1.5}
+  .rlabel{position:absolute;padding:3px 5px;border-radius:4px;background:#fff;border:1px solid #dbe3ec;color:#334155;font-size:7px;box-shadow:0 3px 8px rgba(15,23,42,.08)}
+  .why{display:grid;grid-template-columns:repeat(3,1fr);gap:7px;margin-top:9px}.why div{padding:8px;border-radius:7px;background:#eff6ff;color:#1e3a8a;font-size:7px;line-height:1.45}
+  @media(max-width:950px){.body{grid-template-columns:1fr}.side{border-left:0;border-top:1px solid #e2e8f0}.matrix{grid-template-columns:80px repeat(8,minmax(35px,1fr));overflow:auto}}
+</style>
+
+<div class="intro">
+  <h2>深层级大树:怎样一眼看到全局热和冷?</h2>
+  <p class="subtitle">这次不再把 2,205 个节点画成 2,205 个盒子。全局态只表达三件事:树的整体结构、热冷分布、热区在全树中的相对位置;节点名称和关系在放大或下钻后出现。</p>
+  <div class="principle"><span>全量节点必须占有位置</span><span>冷区不能隐藏</span><span>面积表达分支规模</span><span>颜色只表达热冷</span><span>需求结果独立输出</span></div>
+</div>
+
+<div class="options">
+  <section class="option" data-choice="icicle" onclick="toggleSelect(this)">
+    <div class="head"><div class="letter">A</div><div><h3>分层冰柱热力图</h3><p>横向是层级,纵向是完整分支;每个节点按子树规模占据面积,颜色显示热冷。</p></div><span class="recommended">最推荐</span></div>
+    <div class="screen">
+      <div class="appbar"><b>平台全局需求地图</b><div class="legend"><span><i class="hot"></i>高热</span><span><i class="rise"></i>升温</span><span><i class="steady"></i>稳定</span><span><i class="mild"></i>弱信号</span><span><i class="cold"></i>冷区</span><span><i class="zero"></i>暂无信号</span></div><small>点击任意区域放大该分支</small></div>
+      <div class="body">
+        <div class="viz">
+          <div class="viz-title"><strong>完整树的热度地形</strong><span>所有节点都有面积,因此冷区不会消失</span></div>
+          <div class="layer-labels"><span>根</span><span>一级</span><span>二级</span><span>三级</span><span>四级</span><span>五级</span><span>六级+</span><span>元素</span></div>
+          <div class="icicle">
+            <div class="depths"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
+            <div class="block steady" style="left:0;top:0;width:12.5%;height:58%">表象</div>
+            <div class="block steady" style="left:0;top:58%;width:12.5%;height:42%">理念</div>
+            <div class="block rise" style="left:12.5%;top:0;width:12.5%;height:23%">行为</div>
+            <div class="block cold" style="left:12.5%;top:23%;width:12.5%;height:7%">内容形态</div>
+            <div class="block rise" style="left:12.5%;top:30%;width:12.5%;height:12%">场景</div>
+            <div class="block zero" style="left:12.5%;top:42%;width:12.5%;height:5%">声音</div>
+            <div class="block mild" style="left:12.5%;top:47%;width:12.5%;height:11%">实体</div>
+            <div class="block rise" style="left:12.5%;top:58%;width:12.5%;height:25%">知识</div>
+            <div class="block cold" style="left:12.5%;top:83%;width:12.5%;height:7%">现象</div>
+            <div class="block steady" style="left:12.5%;top:90%;width:12.5%;height:6%">事件</div>
+            <div class="block cold" style="left:12.5%;top:96%;width:12.5%;height:4%">观念</div>
+
+            <div class="block rise" style="left:25%;top:0;width:12.5%;height:8%">日常互动</div>
+            <div class="block rise" style="left:25%;top:8%;width:12.5%;height:10%">日常活动</div>
+            <div class="block zero" style="left:25%;top:18%;width:12.5%;height:5%">纪念活动</div>
+            <div class="block hot" style="left:25%;top:23%;width:12.5%;height:7%">战斗作战</div>
+            <div class="block rise" style="left:25%;top:30%;width:12.5%;height:7%">空间</div>
+            <div class="block steady" style="left:25%;top:37%;width:12.5%;height:5%">时间</div>
+            <div class="block cold" style="left:25%;top:42%;width:12.5%;height:5%">音乐 / 语音</div>
+            <div class="block rise" style="left:25%;top:47%;width:12.5%;height:8%">人物</div>
+            <div class="block mild" style="left:25%;top:55%;width:12.5%;height:3%">物品</div>
+            <div class="block rise" style="left:25%;top:58%;width:12.5%;height:18%">概念</div>
+            <div class="block rise" style="left:25%;top:76%;width:12.5%;height:7%">方法</div>
+            <div class="block zero" style="left:25%;top:83%;width:12.5%;height:7%">生活状态</div>
+            <div class="block rise" style="left:25%;top:90%;width:12.5%;height:6%">历史事件</div>
+            <div class="block zero" style="left:25%;top:96%;width:12.5%;height:4%">社会 / 个人</div>
+
+            <div class="block hot focus-ring" style="left:37.5%;top:8%;width:12.5%;height:6%">居家生活</div>
+            <div class="block rise" style="left:50%;top:8%;width:12.5%;height:2.8%">家务劳动</div>
+            <div class="block steady" style="left:50%;top:10.8%;width:12.5%;height:1.5%">阅读</div>
+            <div class="block cold" style="left:50%;top:12.3%;width:12.5%;height:1.7%">其他</div>
+            <div class="block rise" style="left:62.5%;top:8%;width:12.5%;height:2.8%">家庭清洁</div>
+            <div class="block zero" style="left:62.5%;top:10.8%;width:12.5%;height:1.5%">书本翻页</div>
+
+            <div class="block rise" style="left:37.5%;top:47%;width:12.5%;height:4%">现实人物</div>
+            <div class="block steady" style="left:50%;top:47%;width:12.5%;height:3%">历史名人</div>
+            <div class="block mild" style="left:62.5%;top:47%;width:12.5%;height:2%">政治领袖</div>
+            <div class="block cold" style="left:75%;top:47%;width:12.5%;height:2%">共和国领导人</div>
+
+            <div class="block steady" style="left:37.5%;top:76%;width:12.5%;height:7%">生活技巧</div>
+            <div class="block zero focus-ring" style="left:50%;top:76%;width:12.5%;height:3%">居家生活</div>
+            <div class="block mild" style="left:62.5%;top:76%;width:12.5%;height:3%">驱蚊技巧</div>
+            <div class="callout"><b>为什么这种图更适合?</b>热点是红橙色“岛屿”,冷区是连续的蓝白背景。你一眼先看到热点位置,再看它位于哪条层级路径;不需要阅读全部节点名。</div>
+          </div>
+          <div class="why"><div>全局态:只看热冷地形与分支规模</div><div>悬停态:显示节点名称、热度构成与 Reason</div><div>下钻态:点击区域,平滑放大该分支并显示子节点</div></div>
+        </div>
+        <aside class="side"><h4>系统提出的需求</h4><p>沿用 05 的卡片感。热力图负责发现,需求卡负责业务执行。</p>
+          <div class="demand"><strong>家庭清洁的分步骤、省力技巧与常见误区</strong><p>来自:居家生活 → 家务劳动 → 家庭清洁。父级热度突出,元素语义具体,后验待验证。</p><em>形成需求</em></div>
+          <div class="demand"><strong>具体居家场景下的驱蚊方法与风险提醒</strong><p>位于冷区,但问题明确、路径完整,可作为探索型需求小规模验证。</p><em>冷区候选</em></div>
+          <div class="demand"><strong>“补充战争内容”不直接生成</strong><p>热度高但语义过宽,继续拆成人物、事件、策略、情感等具体问题。</p><em>需要拆解</em></div>
+        </aside>
+      </div>
+    </div>
+    <div class="foot">优势:最适合“层级深、每层节点多”的真实情况。没有成千上万条交叉线,全部节点仍保留位置;热和冷形成连续地形,视觉上也最接近 05 的克制感。</div>
+  </section>
+
+  <section class="option" data-choice="matrix" onclick="toggleSelect(this)">
+    <div class="head"><div class="letter">B</div><div><h3>分支 × 层级热力矩阵</h3><p>每一行是一个业务分支,每一列是树深度;先看哪里聚集热度,再点格子看节点。</p></div></div>
+    <div class="screen">
+      <div class="appbar"><b>全局分支热力矩阵</b><div class="legend"><span><i class="hot"></i>高热</span><span><i class="rise"></i>升温</span><span><i class="mild"></i>弱信号</span><span><i class="cold"></i>冷区</span></div><small>适合横向比较模块</small></div>
+      <div class="body">
+        <div class="viz"><div class="viz-title"><strong>业务分支与深度分布</strong><span>格子聚合该层节点,数字表示节点数量</span></div>
+          <div class="matrix">
+            <div></div><div class="mhead">根</div><div class="mhead">一级</div><div class="mhead">二级</div><div class="mhead">三级</div><div class="mhead">四级</div><div class="mhead">五级</div><div class="mhead">六级</div><div class="mhead">七级+</div>
+            <div class="rhead">行为 / 日常活动</div><div class="cell steady"><small>1</small></div><div class="cell rise"><small>6</small></div><div class="cell rise"><small>18</small></div><div class="cell hot"><small>42</small></div><div class="cell rise"><small>86</small></div><div class="cell mild"><small>124</small></div><div class="cell cold"><small>91</small></div><div class="cell zero"><small>16</small></div>
+            <div class="rhead">场景 / 空间</div><div class="cell steady"><small>1</small></div><div class="cell rise"><small>4</small></div><div class="cell rise"><small>12</small></div><div class="cell steady"><small>31</small></div><div class="cell mild"><small>58</small></div><div class="cell cold"><small>83</small></div><div class="cell cold"><small>66</small></div><div class="cell zero"><small>9</small></div>
+            <div class="rhead">实体 / 人物</div><div class="cell steady"><small>1</small></div><div class="cell rise"><small>4</small></div><div class="cell rise"><small>15</small></div><div class="cell rise"><small>48</small></div><div class="cell steady"><small>97</small></div><div class="cell mild"><small>137</small></div><div class="cell cold"><small>122</small></div><div class="cell cold"><small>45</small></div>
+            <div class="rhead">知识 / 概念</div><div class="cell steady"><small>1</small></div><div class="cell rise"><small>3</small></div><div class="cell hot"><small>21</small></div><div class="cell rise"><small>76</small></div><div class="cell rise"><small>144</small></div><div class="cell mild"><small>201</small></div><div class="cell cold"><small>168</small></div><div class="cell zero"><small>62</small></div>
+            <div class="rhead">知识 / 方法</div><div class="cell steady"><small>1</small></div><div class="cell rise"><small>3</small></div><div class="cell rise"><small>12</small></div><div class="cell steady"><small>38</small></div><div class="cell cold"><small>70</small></div><div class="cell mild"><small>61</small></div><div class="cell cold"><small>36</small></div><div class="cell zero"><small>8</small></div>
+            <div class="rhead">事件 / 历史</div><div class="cell steady"><small>1</small></div><div class="cell steady"><small>4</small></div><div class="cell rise"><small>13</small></div><div class="cell rise"><small>41</small></div><div class="cell mild"><small>68</small></div><div class="cell cold"><small>72</small></div><div class="cell zero"><small>38</small></div><div class="cell zero"><small>7</small></div>
+            <div class="rhead">声音 / 内容形态</div><div class="cell steady"><small>1</small></div><div class="cell cold"><small>6</small></div><div class="cell zero"><small>17</small></div><div class="cell cold"><small>29</small></div><div class="cell zero"><small>31</small></div><div class="cell zero"><small>18</small></div><div class="cell zero"><small>7</small></div><div class="cell zero"><small>2</small></div>
+          </div>
+        </div>
+        <aside class="side"><h4>当前热度集中区</h4><p>矩阵适合回答“哪个模块、哪一层最热”,但父子关系需要点击格子后再展示。</p>
+          <div class="demand"><strong>三级至五级是当前需求形成密集区</strong><p>行为、人物、概念三个分支存在连续热度,需要优先提取更具体的问题。</p></div>
+          <div class="demand"><strong>声音和内容形态整体偏冷</strong><p>不能直接判为无需求,需要检查是否因为上游元素和表现数据覆盖不足。</p></div>
+        </aside>
+      </div>
+    </div>
+    <div class="foot">优势:最容易比较不同大模块的冷热和热度出现在哪一层;不足:它更像分析图,树的真实父子结构不如 A 清晰。</div>
+  </section>
+
+  <section class="option" data-choice="sunburst" onclick="toggleSelect(this)">
+    <div class="head"><div class="letter">C</div><div><h3>全局旭日环图</h3><p>从圆心向外表达层级,完整分支围成一圈;热区形成醒目的扇形。</p></div></div>
+    <div class="screen">
+      <div class="appbar"><b>全局需求树环图</b><div class="legend"><span><i class="hot"></i>高热</span><span><i class="rise"></i>升温</span><span><i class="mild"></i>弱信号</span><span><i class="cold"></i>冷区</span></div><small>点击扇区进入分支</small></div>
+      <div class="body">
+        <div class="viz"><div class="viz-title"><strong>完整树环形概览</strong><span>越向外层级越深,扇区角度代表子树规模</span></div>
+          <div class="radial-wrap"><div class="radial">
+            <span class="rlabel" style="left:-30px;top:35px">行为 · 居家生活</span><span class="rlabel" style="right:-35px;top:65px">知识 · 概念</span><span class="rlabel" style="right:-20px;bottom:50px">事件 · 历史</span><span class="rlabel" style="left:-22px;bottom:55px">实体 · 人物</span>
+          </div></div>
+        </div>
+        <aside class="side"><h4>热区需求摘要</h4><p>视觉整体性强,但深层标签和精确比较较困难。</p>
+          <div class="demand"><strong>居家生活分支出现连续高热扇区</strong><p>进入后再展开家务劳动、个人护理、阅读等具体方向。</p></div>
+          <div class="demand"><strong>知识分支规模最大但冷热交错</strong><p>需要下钻后才能判断是概念热还是具体方法热。</p></div>
+        </aside>
+      </div>
+    </div>
+    <div class="foot">优势:整体像一张完整地图,适合展示;不足:中文标签多、层级深时阅读成本高,而且不符合此前确定的从左到右阅读习惯。</div>
+  </section>
+</div>

+ 223 - 0
visualization/.superpowers/brainstorm/52647-1784195885/content/global-demand-demo-a-v2.html

@@ -0,0 +1,223 @@
+<style>
+  .note { max-width:1050px; color:#68736c; line-height:1.65; }
+  .app {
+    overflow:hidden; margin-top:18px; border:1px solid #d5dcd7; border-radius:18px;
+    background:white; box-shadow:0 18px 48px rgba(25,51,42,.09);
+  }
+  .top {
+    display:flex; align-items:center; justify-content:space-between; gap:20px;
+    padding:17px 20px; color:#fff; background:#143b31;
+  }
+  .top h3 { margin:0 0 3px; font-size:17px; }
+  .top p { margin:0; color:#b9ccc5; font-size:10px; }
+  .legend { display:flex; gap:9px; align-items:center; font-size:9px; white-space:nowrap; }
+  .legend span { display:flex; align-items:center; gap:4px; }
+  .legend i { width:9px; height:9px; border-radius:3px; display:inline-block; }
+  .summary {
+    display:grid; grid-template-columns:repeat(4,1fr); border-bottom:1px solid #dde3df;
+    background:#f7f9f7;
+  }
+  .summary div { padding:11px 15px; border-right:1px solid #e0e5e1; }
+  .summary div:last-child { border:0; }
+  .summary b { display:block; color:#163e34; font-size:12px; }
+  .summary small { color:#758079; font-size:8px; }
+  .workspace { display:grid; grid-template-columns:minmax(620px,1.55fr) minmax(330px,.65fr); min-height:650px; }
+  .graph { position:relative; padding:14px 16px 22px; overflow:hidden; background:#fbfcfa; }
+  .graph:before {
+    content:""; position:absolute; inset:0;
+    background:linear-gradient(90deg,rgba(35,58,50,.045) 1px,transparent 1px);
+    background-size:12.5% 100%; pointer-events:none;
+  }
+  .graph-head { position:relative; z-index:1; display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:10px; }
+  .graph-head strong { font-size:11px; }
+  .graph-head span { color:#758079; font-size:8px; }
+  .axis { position:relative; display:grid; grid-template-columns:repeat(8,1fr); margin:0 0 9px 12px; color:#929a95; font-size:7px; text-align:center; }
+  .tree { position:relative; z-index:1; display:grid; gap:7px; }
+  .lane {
+    display:grid; grid-template-columns:66px 74px 82px 92px 96px 100px minmax(90px,1fr);
+    align-items:center; gap:8px; min-height:42px; padding:4px 6px;
+    border:1px solid #edf0ed; border-radius:8px; background:rgba(255,255,255,.72);
+  }
+  .lane.focus { border-color:#e6b6aa; background:#fff8f5; }
+  .lane.coldzone { background:#f6f8fa; }
+  .node {
+    position:relative; min-width:0; padding:7px 5px; border-radius:6px; color:white;
+    font-size:8px; font-weight:700; text-align:center; line-height:1.25;
+    box-shadow:0 1px 0 rgba(0,0,0,.05);
+  }
+  .node:not(:first-child):before {
+    content:""; position:absolute; left:-9px; top:50%; width:9px; border-top:1px solid #aeb9b3;
+  }
+  .node.blank { visibility:hidden; }
+  .high { background:#d94b3e; }
+  .rise { background:#eb8736; }
+  .steady { background:#d4a63d; }
+  .weak { background:#86a39a; }
+  .cold { background:#d6dde2; color:#475a63; }
+  .zero { background:#edf1f3; color:#76858c; border:1px solid #d5dde1; box-shadow:none; }
+  .chosen { outline:3px solid rgba(19,114,92,.22); box-shadow:inset 0 0 0 1px #fff; }
+  .children { display:flex; flex-wrap:wrap; gap:4px; }
+  .children .node { flex:0 1 auto; min-width:54px; padding:6px 7px; }
+  .lane-label { position:absolute; right:8px; margin-top:-35px; color:#7c8780; font-size:7px; }
+  .comparison {
+    position:relative; z-index:1; display:grid; grid-template-columns:repeat(4,1fr); gap:6px; margin-top:12px;
+  }
+  .case { padding:9px; border:1px solid #dce2de; border-radius:7px; background:white; }
+  .case b { display:block; margin-bottom:4px; font-size:8px; }
+  .case p { margin:0; color:#737e77; font-size:7px; line-height:1.5; }
+  .case.yes { border-top:3px solid #148064; }
+  .case.watch { border-top:3px solid #bb8d3d; }
+  .case.no { border-top:3px solid #8e9993; }
+  .output { padding:15px; border-left:1px solid #dde3df; background:#f7f9f7; }
+  .output h4 { margin:0 0 4px; font-size:13px; }
+  .output-intro { margin:0 0 11px; color:#77817b; font-size:8px; line-height:1.5; }
+  .filters { display:flex; gap:5px; margin-bottom:10px; }
+  .filters span { padding:4px 6px; border:1px solid #d6ddd8; border-radius:5px; background:#fff; color:#647069; font-size:7px; }
+  .filters .active { background:#173e34; color:white; border-color:#173e34; }
+  .demand { margin-bottom:9px; padding:10px; border:1px solid #d9e0db; border-radius:8px; background:white; }
+  .demand-top { display:flex; justify-content:space-between; gap:7px; }
+  .demand strong { font-size:9px; line-height:1.45; }
+  .tag { flex:0 0 auto; height:18px; padding:3px 5px; border-radius:4px; color:white; font-size:7px; font-weight:800; }
+  .tag.yes { background:#148064; }
+  .tag.watch { background:#b78435; }
+  .tag.no { background:#87938c; }
+  .reason { margin-top:6px; color:#667169; font-size:7px; line-height:1.55; }
+  .facts { display:flex; flex-wrap:wrap; gap:3px; margin-top:6px; }
+  .facts span { padding:2px 4px; border-radius:3px; background:#edf2ef; color:#5d6c64; font-size:6px; }
+  .facts .coldfact { background:#edf1f3; color:#667982; }
+  .model { margin-top:6px; padding-top:6px; border-top:1px dashed #d8ded9; color:#7d6b4f; font-size:6px; line-height:1.5; }
+  .footer-note { padding:12px 18px; border-top:1px solid #dde3df; color:#657069; font-size:9px; line-height:1.6; }
+  @media(max-width:1000px){.workspace{grid-template-columns:1fr}.output{border-left:0;border-top:1px solid #dde3df}.lane{grid-template-columns:repeat(7,minmax(70px,1fr));overflow:auto}.comparison{grid-template-columns:1fr 1fr}}
+</style>
+
+<h2>A 方案 V2:冷区完整保留的全局热力树</h2>
+<p class="note">这版不再只挑热点。整棵树的高热、升温、稳定、弱信号和零信号区域都同时存在,形成真正的比较基线。热度只是证据之一;右侧最终是否生成需求,还要结合节点具体程度、元素、父子关系、跨树关系和真实反馈。</p>
+
+<section class="app" data-choice="a-v2" onclick="toggleSelect(this)">
+  <div class="top">
+    <div><h3>平台全局需求热力树</h3><p>从全量分类和证据中持续形成平台需求,不需要先点节点才能得到结论</p></div>
+    <div class="legend">
+      <span><i style="background:#d94b3e"></i>高热</span>
+      <span><i style="background:#eb8736"></i>升温</span>
+      <span><i style="background:#d4a63d"></i>稳定</span>
+      <span><i style="background:#86a39a"></i>弱信号</span>
+      <span><i style="background:#d6dde2"></i>冷区</span>
+      <span><i style="background:#edf1f3;border:1px solid #d5dde1"></i>暂无信号</span>
+    </div>
+  </div>
+  <div class="summary">
+    <div><b>全树同时可见</b><small>冷区不隐藏,提供比较基线</small></div>
+    <div><b>颜色只表达热度</b><small>颜色不直接等于需求结论</small></div>
+    <div><b>判断覆盖全部节点</b><small>热区和冷区都进入需求判断</small></div>
+    <div><b>结果自带 Reason</b><small>区分数据事实与模型解释</small></div>
+  </div>
+  <div class="workspace">
+    <div class="graph">
+      <div class="graph-head"><strong>完整树骨架 · 从左到右</strong><span>下图以真实分类名称演示;高低颜色为界面方案示意</span></div>
+      <div class="axis"><span>根</span><span>一级</span><span>二级</span><span>三级</span><span>四级</span><span>五级</span><span>更深层</span><span></span></div>
+      <div class="tree">
+        <div class="lane focus">
+          <div class="node steady">表象</div><div class="node rise">行为</div><div class="node rise">日常活动</div>
+          <div class="node high chosen">居家生活</div><div class="node rise">家务劳动</div><div class="node weak">家庭清洁</div>
+          <div class="children"><div class="node rise">个人护理</div><div class="node steady">阅读</div><div class="node cold">物品处置</div></div>
+        </div>
+        <div class="lane">
+          <div class="node steady">表象</div><div class="node rise">行为</div><div class="node weak">日常互动</div>
+          <div class="node cold">家庭互动</div><div class="node zero">—</div><div class="node blank">—</div>
+          <div class="children"><div class="node cold">社交互动</div><div class="node weak">情感互动</div></div>
+        </div>
+        <div class="lane">
+          <div class="node steady">表象</div><div class="node rise">行为</div><div class="node weak">纪念活动</div>
+          <div class="node cold">公共纪念</div><div class="node zero">—</div><div class="node blank">—</div>
+          <div class="children"><div class="node zero">暂无元素</div></div>
+        </div>
+        <div class="lane">
+          <div class="node steady">表象</div><div class="node rise">行为</div><div class="node high">战斗作战</div>
+          <div class="node weak">战争</div><div class="node blank">—</div><div class="node blank">—</div>
+          <div class="children"><div class="node cold">语义过宽</div></div>
+        </div>
+        <div class="lane">
+          <div class="node steady">表象</div><div class="node rise">场景</div><div class="node rise">空间</div>
+          <div class="node steady">居住空间</div><div class="node weak">室内</div><div class="node cold">局部空间</div>
+          <div class="children"><div class="node cold">暂无后验</div></div>
+        </div>
+        <div class="lane">
+          <div class="node steady">表象</div><div class="node rise">实体</div><div class="node rise">人物</div>
+          <div class="node steady">现实人物</div><div class="node rise">历史名人</div><div class="node steady">政治领袖</div>
+          <div class="children"><div class="node weak">共和国领导人</div><div class="node cold">其他人物</div></div>
+        </div>
+        <div class="lane coldzone">
+          <div class="node steady">表象</div><div class="node cold">内容形态</div><div class="node cold">影像</div>
+          <div class="node zero">宣传推广</div><div class="node zero">出版物</div><div class="node blank">—</div>
+          <div class="children"><div class="node zero">暂无有效信号</div></div>
+        </div>
+        <div class="lane coldzone">
+          <div class="node steady">表象</div><div class="node cold">声音</div><div class="node cold">音乐</div>
+          <div class="node cold">语音</div><div class="node zero">环境音效</div><div class="node blank">—</div>
+          <div class="children"><div class="node cold">低密度区域</div></div>
+        </div>
+        <div class="lane">
+          <div class="node steady">理念</div><div class="node rise">知识</div><div class="node rise">方法</div>
+          <div class="node steady">生活技巧</div><div class="node zero chosen">居家生活</div><div class="node weak">驱蚊技巧</div>
+          <div class="children"><div class="node cold">热度缺失但语义具体</div></div>
+        </div>
+        <div class="lane coldzone">
+          <div class="node steady">理念</div><div class="node cold">现象</div><div class="node zero">语言文化</div>
+          <div class="node cold">生活状态</div><div class="node zero">社会趋势</div><div class="node blank">—</div>
+          <div class="children"><div class="node zero">暂无具体元素</div></div>
+        </div>
+        <div class="lane">
+          <div class="node steady">理念</div><div class="node steady">事件</div><div class="node rise">历史事件</div>
+          <div class="node steady">战争历史</div><div class="node weak">抗日战争</div><div class="node weak">解放战争</div>
+          <div class="children"><div class="node cold">人物关联</div><div class="node cold">事件关联</div></div>
+        </div>
+        <div class="lane coldzone">
+          <div class="node steady">理念</div><div class="node weak">观念</div><div class="node cold">社会</div>
+          <div class="node cold">个人</div><div class="node zero">暂无信号</div><div class="node blank">—</div>
+          <div class="children"><div class="node zero">证据不足</div></div>
+        </div>
+      </div>
+
+      <div class="comparison">
+        <div class="case yes"><b>热区 → 形成需求</b><p>家庭清洁:父级热、元素具体,虽无后验仍可作为待验证需求。</p></div>
+        <div class="case no"><b>热区 → 不直接形成</b><p>战争:热度强,但语义过宽,不能直接生成有用的平台需求。</p></div>
+        <div class="case yes"><b>冷区 → 形成候选</b><p>驱蚊技巧:热度缺失,但问题具体,可进入小规模验证。</p></div>
+        <div class="case no"><b>冷区 → 不形成</b><p>既无热度,也无具体元素和关系证据,暂不生成需求。</p></div>
+      </div>
+    </div>
+
+    <aside class="output">
+      <h4>系统形成的需求</h4>
+      <p class="output-intro">这里是核心业务输出,不要求用户逐节点点击。点击树只用于追溯和解释。</p>
+      <div class="filters"><span class="active">全部结论</span><span>生成需求</span><span>验证候选</span><span>未形成</span></div>
+
+      <div class="demand">
+        <div class="demand-top"><strong>补充家庭清洁的分步骤、省力技巧和常见误区内容</strong><span class="tag yes">生成需求</span></div>
+        <div class="reason"><b>Reason:</b>“居家生活”有明显外部信号;“家务劳动”存在同期信号;元素“家庭清洁”能映射到明确生活问题。没有真实 ROV / VOV 代表尚未验证,不代表需求不存在。</div>
+        <div class="facts"><span>父级外部热度</span><span>子级同期信号</span><span>元素:家庭清洁</span><span class="coldfact">后验缺失</span></div>
+        <div class="model">模型解释:把缺失后验识别为“验证空白”,结合具体语义后形成供给需求。</div>
+      </div>
+
+      <div class="demand">
+        <div class="demand-top"><strong>制作面向具体居家场景的驱蚊方法、适用条件与风险提醒</strong><span class="tag watch">验证候选</span></div>
+        <div class="reason"><b>Reason:</b>该节点所有热度与后验均为空,但上游元素“驱蚊技巧”问题语义明确,且位于“方法 → 生活技巧 → 居家生活”的完整路径上。可以小规模验证,但证据不足以直接判为高优先级。</div>
+        <div class="facts"><span class="coldfact">冷区</span><span>完整语义路径</span><span>元素:驱蚊技巧</span><span class="coldfact">所有表现待验证</span></div>
+        <div class="model">模型解释:冷区不是删除区;具体可行动的问题可以成为探索型需求。</div>
+      </div>
+
+      <div class="demand">
+        <div class="demand-top"><strong>泛化提出“补充战争内容”</strong><span class="tag no">不形成</span></div>
+        <div class="reason"><b>Reason:</b>“战斗作战”外部信号很强,也存在真实反馈,但元素只有“战争”,范围过宽,无法确定用户需要人物、事件、战术、情感还是作品内容。</div>
+        <div class="facts"><span>外部热度高</span><span>存在真实反馈</span><span class="coldfact">元素语义过宽</span></div>
+        <div class="model">模型解释:高热度只能说明值得继续拆解,不能直接生成模糊需求。</div>
+      </div>
+
+      <div class="demand">
+        <div class="demand-top"><strong>围绕“书本翻页”批量生成阅读需求</strong><span class="tag no">暂不形成</span></div>
+        <div class="reason"><b>Reason:</b>只有较弱的去年同期信号;“书本翻页”描述内容动作,不足以表达用户问题。需补充明确语言、视频表现或关联元素。</div>
+        <div class="facts"><span class="coldfact">弱同期信号</span><span class="coldfact">缺少用户问题</span><span>元素:书本翻页</span></div>
+      </div>
+    </aside>
+  </div>
+  <div class="footer-note"><strong>这一版的原则:</strong>整棵树提供全局对比,颜色只说明信号状态;需求是否成立由多维证据和语义共同判断。Reason 中“事实”来自上游数据,“模型解释”是系统新增的判断说明,二者必须分开呈现。</div>
+</section>

+ 197 - 0
visualization/.superpowers/brainstorm/52647-1784195885/content/global-demand-demo-options.html

@@ -0,0 +1,197 @@
+<style>
+  .intro { max-width: 980px; margin-bottom: 20px; }
+  .intro strong { color: #123f35; }
+  .demo-grid { display: grid; gap: 20px; }
+  .demo-card {
+    overflow: hidden;
+    border: 1px solid #d8ddd8;
+    border-radius: 18px;
+    background: #fff;
+    box-shadow: 0 14px 38px rgba(32,55,47,.07);
+    cursor: pointer;
+  }
+  .demo-card.selected { border: 2px solid #14745f; box-shadow: 0 0 0 4px rgba(20,116,95,.12); }
+  .demo-title { display:flex; align-items:flex-start; gap:14px; padding:18px 20px 14px; border-bottom:1px solid #e5e8e5; }
+  .demo-letter { display:grid; place-items:center; width:34px; height:34px; border-radius:50%; background:#173f36; color:white; font-weight:800; }
+  .demo-title h3 { margin:0 0 4px; font-size:17px; }
+  .demo-title p { margin:0; color:#6d766f; font-size:12px; }
+  .recommended { margin-left:auto; padding:5px 8px; border-radius:6px; background:#e4f2ec; color:#17604f; font-size:10px; font-weight:750; }
+  .screen { padding:14px; background:#f4f5f1; }
+  .appbar { display:flex; align-items:center; justify-content:space-between; padding:10px 13px; background:#17332c; color:white; border-radius:10px 10px 0 0; }
+  .appbar b { font-size:12px; }
+  .appbar span { color:#b8ccc4; font-size:8px; }
+  .heat-legend { display:flex; gap:8px; align-items:center; font-size:8px; color:#657069; }
+  .heat-legend i { display:inline-block; width:7px; height:7px; border-radius:50%; margin-right:2px; }
+  .workspace { display:grid; grid-template-columns:minmax(0,1.45fr) minmax(260px,.55fr); min-height:330px; background:white; border:1px solid #dde2de; border-top:0; border-radius:0 0 10px 10px; }
+  .map { position:relative; padding:15px; overflow:hidden; background:linear-gradient(90deg,rgba(34,57,49,.035) 1px,transparent 1px),#fbfcfa; background-size:80px 100%; }
+  .map-head { display:flex; justify-content:space-between; margin-bottom:12px; font-size:9px; }
+  .map-head strong { font-size:11px; }
+  .branch { display:grid; grid-template-columns:90px 1fr; align-items:center; gap:10px; margin:7px 0; }
+  .branch-name { padding:7px 8px; border-radius:6px; font-size:9px; font-weight:700; color:white; }
+  .children { display:flex; align-items:center; gap:5px; }
+  .node { min-width:55px; padding:6px 7px; border-radius:5px; color:#fff; font-size:8px; text-align:center; position:relative; }
+  .node:before { content:""; position:absolute; left:-6px; top:50%; width:6px; border-top:1px solid #aeb8b2; }
+  .hot { background:#d94f3f; }
+  .warm { background:#e58b3c; }
+  .mid { background:#d5a943; }
+  .cool { background:#7c9a91; }
+  .cold { background:#cbd2ce; color:#53615b; }
+  .selected-node { outline:3px solid rgba(20,116,95,.22); box-shadow:0 0 0 1px #fff inset; }
+  .insight { margin-top:13px; padding:10px 11px; border-left:3px solid #d94f3f; background:#fff3ef; font-size:9px; line-height:1.6; }
+  .output { padding:13px; border-left:1px solid #e0e4e1; background:#f9faf8; }
+  .output h4 { margin:0 0 3px; font-size:11px; }
+  .output > p { margin:0 0 10px; color:#748078; font-size:8px; }
+  .demand { margin-bottom:8px; padding:9px; border:1px solid #dce2de; border-radius:7px; background:#fff; }
+  .demand-top { display:flex; justify-content:space-between; gap:8px; }
+  .demand strong { font-size:9px; line-height:1.4; }
+  .tag { flex:0 0 auto; height:17px; padding:2px 5px; border-radius:4px; color:#fff; font-size:7px; font-weight:750; }
+  .tag.yes { background:#168065; }
+  .tag.watch { background:#b8893d; }
+  .tag.no { background:#929c96; }
+  .reason { margin-top:5px; color:#6d776f; font-size:7px; line-height:1.5; }
+  .evidence { display:flex; flex-wrap:wrap; gap:3px; margin-top:6px; }
+  .evidence span { padding:2px 4px; border-radius:3px; background:#edf1ee; color:#5c6861; font-size:6px; }
+  .evidence .missing { border:1px dashed #c5cbc7; background:#fafafa; color:#929994; }
+  .matrix { display:grid; grid-template-columns:repeat(4,1fr); gap:5px; margin-top:10px; }
+  .tile { min-height:65px; padding:8px; border-radius:6px; color:white; }
+  .tile b { display:block; font-size:9px; }
+  .tile small { display:block; margin-top:3px; font-size:6px; opacity:.86; }
+  .pipeline { display:flex; align-items:stretch; gap:6px; margin-top:14px; }
+  .stage { flex:1; padding:8px; border:1px solid #dce1dd; border-radius:6px; background:#fff; }
+  .stage b { display:block; font-size:8px; }
+  .stage small { color:#768079; font-size:6px; line-height:1.4; }
+  .arrow { align-self:center; color:#98a19b; }
+  .river { position:relative; height:245px; margin-top:10px; }
+  .river-line { position:absolute; left:35px; right:35px; height:1px; background:#d9dfdb; transform-origin:left; }
+  .river-node { position:absolute; display:grid; place-items:center; border-radius:50%; color:white; font-size:7px; text-align:center; box-shadow:0 0 0 5px rgba(255,255,255,.75); }
+  .demo-foot { padding:11px 18px 14px; color:#626d66; font-size:10px; line-height:1.55; }
+  .logic-strip { display:grid; grid-template-columns:1fr auto 1fr auto 1fr; align-items:center; gap:8px; margin-top:18px; padding:14px; border:1px solid #dbe2dd; border-radius:12px; background:#f8faf8; }
+  .logic-strip div { padding:9px; border-radius:7px; background:white; font-size:9px; }
+  .logic-strip strong { display:block; margin-bottom:3px; }
+  .logic-strip span { color:#737d76; font-size:8px; line-height:1.45; }
+  @media(max-width:900px){ .workspace{grid-template-columns:1fr}.output{border-left:0;border-top:1px solid #e0e4e1}.matrix{grid-template-columns:1fr 1fr} }
+</style>
+
+<div class="intro">
+  <h2>三种“全局判断并产出需求”的 Demo</h2>
+  <p class="subtitle">它们都只有一套判断视角,不再切换“先验/后验/机会”等模式。颜色回答哪里热,需求卡回答系统最终提出什么,reason 回答为什么成立或为什么暂不成立。</p>
+  <p style="margin:-8px 0 14px;color:#9a6b32;font-size:11px;">说明:节点名称和“居家生活 / 家务劳动 / 阅读”的证据来自现有上游数据;界面中的需求总数、模块需求条数仅用于比较版式,不代表已经完成的真实计算结果。</p>
+  <div class="logic-strip">
+    <div><strong>上游事实</strong><span>树位置、四项热度、真实 ROV / VOV、元素与原始 reason</span></div>
+    <b>→</b>
+    <div><strong>语义判断</strong><span>维度缺失不等于零;结合父子热度、语义具体度、证据一致性判断</span></div>
+    <b>→</b>
+    <div><strong>最终输出</strong><span>生成需求 / 继续观察 / 暂不生成,并输出可读 reason</span></div>
+  </div>
+</div>
+
+<div class="demo-grid">
+  <section class="demo-card" data-choice="a" onclick="toggleSelect(this)">
+    <div class="demo-title">
+      <div class="demo-letter">A</div>
+      <div><h3>全局热力树 + 需求输出栏</h3><p>保留树的层级关系,但只展示当前有判断价值的骨架;右边持续输出大量需求。</p></div>
+      <span class="recommended">推荐</span>
+    </div>
+    <div class="screen">
+      <div class="appbar"><b>全局需求热力树</b><div class="heat-legend"><span><i style="background:#d94f3f"></i>高热</span><span><i style="background:#e58b3c"></i>升温</span><span><i style="background:#d5a943"></i>稳定</span><span><i style="background:#cbd2ce"></i>弱信号</span></div><span>数据日期 2026-07-14</span></div>
+      <div class="workspace">
+        <div class="map">
+          <div class="map-head"><strong>全局模块热度</strong><span>节点颜色由多维证据综合判断,不显示技术分数</span></div>
+          <div class="branch"><div class="branch-name warm">行为</div><div class="children"><div class="node mid">日常活动</div><div class="node hot selected-node">居家生活</div><div class="node warm">个人护理</div><div class="node warm">家务劳动</div><div class="node mid">阅读</div><div class="node cool">物品处置</div></div></div>
+          <div class="branch"><div class="branch-name mid">人物</div><div class="children"><div class="node mid">现实人物</div><div class="node warm">历史名人</div><div class="node warm">政治领袖</div><div class="node mid">共和国领导人</div></div></div>
+          <div class="branch"><div class="branch-name cool">知识</div><div class="children"><div class="node cool">方法</div><div class="node mid">生活技巧</div><div class="node cold">居家生活</div></div></div>
+          <div class="branch"><div class="branch-name warm">事件</div><div class="children"><div class="node warm">历史事件</div><div class="node mid">抗日战争</div><div class="node mid">解放战争</div></div></div>
+          <div class="insight"><strong>全局判断:</strong>“行为 → 日常活动 → 居家生活”是当前值得扩充需求的高热区域。它的外部热度明显,但真实 ROV / VOV 暂无数据,因此结论是“高热待验证”,不是“低需求”。</div>
+        </div>
+        <aside class="output">
+          <h4>系统提出的需求</h4>
+          <p>当前全局共形成 386 条,按价值与证据完整度排序</p>
+          <div class="demand">
+            <div class="demand-top"><strong>补充家庭清洁的分步骤、省力技巧与常见误区内容</strong><span class="tag yes">生成需求</span></div>
+            <div class="reason">Reason:父级“居家生活”外部热度高;子类“家务劳动”存在同期信号;上游元素“家庭清洁”语义具体。虽无真实 ROV / VOV,证据仍足以形成待验证需求。</div>
+            <div class="evidence"><span>父级高热</span><span>同期信号</span><span>元素:家庭清洁</span><span class="missing">后验待验证</span></div>
+          </div>
+          <div class="demand">
+            <div class="demand-top"><strong>围绕居家阅读场景补充内容</strong><span class="tag watch">继续观察</span></div>
+            <div class="reason">Reason:只有较弱的同期信号;元素“书本翻页”描述的是动作,不足以说明用户想解决的问题。需要更多上游语言或内容表现后再生成明确需求。</div>
+            <div class="evidence"><span>单一弱信号</span><span>元素语义偏浅</span><span class="missing">缺少问题表达</span></div>
+          </div>
+          <div class="demand">
+            <div class="demand-top"><strong>泛化生成“所有居家用品介绍”</strong><span class="tag no">不生成</span></div>
+            <div class="reason">Reason:范围过宽,无法映射到明确用户问题;即便父节点热度高,也不能直接把所有下级主题都判为需求。</div>
+          </div>
+        </aside>
+      </div>
+    </div>
+    <div class="demo-foot">优点:最接近你的业务目标——全局图负责发现热区,右侧直接成为“需求生产结果”。逐层下钻只用于解释需求,不是页面主任务。</div>
+  </section>
+
+  <section class="demo-card" data-choice="b" onclick="toggleSelect(this)">
+    <div class="demo-title">
+      <div class="demo-letter">B</div>
+      <div><h3>热度地图 + 需求生产流水线</h3><p>弱化树线条,用模块面积和颜色展示全局;下方展示需求如何被判断出来。</p></div>
+    </div>
+    <div class="screen">
+      <div class="appbar"><b>平台需求全景</b><span>热度区域 → 判断证据 → 平台需求</span></div>
+      <div class="workspace">
+        <div class="map">
+          <div class="map-head"><strong>全局需求地形</strong><span>面积代表该模块下可提取的信息规模</span></div>
+          <div class="matrix">
+            <div class="tile hot" style="grid-column:span 2"><b>居家生活</b><small>高热 · 8 个直接子类 · 外部信号强</small></div>
+            <div class="tile warm"><b>历史人物</b><small>升温 · 多节点交织</small></div>
+            <div class="tile mid"><b>战争历史</b><small>稳定 · 有事件关系</small></div>
+            <div class="tile mid"><b>个人护理</b><small>稳定热度</small></div>
+            <div class="tile warm"><b>家务劳动</b><small>可形成具体需求</small></div>
+            <div class="tile cool"><b>阅读</b><small>证据尚浅</small></div>
+            <div class="tile cold"><b>物品处置</b><small>弱信号</small></div>
+          </div>
+          <div class="pipeline">
+            <div class="stage"><b>发现热区</b><small>居家生活外部信号突出</small></div><span class="arrow">→</span>
+            <div class="stage"><b>寻找具体语义</b><small>家务劳动 / 家庭清洁</small></div><span class="arrow">→</span>
+            <div class="stage"><b>判断证据缺口</b><small>无后验,但不是零需求</small></div><span class="arrow">→</span>
+            <div class="stage"><b>生成需求</b><small>家庭清洁分步骤内容</small></div>
+          </div>
+        </div>
+        <aside class="output">
+          <h4>需求生产结果</h4><p>按模块汇总,适合快速浏览数百条需求</p>
+          <div class="demand"><div class="demand-top"><strong>居家生活 · 42 条需求</strong><span class="tag yes">重点扩充</span></div><div class="reason">家务劳动 12 · 个人护理 9 · 家庭互动 8 · 其他 13</div></div>
+          <div class="demand"><div class="demand-top"><strong>历史人物 · 31 条需求</strong><span class="tag yes">持续供给</span></div><div class="reason">人物、事件、情感、作品多个挂靠点共同支持。</div></div>
+          <div class="demand"><div class="demand-top"><strong>阅读 · 3 条候选</strong><span class="tag watch">需补证据</span></div><div class="reason">热度信号与用户问题表达都不足,暂不批量生成。</div></div>
+        </aside>
+      </div>
+    </div>
+    <div class="demo-foot">优点:最干净、最有“全局地形感”,适合管理层看重点方向;缺点是树的父子挂靠关系不如方案 A 直观。</div>
+  </section>
+
+  <section class="demo-card" data-choice="c" onclick="toggleSelect(this)">
+    <div class="demo-title">
+      <div class="demo-letter">C</div>
+      <div><h3>需求河流图</h3><p>从左侧全局分类流向右侧需求簇,线条表达哪些证据共同形成一条需求。</p></div>
+    </div>
+    <div class="screen">
+      <div class="appbar"><b>需求形成关系图</b><span>分类与信号在左 · 最终需求在右</span></div>
+      <div class="workspace">
+        <div class="map">
+          <div class="map-head"><strong>全局证据 → 需求</strong><span>线宽表示支持该需求的证据数量</span></div>
+          <div class="river">
+            <div class="river-line" style="top:47px;left:95px;right:190px;transform:rotate(4deg)"></div>
+            <div class="river-line" style="top:105px;left:95px;right:190px;transform:rotate(-8deg)"></div>
+            <div class="river-line" style="top:160px;left:95px;right:190px;transform:rotate(-17deg)"></div>
+            <div class="river-node warm" style="left:15px;top:24px;width:64px;height:64px">居家生活<br>高热</div>
+            <div class="river-node mid" style="left:130px;top:62px;width:55px;height:55px">家务劳动</div>
+            <div class="river-node cool" style="left:130px;top:145px;width:48px;height:48px">阅读</div>
+            <div class="river-node warm" style="left:245px;top:75px;width:52px;height:52px">家庭清洁</div>
+            <div class="river-node hot" style="right:15px;top:35px;width:105px;height:105px">家庭清洁<br>步骤 / 技巧 / 误区<br>生成需求</div>
+            <div class="river-node cold" style="right:35px;top:175px;width:60px;height:60px">阅读场景<br>继续观察</div>
+          </div>
+        </div>
+        <aside class="output">
+          <h4>为什么形成这条需求</h4><p>每条线都是一个可追溯证据</p>
+          <div class="demand"><div class="demand-top"><strong>家庭清洁分步骤内容</strong><span class="tag yes">成立</span></div><div class="reason">① 父模块高热<br>② 子分类有同期信号<br>③ 元素语义具体<br>④ 后验缺失,被识别为验证缺口而非负反馈</div></div>
+          <div class="demand"><div class="demand-top"><strong>居家阅读内容</strong><span class="tag watch">不成立</span></div><div class="reason">同样缺少后验,但只有单一弱信号,且“书本翻页”不能表达明确问题,所以不生成需求。</div></div>
+        </aside>
+      </div>
+    </div>
+    <div class="demo-foot">优点:最能解释“相同维度缺失,为什么一个成为需求、另一个不成为需求”;缺点是面对数百条需求时容易再次出现线条拥挤。</div>
+  </section>
+</div>

+ 138 - 0
visualization/.superpowers/brainstorm/52647-1784195885/content/icicle-demand-final-demo.html

@@ -0,0 +1,138 @@
+<style>
+  .brief{max-width:1080px;margin-bottom:18px}.brief p{line-height:1.7}
+  .demo{overflow:hidden;border:1px solid #dbe3ec;border-radius:18px;background:#fff;box-shadow:0 18px 50px rgba(15,23,42,.08)}
+  .top{display:flex;align-items:center;justify-content:space-between;padding:17px 20px;background:#172554;color:#fff}
+  .top h3{margin:0 0 4px;font-size:17px}.top p{margin:0;color:#bfdbfe;font-size:9px}
+  .legend{display:flex;gap:8px;align-items:center;font-size:8px}.legend span{display:flex;align-items:center;gap:3px}.legend i{width:9px;height:9px;border-radius:2px;display:inline-block}
+  .overview{display:grid;grid-template-columns:repeat(4,1fr);background:#f8fafc;border-bottom:1px solid #e2e8f0}
+  .overview div{padding:11px 15px;border-right:1px solid #e2e8f0}.overview div:last-child{border:0}
+  .overview b{display:block;color:#1e3a8a;font-size:11px}.overview small{color:#64748b;font-size:7px}
+  .body{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(330px,.65fr);min-height:610px}
+  .map{padding:16px;border-right:1px solid #e2e8f0}.maphead{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:11px}
+  .maphead strong{font-size:12px}.maphead span{color:#64748b;font-size:8px}
+  .axis{display:grid;grid-template-columns:repeat(8,1fr);margin:0 0 5px;color:#94a3b8;font-size:7px;text-align:center}
+  .icicle{position:relative;height:465px;overflow:hidden;border:1px solid #cbd5e1;border-radius:12px;background:#f8fafc}
+  .grid{position:absolute;inset:0;display:grid;grid-template-columns:repeat(8,1fr);pointer-events:none}.grid i{border-right:1px solid rgba(148,163,184,.2)}
+  .block{position:absolute;overflow:hidden;padding:4px;border:1px solid rgba(255,255,255,.88);border-radius:3px;color:#fff;font-size:7px;font-weight:700;line-height:1.25}
+  .block.cold,.block.none{color:#475569}.high{background:#ef4444}.rise{background:#f59e0b}.mid{background:#eab308}.low{background:#60a5fa}.cold{background:#dbeafe}.none{background:#f1f5f9}
+  .selected{z-index:4;outline:3px solid #7c3aed;outline-offset:-3px}.score{display:block;margin-top:2px;opacity:.86;font-size:6px;font-weight:500}
+  .hotspot{position:absolute;z-index:8;width:210px;padding:9px 10px;border:1px solid #c4b5fd;border-radius:9px;background:rgba(255,255,255,.96);box-shadow:0 8px 20px rgba(30,41,59,.1);color:#334155;font-size:7px;line-height:1.55}
+  .hotspot b{display:block;color:#6d28d9;font-size:9px}.hotspot em{color:#dc2626;font-style:normal}
+  .reading{display:grid;grid-template-columns:repeat(3,1fr);gap:7px;margin-top:10px}.reading div{padding:8px 9px;border-radius:8px;background:#eff6ff;color:#1e3a8a;font-size:7px;line-height:1.5}
+  .output{padding:15px;background:#fbfdff}.output h4{margin:0 0 3px;font-size:13px}.output>p{margin:0 0 10px;color:#64748b;font-size:8px;line-height:1.55}
+  .filters{display:flex;gap:5px;margin-bottom:10px}.filters span{padding:4px 6px;border:1px solid #dbe3ec;border-radius:5px;background:#fff;color:#64748b;font-size:7px}.filters .active{border-color:#1d4ed8;background:#1d4ed8;color:#fff}
+  .demand{margin-bottom:9px;padding:10px;border:1px solid #dbe3ec;border-radius:10px;background:#fff}
+  .dtop{display:flex;justify-content:space-between;gap:8px}.demand strong{font-size:9px;line-height:1.45}.badge{flex:0 0 auto;padding:3px 5px;border-radius:4px;background:#fff7ed;color:#9a3412;font-size:6px;font-weight:800}
+  .scores{display:flex;gap:4px;margin-top:6px}.scores span{padding:3px 5px;border-radius:4px;background:#eff6ff;color:#1e40af;font-size:6px}.scores .grain{background:#f3e8ff;color:#6b21a8}
+  .reason{margin-top:6px;color:#64748b;font-size:7px;line-height:1.55}.reason b{color:#334155;font-size:7px}.model{margin-top:5px;padding-top:5px;border-top:1px dashed #dbe3ec;color:#7c3aed;font-size:6px;line-height:1.5}
+  .not-demand{border-left:3px solid #94a3b8;background:#f8fafc}.not-demand .badge{background:#e2e8f0;color:#475569}
+  .foot{padding:12px 18px;border-top:1px solid #e2e8f0;color:#526174;font-size:9px;line-height:1.65}
+  @media(max-width:980px){.body{grid-template-columns:1fr}.map{border-right:0;border-bottom:1px solid #e2e8f0}.overview{grid-template-columns:1fr 1fr}}
+</style>
+
+<div class="brief">
+  <h2>推荐版 Demo:全局热力地形 + 批量需求输出</h2>
+  <p class="subtitle">热力图不再显示 P/H,也不要求阅读全部节点。每个节点只用 0~1 热度 Score 着色;所有冷区继续占有位置。右侧直接输出系统判断出的需求,并把“是否为需求”与“需求粒度”分开。</p>
+</div>
+
+<section class="demo" data-choice="approved-icicle" onclick="toggleSelect(this)">
+  <header class="top">
+    <div><h3>平台全局需求地图</h3><p>完整分类树、热冷分布与需求结果使用同一份上游数据</p></div>
+    <div class="legend"><span><i class="high"></i>0.80–1 高热</span><span><i class="rise"></i>0.60–0.79</span><span><i class="mid"></i>0.40–0.59</span><span><i class="low"></i>0.20–0.39</span><span><i class="cold"></i>0–0.19</span><span><i class="none"></i>无数据</span></div>
+  </header>
+  <div class="overview">
+    <div><b>热度 Score 0~1</b><small>颜色只表示当前热冷,不代表是否为需求</small></div>
+    <div><b>冷区完整保留</b><small>所有节点都占据位置,提供全局比较基线</small></div>
+    <div><b>需求按搜索意义判断</b><small>宽泛需求、主题需求和长尾需求都可成立</small></div>
+    <div><b>Reason 可追溯</b><small>上游事实和模型解释分开显示</small></div>
+  </div>
+  <div class="body">
+    <section class="map">
+      <div class="maphead"><strong>完整分类树热力地形</strong><span>横向为层级 · 纵向为分支规模 · 点击色块放大</span></div>
+      <div class="axis"><span>根</span><span>一级</span><span>二级</span><span>三级</span><span>四级</span><span>五级</span><span>六级+</span><span>元素</span></div>
+      <div class="icicle">
+        <div class="grid"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
+        <div class="block mid" style="left:0;top:0;width:12.5%;height:58%">表象<span class="score">Score 0.52</span></div>
+        <div class="block mid" style="left:0;top:58%;width:12.5%;height:42%">理念<span class="score">Score 0.49</span></div>
+        <div class="block rise" style="left:12.5%;top:0;width:12.5%;height:23%">行为<span class="score">0.68</span></div>
+        <div class="block cold" style="left:12.5%;top:23%;width:12.5%;height:7%">内容形态<span class="score">0.08</span></div>
+        <div class="block rise" style="left:12.5%;top:30%;width:12.5%;height:12%">场景<span class="score">0.64</span></div>
+        <div class="block none" style="left:12.5%;top:42%;width:12.5%;height:5%">声音<span class="score">数据较少</span></div>
+        <div class="block low" style="left:12.5%;top:47%;width:12.5%;height:11%">实体<span class="score">0.38</span></div>
+        <div class="block rise" style="left:12.5%;top:58%;width:12.5%;height:25%">知识<span class="score">0.72</span></div>
+        <div class="block cold" style="left:12.5%;top:83%;width:12.5%;height:7%">现象<span class="score">0.16</span></div>
+        <div class="block mid" style="left:12.5%;top:90%;width:12.5%;height:6%">事件<span class="score">0.51</span></div>
+        <div class="block cold" style="left:12.5%;top:96%;width:12.5%;height:4%">观念<span class="score">0.13</span></div>
+
+        <div class="block rise" style="left:25%;top:0;width:12.5%;height:8%">日常互动</div>
+        <div class="block rise" style="left:25%;top:8%;width:12.5%;height:10%">日常活动</div>
+        <div class="block none" style="left:25%;top:18%;width:12.5%;height:5%">纪念活动</div>
+        <div class="block high selected" style="left:25%;top:23%;width:12.5%;height:7%">战斗作战<span class="score">0.91</span></div>
+        <div class="block rise" style="left:25%;top:30%;width:12.5%;height:7%">空间</div>
+        <div class="block mid" style="left:25%;top:37%;width:12.5%;height:5%">时间</div>
+        <div class="block cold" style="left:25%;top:42%;width:12.5%;height:5%">音乐 / 语音</div>
+        <div class="block rise" style="left:25%;top:47%;width:12.5%;height:8%">人物</div>
+        <div class="block low" style="left:25%;top:55%;width:12.5%;height:3%">物品</div>
+        <div class="block rise" style="left:25%;top:58%;width:12.5%;height:18%">概念</div>
+        <div class="block rise" style="left:25%;top:76%;width:12.5%;height:7%">方法</div>
+        <div class="block none" style="left:25%;top:83%;width:12.5%;height:7%">生活状态</div>
+        <div class="block rise" style="left:25%;top:90%;width:12.5%;height:6%">历史事件</div>
+        <div class="block none" style="left:25%;top:96%;width:12.5%;height:4%">社会 / 个人</div>
+
+        <div class="block high selected" style="left:37.5%;top:8%;width:12.5%;height:6%">居家生活<span class="score">0.86</span></div>
+        <div class="block rise" style="left:50%;top:8%;width:12.5%;height:2.8%">家务劳动<span class="score">0.68</span></div>
+        <div class="block mid" style="left:50%;top:10.8%;width:12.5%;height:1.5%">阅读<span class="score">0.44</span></div>
+        <div class="block cold" style="left:50%;top:12.3%;width:12.5%;height:1.7%">物品处置<span class="score">0.11</span></div>
+        <div class="block rise" style="left:62.5%;top:8%;width:12.5%;height:2.8%">家庭清洁</div>
+        <div class="block none" style="left:62.5%;top:10.8%;width:12.5%;height:1.5%">书本翻页</div>
+
+        <div class="block rise" style="left:37.5%;top:47%;width:12.5%;height:4%">现实人物</div>
+        <div class="block mid" style="left:50%;top:47%;width:12.5%;height:3%">历史名人</div>
+        <div class="block low" style="left:62.5%;top:47%;width:12.5%;height:2%">政治领袖</div>
+        <div class="block cold" style="left:75%;top:47%;width:12.5%;height:2%">共和国领导人</div>
+
+        <div class="block mid" style="left:37.5%;top:76%;width:12.5%;height:7%">生活技巧</div>
+        <div class="block none selected" style="left:50%;top:76%;width:12.5%;height:3%">居家生活<span class="score">数据缺失</span></div>
+        <div class="block low" style="left:62.5%;top:76%;width:12.5%;height:3%">驱蚊技巧<span class="score">探索需求</span></div>
+
+        <div class="hotspot" style="right:10px;top:10px"><b>全局阅读方式</b>先看红橙色区域在全树中的位置,再看周围蓝白冷区。<em>红色代表热,不代表更“像需求”</em>;需求成立与否由搜索意义单独判断。</div>
+      </div>
+      <div class="reading"><div><b>全局态</b><br>色块密度和颜色形成热冷地形,不依赖读取全部名称。</div><div><b>放大态</b><br>点击“居家生活”后用整个画布展示其子树和需求。</div><div><b>需求态</b><br>系统已完成全树判断,右侧直接给出数百条结果。</div></div>
+    </section>
+
+    <aside class="output">
+      <h4>系统形成的需求</h4>
+      <p>按热度、可信度和业务粒度排序。宽泛需求与具体需求可以同时保留,并建立父子关系。</p>
+      <div class="filters"><span class="active">全部需求</span><span>宽泛</span><span>主题级</span><span>具体</span><span>探索</span></div>
+
+      <div class="demand">
+        <div class="dtop"><strong>战争</strong><span class="badge">宽泛需求</span></div>
+        <div class="scores"><span>热度 0.91</span><span>可信度 0.78</span><span class="grain">粒度:宽泛</span></div>
+        <div class="reason"><b>数据事实:</b>“战斗作战”外部信号强,存在真实 ROV / VOV;上游元素包含“战争”。</div>
+        <div class="model">模型 Reason:“战争”能够独立表达外部搜索意图,因此成立为需求。范围较大只表示它是父级需求,应继续下挂抗日战争、解放战争、人物、事件等子需求。</div>
+      </div>
+
+      <div class="demand">
+        <div class="dtop"><strong>家庭清洁</strong><span class="badge">主题级需求</span></div>
+        <div class="scores"><span>热度 0.68</span><span>可信度 0.55</span><span class="grain">粒度:主题级</span></div>
+        <div class="reason"><b>数据事实:</b>位于“居家生活 → 家务劳动”,上游元素为“家庭清洁”,真实 ROV / VOV 暂缺。</div>
+        <div class="model">模型 Reason:可以独立搜索并承接多类内容。后验缺失属于验证空白,不应过滤该需求。</div>
+      </div>
+
+      <div class="demand">
+        <div class="dtop"><strong>驱蚊技巧</strong><span class="badge">具体需求</span></div>
+        <div class="scores"><span>热度 0.24</span><span>可信度 0.41</span><span class="grain">粒度:具体</span></div>
+        <div class="reason"><b>数据事实:</b>位于“方法 → 生活技巧 → 居家生活”,上游存在同名具体元素,表现数据缺失。</div>
+        <div class="model">模型 Reason:虽然属于冷区,但具有明确搜索意义,仍然成立为探索需求。</div>
+      </div>
+
+      <div class="demand not-demand">
+        <div class="dtop"><strong>表象</strong><span class="badge">不是需求</span></div>
+        <div class="scores"><span>热度 0.52</span><span class="grain">类型:分类结构词</span></div>
+        <div class="reason"><b>数据事实:</b>它是全局树根节点,覆盖大量不同语义分支。</div>
+        <div class="model">模型 Reason:“表象”不能独立表达有意义的外部搜索意图,因此只作为分类结构保留,不进入需求结果。</div>
+      </div>
+    </aside>
+  </div>
+  <div class="foot"><b>这版解决的问题:</b>深层级和大量节点通过连续面积表达,不再依靠密集线条;冷区始终存在;Score 统一为 0~1;“是否为需求”和“需求粒度”彻底分开。</div>
+</section>

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
visualization/.superpowers/brainstorm/52647-1784195885/state/events


+ 1 - 0
visualization/.superpowers/brainstorm/52647-1784195885/state/server-info

@@ -0,0 +1 @@
+{"type":"server-started","port":55457,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:55457/?key=a3be5e873ced090c1982c9ef16baa747bed1265f6e70aff99737433ae1887e6c","screen_dir":"/Users/apple/code/service/SupplyAgent/visualization/.superpowers/brainstorm/52647-1784195885/content","state_dir":"/Users/apple/code/service/SupplyAgent/visualization/.superpowers/brainstorm/52647-1784195885/state","idle_timeout_ms":14400000}

+ 1 - 0
visualization/.superpowers/brainstorm/52647-1784195885/state/server-instance-id

@@ -0,0 +1 @@
+1b1a186ab50463ed7f49c2dc07895f8746c4f2e729882740

+ 1 - 0
visualization/.superpowers/brainstorm/52647-1784195885/state/server.pid

@@ -0,0 +1 @@
+52662

+ 88 - 0
visualization/.superpowers/tree_navigation_regression.py

@@ -0,0 +1,88 @@
+from playwright.sync_api import sync_playwright
+
+
+URL = "http://localhost:8765/view/全局分类树-需求热度与关系分析.html"
+
+
+def wait_until_ready(page):
+    page.goto(URL)
+    page.locator("#demand-count").wait_for(state="visible")
+    page.wait_for_function(
+        "document.querySelector('#demand-count').textContent.trim() !== '—'"
+    )
+
+
+def test_level_scroll(page):
+    wait_until_ready(page)
+    page.locator('[data-start-depth="3"]').click()
+    viewport = page.locator("#icicle-viewport")
+    dimensions = viewport.evaluate(
+        "(element) => ({clientHeight: element.clientHeight, scrollHeight: element.scrollHeight})"
+    )
+    assert dimensions["scrollHeight"] > dimensions["clientHeight"], dimensions
+    assert page.locator("#map-context").text_content().startswith("从第 3 级开始")
+    assert page.locator("body").get_attribute("data-start-depth") == "3"
+    page.screenshot(path="/tmp/supplyagent-level-tree.png")
+
+    page.locator("#icicle-canvas").hover(position={"x": 120, "y": 220})
+    page.mouse.wheel(0, 900)
+    page.wait_for_timeout(300)
+    scroll_top = viewport.evaluate("(element) => element.scrollTop")
+    assert scroll_top > 0, f"ordinary wheel did not scroll the long tree: {scroll_top}"
+    before_scale = float(page.locator("body").get_attribute("data-view-scale"))
+    page.keyboard.down("Control")
+    page.mouse.wheel(0, -120)
+    page.keyboard.up("Control")
+    page.wait_for_timeout(100)
+    after_scale = float(page.locator("body").get_attribute("data-view-scale"))
+    assert after_scale > before_scale, (before_scale, after_scale)
+
+
+def test_demand_path_navigation(page):
+    wait_until_ready(page)
+    page.locator("#global-search").fill("节日")
+    page.locator(".demand-card", has_text="节日").first.click()
+    page.locator(".attachment-button").first.click()
+
+    path_buttons = page.locator("[data-path-info-node]")
+    count = path_buttons.count()
+    assert count > 1, f"expected an ancestor path, got {count} nodes"
+    parent_button = path_buttons.nth(count - 2)
+    parent_name = parent_button.locator("strong").text_content().strip()
+    parent_button.click()
+
+    assert "visible" in page.locator(".demand-attachments").get_attribute("class")
+    assert "节日" in page.locator(".demand-card.selected").text_content()
+    current_name = page.locator("#breadcrumb-bar button.current").text_content().strip()
+    assert current_name == parent_name, (current_name, parent_name)
+
+    page.locator("#parent-view").click()
+    higher_parent_name = (
+        page.locator("#breadcrumb-bar button.current").text_content().strip()
+    )
+    assert higher_parent_name != parent_name
+    assert "节日" in page.locator(".demand-card.selected").text_content()
+    assert (
+        page.locator("[data-path-info-node].active strong").text_content().strip()
+        == higher_parent_name
+    )
+    page.screenshot(path="/tmp/supplyagent-demand-path.png", full_page=True)
+
+
+with sync_playwright() as playwright:
+    browser = playwright.chromium.launch(
+        headless=True,
+        executable_path="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+    )
+    page = browser.new_page(viewport={"width": 1600, "height": 1000})
+    console_errors = []
+    page.on(
+        "console",
+        lambda message: console_errors.append(message.text)
+        if message.type == "error"
+        else None,
+    )
+    test_level_scroll(page)
+    test_demand_path_navigation(page)
+    assert not console_errors, console_errors
+    browser.close()

BIN=BIN
visualization/assets/liubai.png


+ 543 - 0
visualization/pages/global-tree-analysis.css

@@ -0,0 +1,543 @@
+:root {
+  --navy: #172554;
+  --blue: #1d4ed8;
+  --blue-soft: #eff6ff;
+  --orange: #f59e0b;
+  --purple: #7c3aed;
+  --ink: #0f172a;
+  --muted: #64748b;
+  --line: #dbe3ec;
+  --paper: #ffffff;
+  --background: #f8fafc;
+  --high: #ef4444;
+  --rise: #f59e0b;
+  --mid: #22a06b;
+  --low: #2563eb;
+  --cold: #bfdbfe;
+  --missing: #f1f5f9;
+}
+
+* { box-sizing: border-box; }
+
+.content {
+  width: calc(100% - 20px);
+  max-width: none;
+  padding-top: 12px;
+}
+
+.demand-map-app {
+  min-height: 100vh;
+  padding: 12px;
+  color: var(--ink);
+  background:
+    radial-gradient(circle at 8% 0%, rgba(59,130,246,.08), transparent 24%),
+    radial-gradient(circle at 92% 4%, rgba(245,158,11,.06), transparent 20%),
+    var(--background);
+  font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
+}
+
+.page-header {
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 24px;
+  max-width: none;
+  margin: 0 auto 18px;
+}
+
+.page-header h1 {
+  margin: 0 0 6px;
+  color: #111b3f;
+  font-family: Georgia, "Noto Serif SC", serif;
+  font-size: clamp(30px, 3.4vw, 50px);
+  font-weight: 570;
+  letter-spacing: -.04em;
+}
+
+.page-header p { margin: 0; color: var(--muted); font-size: 14px; }
+
+.source-stats {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+  gap: 7px;
+}
+
+.source-stats span {
+  padding: 7px 10px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: rgba(255,255,255,.8);
+  color: #526174;
+  font-size: 10px;
+}
+
+.principle-strip {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  max-width: none;
+  margin: 0 auto;
+  overflow: hidden;
+  border: 1px solid var(--line);
+  border-bottom: 0;
+  border-radius: 16px 16px 0 0;
+  background: #fff;
+}
+
+.principle-strip div { padding: 11px 15px; border-right: 1px solid var(--line); }
+.principle-strip div:last-child { border: 0; }
+.principle-strip strong { display: block; color: #1e3a8a; font-size: 11px; }
+.principle-strip small { display: block; margin-top: 3px; color: var(--muted); font-size: 8px; }
+
+.workspace {
+  display: grid;
+  grid-template-columns: minmax(680px, 1fr) minmax(360px, 480px);
+  max-width: none;
+  height: min(76vh, 900px);
+  min-height: 700px;
+  margin: 0 auto;
+  overflow: hidden;
+  border: 1px solid var(--line);
+  border-radius: 0 0 16px 16px;
+  background: #fff;
+  box-shadow: 0 20px 55px rgba(15,23,42,.07);
+}
+
+.map-panel {
+  display: grid;
+  grid-template-rows: auto auto auto minmax(0, 1fr) auto;
+  min-width: 0;
+  min-height: 0;
+  height: 100%;
+  overflow: hidden;
+  border-right: 1px solid var(--line);
+}
+
+.map-toolbar {
+  display: grid;
+  grid-template-columns: minmax(220px, auto) minmax(330px, 1fr) 220px auto;
+  align-items: center;
+  gap: 14px;
+  padding: 15px 16px 11px;
+  border-bottom: 1px solid #e8edf2;
+}
+
+.map-toolbar h2, .demand-panel-head h2 { margin: 0 0 3px; font-size: 15px; }
+.map-toolbar p, .demand-panel-head p { margin: 0; color: var(--muted); font-size: 9px; }
+
+.heat-legend { display: flex; flex-wrap: wrap; gap: 7px; color: #526174; font-size: 8px; }
+.heat-legend span { display: inline-flex; align-items: center; gap: 3px; }
+.heat-legend i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; }
+.heat-high { background: var(--high); }
+.heat-rise { background: var(--rise); }
+.heat-mid { background: var(--mid); }
+.heat-low { background: var(--low); }
+.heat-cold { background: var(--cold); border: 1px solid #bfdbfe; }
+.heat-missing { background: var(--missing); border: 1px solid #d8e0e8; }
+
+.search-box {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 7px 9px;
+  border: 1px solid var(--line);
+  border-radius: 7px;
+  background: #fff;
+  color: #94a3b8;
+}
+
+.search-box input {
+  width: 100%;
+  min-width: 0;
+  border: 0;
+  outline: 0;
+  background: none;
+  color: var(--ink);
+  font-size: 10px;
+}
+
+.global-button {
+  height: 31px;
+  padding: 0 10px;
+  border: 1px solid #93c5fd;
+  border-radius: 7px;
+  background: var(--blue-soft);
+  color: #1d4ed8;
+  font-size: 9px;
+  cursor: pointer;
+}
+.global-button:disabled { cursor: not-allowed; opacity: .42; }
+.view-actions { display: flex; gap: 5px; }
+
+.depth-axis {
+  display: grid;
+  min-height: 24px;
+  padding: 6px 12px 4px;
+  color: #94a3b8;
+  background: #fbfdff;
+  border-bottom: 1px solid #edf1f5;
+  font-size: 7px;
+  text-align: center;
+}
+.depth-axis button {
+  min-width: 0;
+  padding: 2px 3px;
+  border: 0;
+  border-radius: 4px;
+  background: transparent;
+  color: #94a3b8;
+  font: inherit;
+  cursor: pointer;
+}
+.depth-axis button:hover { background: #eff6ff; color: #1d4ed8; }
+.depth-axis button.active { background: #dbeafe; color: #1d4ed8; font-weight: 750; }
+
+.breadcrumb-bar {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  min-height: 30px;
+  overflow-x: auto;
+  padding: 5px 12px;
+  border-bottom: 1px solid #edf1f5;
+  background: #fff;
+}
+.breadcrumb-bar button {
+  flex: 0 0 auto;
+  padding: 4px 6px;
+  border: 0;
+  border-radius: 5px;
+  background: #f1f5f9;
+  color: #526174;
+  font-size: 8px;
+  cursor: pointer;
+}
+.breadcrumb-bar button.current { background: #ede9fe; color: #6d28d9; font-weight: 750; }
+.breadcrumb-bar i { color: #94a3b8; font-size: 8px; font-style: normal; }
+
+.icicle-viewport {
+  position: relative;
+  min-height: 0;
+  margin: 12px;
+  overflow: auto;
+  border: 1px solid #cbd5e1;
+  border-radius: 12px;
+  background: #f8fafc;
+  scrollbar-gutter: stable;
+}
+
+.icicle-wrap {
+  position: relative;
+  width: 100%;
+  min-height: 100%;
+  overflow: hidden;
+  background: #f8fafc;
+}
+
+#icicle-canvas {
+  position: absolute;
+  inset: 0;
+  display: block;
+  width: 100%;
+  height: 100%;
+  cursor: crosshair;
+  touch-action: none;
+}
+.icicle-wrap.dragging #icicle-canvas { cursor: grabbing; }
+.map-loading { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); font-size: 12px; }
+
+.map-tooltip {
+  position: absolute;
+  z-index: 8;
+  display: none;
+  width: 235px;
+  padding: 9px 10px;
+  border: 1px solid #c4b5fd;
+  border-radius: 8px;
+  background: rgba(255,255,255,.97);
+  color: #334155;
+  box-shadow: 0 9px 24px rgba(15,23,42,.14);
+  font-size: 8px;
+  line-height: 1.55;
+  pointer-events: none;
+}
+
+.map-tooltip strong { display: block; color: #5b21b6; font-size: 10px; }
+.map-tooltip span { display: block; color: #64748b; }
+
+.map-guide {
+  position: absolute;
+  right: 10px;
+  top: 10px;
+  z-index: 5;
+  width: 190px;
+  padding: 9px 10px;
+  border: 1px solid #bfdbfe;
+  border-radius: 8px;
+  background: rgba(255,255,255,.94);
+  color: #526174;
+  box-shadow: 0 7px 18px rgba(15,23,42,.07);
+  pointer-events: none;
+}
+
+.map-guide strong { display: block; margin-bottom: 3px; color: #1d4ed8; font-size: 9px; }
+.map-guide span { display: block; font-size: 7px; line-height: 1.5; }
+
+.zoom-controls {
+  position: absolute;
+  right: 10px;
+  bottom: 10px;
+  z-index: 6;
+  display: flex;
+  gap: 4px;
+}
+.zoom-controls button {
+  min-width: 31px;
+  height: 29px;
+  padding: 0 7px;
+  border: 1px solid #bfdbfe;
+  border-radius: 6px;
+  background: rgba(255,255,255,.95);
+  color: #1d4ed8;
+  font-size: 9px;
+  cursor: pointer;
+}
+
+.map-summary {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 7px;
+  padding: 0 12px 12px;
+}
+
+.map-summary div {
+  padding: 8px 9px;
+  border-radius: 8px;
+  background: #eff6ff;
+  color: #1e3a8a;
+  font-size: 8px;
+  line-height: 1.5;
+}
+
+.demand-panel {
+  display: grid;
+  grid-template-rows: auto auto auto minmax(0, 1fr);
+  min-width: 0;
+  min-height: 0;
+  height: 100%;
+  overflow: hidden;
+  background: #fbfdff;
+}
+
+.demand-panel-head {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 15px 15px 11px;
+  border-bottom: 1px solid #e8edf2;
+}
+
+.demand-panel-head > strong {
+  color: #1d4ed8;
+  font-family: Georgia, serif;
+  font-size: 22px;
+  font-weight: 560;
+}
+
+.demand-filters { display: flex; gap: 5px; overflow-x: auto; padding: 10px 14px 7px; }
+.demand-filters button {
+  flex: 0 0 auto;
+  padding: 5px 8px;
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  background: #fff;
+  color: var(--muted);
+  font-size: 8px;
+  cursor: pointer;
+}
+.demand-filters button.active { border-color: var(--blue); background: var(--blue); color: #fff; }
+
+.demand-attachments {
+  display: none;
+  max-height: 210px;
+  overflow: auto;
+  margin: 2px 13px 8px;
+  padding: 9px;
+  border: 1px solid #c4b5fd;
+  border-radius: 9px;
+  background: #faf8ff;
+}
+.demand-attachments.visible { display: block; }
+.demand-attachments-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  margin-bottom: 7px;
+}
+.demand-attachments-head strong { color: #5b21b6; font-size: 9px; }
+.demand-attachments-head span { color: #7c3aed; font-size: 7px; }
+.attachment-button {
+  display: grid;
+  grid-template-columns: 18px minmax(0, 1fr) auto;
+  align-items: center;
+  gap: 6px;
+  width: 100%;
+  margin-top: 5px;
+  padding: 7px;
+  border: 1px solid #e2e8f0;
+  border-radius: 7px;
+  background: #fff;
+  color: #475569;
+  text-align: left;
+  cursor: pointer;
+}
+.attachment-button:hover, .attachment-button.active { border-color: #8b5cf6; background: #f5f3ff; }
+.attachment-button b {
+  display: grid;
+  place-items: center;
+  width: 18px;
+  height: 18px;
+  border-radius: 50%;
+  background: #ede9fe;
+  color: #6d28d9;
+  font-size: 7px;
+}
+.attachment-button span { min-width: 0; font-size: 7px; line-height: 1.5; }
+.attachment-button em { color: #7c3aed; font-size: 7px; font-style: normal; white-space: nowrap; }
+
+.demand-list { overflow: auto; padding: 3px 13px 16px; }
+.demand-card {
+  margin-bottom: 9px;
+  padding: 10px;
+  border: 1px solid var(--line);
+  border-left: 3px solid var(--orange);
+  border-radius: 9px;
+  background: #fff;
+  cursor: pointer;
+  transition: border-color .15s, box-shadow .15s;
+}
+.demand-card:hover, .demand-card.selected { border-color: #93c5fd; box-shadow: 0 7px 18px rgba(15,23,42,.06); }
+.demand-card.selected { border-left-color: var(--purple); }
+.demand-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
+.demand-card h3 { margin: 0; font-size: 11px; line-height: 1.4; }
+.granularity {
+  flex: 0 0 auto;
+  padding: 3px 5px;
+  border-radius: 4px;
+  background: #fff7ed;
+  color: #9a3412;
+  font-size: 7px;
+  font-weight: 750;
+}
+
+.score-row { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 7px; }
+.score-row span { padding: 3px 5px; border-radius: 4px; background: #eff6ff; color: #1e40af; font-size: 7px; }
+.score-row .path-count { background: #f3e8ff; color: #6b21a8; }
+.score-row .missing-data { background: #f1f5f9; color: #64748b; }
+.card-reason { margin: 7px 0 0; color: #64748b; font-size: 8px; line-height: 1.55; }
+.card-reason strong { color: #334155; }
+
+.empty-state, .explanation-empty { padding: 30px 18px; color: var(--muted); font-size: 11px; line-height: 1.7; }
+
+.explanation-panel {
+  display: grid;
+  grid-template-columns: minmax(230px, .72fr) minmax(360px, 1fr) minmax(360px, 1fr);
+  gap: 12px;
+  max-width: none;
+  margin: 14px auto 0;
+}
+
+.explanation-panel > article {
+  min-width: 0;
+  padding: 15px 16px;
+  border: 1px solid var(--line);
+  border-radius: 13px;
+  background: #fff;
+}
+
+.explanation-panel > article.fact-card { border-top: 3px solid var(--orange); }
+.explanation-panel > article.model-card { border-top: 3px solid var(--purple); }
+.explanation-panel > article.path-context-card {
+  grid-column: 1 / -1;
+  border-top: 3px solid #2563eb;
+}
+.explanation-panel h2 { margin: 0 0 4px; font-size: 14px; }
+.explanation-panel h3 { margin: 0 0 8px; color: var(--muted); font-size: 9px; font-weight: 500; }
+.explanation-panel p { margin: 0; color: #526174; font-size: 9px; line-height: 1.7; }
+.explanation-panel .demand-title { color: #1e3a8a; font-size: 20px; font-family: Georgia, "Noto Serif SC", serif; }
+
+.attachment-list { display: grid; gap: 5px; margin-top: 9px; }
+.attachment-list span { padding: 6px 7px; border-radius: 6px; background: #f8fafc; color: #526174; font-size: 8px; line-height: 1.45; }
+.raw-reason { margin-top: 8px !important; padding-top: 8px; border-top: 1px dashed var(--line); color: #7c3aed !important; }
+
+.path-node-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+  gap: 8px;
+  margin-top: 10px;
+}
+.path-node-grid button {
+  display: grid;
+  grid-template-columns: 22px minmax(0, 1fr);
+  gap: 8px;
+  min-width: 0;
+  padding: 9px;
+  border: 1px solid #dbeafe;
+  border-radius: 9px;
+  background: #f8fbff;
+  color: #334155;
+  text-align: left;
+  cursor: pointer;
+}
+.path-node-grid button:hover,
+.path-node-grid button.active {
+  border-color: #2563eb;
+  background: #eff6ff;
+  box-shadow: 0 6px 16px rgba(37,99,235,.08);
+}
+.path-node-grid b {
+  display: grid;
+  place-items: center;
+  width: 22px;
+  height: 22px;
+  border-radius: 50%;
+  background: #dbeafe;
+  color: #1d4ed8;
+  font-size: 8px;
+}
+.path-node-grid span { min-width: 0; }
+.path-node-grid strong,
+.path-node-grid small,
+.path-node-grid em { display: block; }
+.path-node-grid strong { overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
+.path-node-grid small { margin-top: 3px; color: #64748b; font-size: 7px; line-height: 1.5; }
+.path-node-grid em {
+  grid-column: 2;
+  color: #2563eb;
+  font-size: 7px;
+  font-style: normal;
+}
+
+@media (max-width: 1200px) {
+  .workspace { grid-template-columns: minmax(600px, 1fr) minmax(340px, 410px); }
+  .map-toolbar { grid-template-columns: 1fr auto; }
+  .heat-legend { grid-column: 1 / -1; grid-row: 2; }
+  .search-box { grid-column: 1; grid-row: 3; }
+  .global-button { grid-column: 2; grid-row: 3; }
+  .view-actions { grid-column: 2; grid-row: 3; }
+}
+
+@media (max-width: 900px) {
+  .demand-map-app { padding: 16px; }
+  .page-header { align-items: flex-start; flex-direction: column; }
+  .source-stats { justify-content: flex-start; }
+  .principle-strip { grid-template-columns: 1fr 1fr; }
+  .workspace { display: block; height: auto; }
+  .map-panel { height: 730px; border-right: 0; border-bottom: 1px solid var(--line); }
+  .demand-panel { height: 700px; }
+  .explanation-panel { grid-template-columns: 1fr; }
+  .map-guide { display: none; }
+  .zoom-controls { right: 7px; bottom: 7px; }
+}

+ 1114 - 0
visualization/pages/global-tree-analysis.js

@@ -0,0 +1,1114 @@
+(() => {
+  const SOURCE_URL = "/全局分类树.html";
+  const DIMENSIONS = [
+    { key: "ext_pop", label: "外部热度", group: "prior", weight: .30 },
+    { key: "plat_sust_pop", label: "平台持续热度", group: "prior", weight: .23 },
+    { key: "plat_ly_pop", label: "去年同期热度", group: "prior", weight: .17 },
+    { key: "recent_pop", label: "近期热度", group: "prior", weight: .30 },
+    { key: "real_rov_7d", label: "真实 ROV", group: "posterior", weight: .50 },
+    { key: "real_vov_7d", label: "真实 VOV", group: "posterior", weight: .50 }
+  ];
+
+  const STRUCTURE_ONLY = new Set([
+    "表象", "理念", "实体", "行为", "知识", "现象", "事件", "观念",
+    "内容形态", "场景", "声音", "符号", "视觉", "对象", "分类", "其他",
+    "个体", "方法", "概念", "社会", "个人", "事物", "属性", "关系"
+  ]);
+  const ISOLATED_ACTIONS = new Set([
+    "转发", "点赞", "评论", "收藏", "关注", "播放", "浏览", "点击", "分享",
+    "上传", "下载", "发布", "互动", "推荐"
+  ]);
+  const BROAD_DEMANDS = new Set([
+    "战争", "历史", "人物", "诗词", "音乐", "电影", "健康", "教育", "科技",
+    "美食", "旅游", "体育", "情感", "家庭", "生活", "文化", "军事", "政治"
+  ]);
+
+  const state = {
+    roots: [],
+    flat: [],
+    byId: new Map(),
+    elementsByCategory: {},
+    metricModel: {},
+    candidateCount: 0,
+    demands: [],
+    demandById: new Map(),
+    focus: null,
+    startDepth: 0,
+    maxDepth: 0,
+    selectedNode: null,
+    selectedDemand: null,
+    granularity: "all",
+    query: "",
+    canvas: null,
+    ctx: null,
+    dpr: 1,
+    baseRects: [],
+    rects: [],
+    view: { scale: 1, x: 0, y: 0 },
+    pointers: new Map(),
+    drag: { active: false, moved: false, x: 0, y: 0, pinchDistance: 0 },
+    resizeObserver: null
+  };
+
+  const esc = value => String(value ?? "").replace(/[&<>"']/g, char => (
+    { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]
+  ));
+  const clamp01 = value => Math.max(0, Math.min(1, Number(value) || 0));
+  const formatScore = value => value == null ? "无数据" : clamp01(value).toFixed(2);
+  const rawNumber = value => Number(value) || 0;
+  const formatRaw = value => rawNumber(value).toLocaleString("zh-CN", { maximumFractionDigits: 4 });
+  const normalizeName = name => String(name || "").replace(/\s+/g, "").trim();
+
+  function quantile(sorted, q) {
+    if (!sorted.length) return 0;
+    const position = (sorted.length - 1) * q;
+    const base = Math.floor(position);
+    const rest = position - base;
+    return sorted[base + 1] == null
+      ? sorted[base]
+      : sorted[base] + rest * (sorted[base + 1] - sorted[base]);
+  }
+
+  function buildMetricModel(nodes) {
+    const model = {};
+    for (const dimension of DIMENSIONS) {
+      const values = nodes
+        .map(node => rawNumber(node.weights?.[dimension.key]))
+        .filter(value => value > 0)
+        .map(value => Math.log1p(value))
+        .sort((a, b) => a - b);
+      model[dimension.key] = {
+        cap: quantile(values, .95) || Math.max(...values, 1),
+        nonZeroCount: values.length
+      };
+    }
+    return model;
+  }
+
+  function normalizedMetric(key, raw) {
+    const value = rawNumber(raw);
+    if (value <= 0) return null;
+    const cap = state.metricModel[key]?.cap || 1;
+    return clamp01(Math.log1p(value) / cap);
+  }
+
+  function weightedGroup(node, group) {
+    const dimensions = DIMENSIONS.filter(item => item.group === group);
+    let weighted = 0;
+    let weight = 0;
+    let covered = 0;
+    for (const dimension of dimensions) {
+      const score = normalizedMetric(dimension.key, node.weights?.[dimension.key]);
+      if (score == null) continue;
+      weighted += score * dimension.weight;
+      weight += dimension.weight;
+      covered += 1;
+    }
+    return {
+      score: weight ? weighted / weight : null,
+      coverage: covered / dimensions.length,
+      count: covered
+    };
+  }
+
+  function computeNodeScores(node) {
+    const prior = weightedGroup(node, "prior");
+    const posterior = weightedGroup(node, "posterior");
+    const hasAnyData = prior.count + posterior.count > 0;
+    let heatScore = null;
+    if (prior.score != null && posterior.score != null) {
+      heatScore = prior.score * .58 + posterior.score * .42;
+    } else if (prior.score != null) {
+      heatScore = prior.score;
+    } else if (posterior.score != null) {
+      heatScore = posterior.score;
+    }
+    const dimensionCoverage = (prior.count + posterior.count) / DIMENSIONS.length;
+    // 单一维度很强仍然是有效信号,但不应与多维共同升温显示成同样热。
+    // 覆盖度只校正“热度”,不参与是否为需求的判断。
+    if (heatScore != null) {
+      heatScore *= .50 + dimensionCoverage * .50;
+    }
+    const agreement = prior.score != null && posterior.score != null
+      ? 1 - Math.min(1, Math.abs(prior.score - posterior.score))
+      : .46;
+    const confidence = hasAnyData
+      ? clamp01(dimensionCoverage * .62 + agreement * .25 + (node.elements.length ? .13 : 0))
+      : clamp01(node.elements.length ? .18 : 0);
+    node.score = {
+      heatScore: heatScore == null ? null : clamp01(heatScore),
+      confidence,
+      dataState: hasAnyData ? "observed" : "missing",
+      priorScore: prior.score,
+      posteriorScore: posterior.score,
+      priorCoverage: prior.coverage,
+      posteriorCoverage: posterior.coverage
+    };
+  }
+
+  function flatten(nodes, parent = null, path = []) {
+    for (const node of nodes || []) {
+      node.parent = parent;
+      node.path = [...path, node.name];
+      node.children = node.children || [];
+      node.elements = state.elementsByCategory[String(node.id)] || [];
+      state.flat.push(node);
+      state.maxDepth = Math.max(state.maxDepth, node.path.length - 1);
+      state.byId.set(String(node.id), node);
+      flatten(node.children, node, node.path);
+    }
+  }
+
+  function computeSubtreeScore(node) {
+    node.leafCount = node.children.length
+      ? node.children.reduce((sum, child) => sum + computeSubtreeScore(child).leafCount, 0)
+      : 1;
+    const childScores = node.children
+      .map(child => child.displayScore)
+      .filter(value => value != null)
+      .sort((a, b) => b - a);
+    const strongChildren = childScores.slice(0, Math.max(1, Math.ceil(childScores.length * .25)));
+    const childSignal = strongChildren.length
+      ? strongChildren.reduce((sum, value) => sum + value, 0) / strongChildren.length
+      : null;
+    if (node.score.heatScore != null && childSignal != null) {
+      node.displayScore = clamp01(node.score.heatScore * .68 + childSignal * .32);
+    } else {
+      node.displayScore = node.score.heatScore ?? (childSignal == null ? null : childSignal * .78);
+    }
+    return node;
+  }
+
+  function classifySearchIntent(name, sourceType, node) {
+    const normalized = normalizeName(name);
+    if (!normalized || normalized.length < 2) {
+      return { isDemand: false, granularity: null, reasonCode: "too_short" };
+    }
+    if (STRUCTURE_ONLY.has(normalized)) {
+      return { isDemand: false, granularity: null, reasonCode: "structure_only" };
+    }
+    if (ISOLATED_ACTIONS.has(normalized)) {
+      return { isDemand: false, granularity: null, reasonCode: "isolated_action" };
+    }
+    if (/^(其他|相关|类别|类型|内容|信息|方面|部分|综合|未知)/.test(normalized)) {
+      return { isDemand: false, granularity: null, reasonCode: "generic_structure" };
+    }
+
+    let granularity;
+    if (BROAD_DEMANDS.has(normalized) || normalized.length <= 2) granularity = "宽泛";
+    else if (normalized.length <= 5) granularity = "主题级";
+    else if (normalized.length <= 10) granularity = "具体";
+    else granularity = "长尾";
+
+    if (sourceType === "node" && node && node.path.length <= 2 && normalized.length <= 3) {
+      return { isDemand: false, granularity: null, reasonCode: "upper_structure" };
+    }
+    return { isDemand: true, granularity, reasonCode: "searchable" };
+  }
+
+  function inheritedHeat(node) {
+    if (node.score.heatScore != null) return node.score.heatScore;
+    let current = node.parent;
+    let decay = .64;
+    while (current) {
+      if (current.score.heatScore != null) return clamp01(current.score.heatScore * decay);
+      current = current.parent;
+      decay *= .72;
+    }
+    return null;
+  }
+
+  function createCandidate(name, node, sourceType, rawReason = "") {
+    const intent = classifySearchIntent(name, sourceType, node);
+    if (!intent.isDemand) return null;
+    return {
+      name: normalizeName(name),
+      sourceType,
+      granularity: intent.granularity,
+      attachment: {
+        nodeId: String(node.id),
+        nodeName: node.name,
+        path: node.path.join(" / "),
+        heatScore: inheritedHeat(node),
+        ownHeatScore: node.score.heatScore,
+        confidence: node.score.confidence,
+        dataState: node.score.dataState,
+        weights: node.weights || {},
+        rawReason: rawReason || "",
+        elementName: sourceType === "element" ? name : ""
+      }
+    };
+  }
+
+  function extractDemandCandidates() {
+    const candidates = [];
+    for (const node of state.flat) {
+      for (const element of node.elements) {
+        const candidate = createCandidate(element.name, node, "element", element.reason);
+        if (candidate) candidates.push(candidate);
+      }
+      const nodeCandidate = createCandidate(node.name, node, "node", node.description || "");
+      if (nodeCandidate) candidates.push(nodeCandidate);
+    }
+    return candidates;
+  }
+
+  function mergeDemandCandidates(candidates) {
+    const merged = new Map();
+    for (const candidate of candidates) {
+      const key = candidate.name.toLowerCase();
+      if (!merged.has(key)) {
+        merged.set(key, {
+          id: `demand-${merged.size + 1}`,
+          name: candidate.name,
+          granularity: candidate.granularity,
+          attachments: [],
+          sourceTypes: new Set()
+        });
+      }
+      const demand = merged.get(key);
+      demand.sourceTypes.add(candidate.sourceType);
+      const existingAttachment = demand.attachments.find(item =>
+        item.nodeId === candidate.attachment.nodeId
+      );
+      if (!existingAttachment) {
+        demand.attachments.push(candidate.attachment);
+      } else if (
+        candidate.attachment.rawReason &&
+        !existingAttachment.rawReason.includes(candidate.attachment.rawReason)
+      ) {
+        existingAttachment.rawReason = existingAttachment.rawReason
+          ? `${existingAttachment.rawReason};${candidate.attachment.rawReason}`
+          : candidate.attachment.rawReason;
+      }
+      if (candidate.granularity === "长尾") demand.granularity = "长尾";
+      else if (candidate.granularity === "具体" && demand.granularity !== "长尾") demand.granularity = "具体";
+    }
+
+    const results = [...merged.values()];
+    for (const demand of results) {
+      const heatValues = demand.attachments.map(item => item.heatScore).filter(value => value != null);
+      const confidenceValues = demand.attachments.map(item => item.confidence).filter(value => value != null);
+      demand.heatScore = heatValues.length ? Math.max(...heatValues) : null;
+      demand.confidence = confidenceValues.length
+        ? clamp01(confidenceValues.reduce((sum, value) => sum + value, 0) / confidenceValues.length)
+        : 0;
+      demand.hasObservedData = demand.attachments.some(item => item.dataState === "observed");
+      demand.priority = (demand.heatScore ?? .08) * .56
+        + demand.confidence * .24
+        + Math.min(1, demand.attachments.length / 4) * .12
+        + (demand.sourceTypes.has("element") ? .08 : 0);
+      demand.factReason = buildFactReason(demand);
+      demand.modelReason = buildModelReason(demand);
+    }
+    return results.sort((a, b) => b.priority - a.priority || b.attachments.length - a.attachments.length);
+  }
+
+  function selectDemandPortfolio(candidates) {
+    // 输出规模保持在“数百条”。主队列由多维数据支持,探索队列专门保留
+    // 冷区或数据缺失但具备明确搜索意义的需求,避免热度阈值一刀切。
+    const supported = candidates.filter(demand =>
+      demand.hasObservedData && (demand.heatScore ?? 0) >= .12
+    );
+    const exploration = candidates.filter(demand =>
+      !demand.hasObservedData || (demand.heatScore ?? 0) < .12
+    );
+    const selected = new Map();
+    supported.slice(0, 650).forEach(demand => selected.set(demand.name.toLowerCase(), demand));
+    exploration
+      .sort((a, b) => {
+        const aSpecific = a.sourceTypes.has("element") ? 1 : 0;
+        const bSpecific = b.sourceTypes.has("element") ? 1 : 0;
+        return bSpecific - aSpecific || b.priority - a.priority;
+      })
+      .slice(0, 250)
+      .forEach(demand => selected.set(demand.name.toLowerCase(), demand));
+
+    // 用户明确确认的宽泛需求必须保留,不能因为配额或粒度被过滤。
+    for (const demand of candidates) {
+      if (BROAD_DEMANDS.has(demand.name)) selected.set(demand.name.toLowerCase(), demand);
+    }
+    return [...selected.values()].sort((a, b) => b.priority - a.priority);
+  }
+
+  function strongestDimensions(attachment) {
+    return DIMENSIONS
+      .map(dimension => ({
+        label: dimension.label,
+        raw: rawNumber(attachment.weights?.[dimension.key]),
+        normalized: normalizedMetric(dimension.key, attachment.weights?.[dimension.key])
+      }))
+      .filter(item => item.normalized != null)
+      .sort((a, b) => b.normalized - a.normalized)
+      .slice(0, 2);
+  }
+
+  function buildFactReason(demand) {
+    const primary = demand.attachments
+      .slice()
+      .sort((a, b) => (b.heatScore ?? -1) - (a.heatScore ?? -1))[0];
+    const dimensions = strongestDimensions(primary);
+    const signals = dimensions.length
+      ? dimensions.map(item => `${item.label} ${formatRaw(item.raw)}`).join("、")
+      : "六项表现数据当前缺失";
+    const posterior = rawNumber(primary.weights.real_rov_7d) > 0 || rawNumber(primary.weights.real_vov_7d) > 0
+      ? `真实 ROV ${formatRaw(primary.weights.real_rov_7d)}、真实 VOV ${formatRaw(primary.weights.real_vov_7d)}`
+      : "真实 ROV / VOV 暂无样本";
+    const rawReason = demand.attachments.find(item => item.rawReason)?.rawReason;
+    return `主要挂靠“${primary.path}”;${signals};${posterior}`
+      + (rawReason ? `;上游原始 reason:“${rawReason}”` : "。");
+  }
+
+  function buildModelReason(demand) {
+    const heat = demand.heatScore;
+    const dataPhrase = heat == null
+      ? "当前表现数据不足,热度需要后续验证"
+      : heat >= .8 ? "当前处于高热区域"
+      : heat >= .6 ? "当前处于升温区域"
+      : heat >= .4 ? "当前热度中等"
+      : heat >= .2 ? "当前偏冷但仍具有搜索意义"
+      : "当前位于冷区,但冷区不等于无需求";
+    const breadth = {
+      宽泛: "它能够独立表达外部搜索意图,范围较大,因此作为父级宽泛需求保留,并继续下挂更具体需求",
+      主题级: "它能够独立表达明确主题,可承接多个具体内容方向",
+      具体: "它表达了较明确的搜索对象或问题,可以直接用于内容供给",
+      长尾: "它包含较完整的限定条件,适合作为长尾搜索需求"
+    }[demand.granularity];
+    const relation = demand.attachments.length > 1
+      ? `;该需求具有 ${demand.attachments.length} 个挂靠点,说明它跨越多个分类语义`
+      : "";
+    return `${breadth}。${dataPhrase}${relation}。`;
+  }
+
+  function prepareData(data) {
+    state.roots = data.nodes || [];
+    state.elementsByCategory = data.demandsByCategory || {};
+    flatten(state.roots);
+    state.metricModel = buildMetricModel(state.flat);
+    state.flat.forEach(computeNodeScores);
+    state.roots.forEach(computeSubtreeScore);
+    const candidates = mergeDemandCandidates(extractDemandCandidates());
+    state.candidateCount = candidates.length;
+    state.demands = selectDemandPortfolio(candidates);
+    state.demands.forEach(demand => state.demandById.set(demand.id, demand));
+  }
+
+  function heatColor(score, dataState) {
+    if (dataState === "missing" && score == null) return "#f1f5f9";
+    if (score == null) return "#f1f5f9";
+    if (score >= .8) return "#ef4444";
+    if (score >= .6) return "#f59e0b";
+    if (score >= .4) return "#22a06b";
+    if (score >= .2) return "#2563eb";
+    return "#bfdbfe";
+  }
+
+  function textColor(score) {
+    return score == null || score < .2 ? "#475569" : "#ffffff";
+  }
+
+  function descendantsAtDepth(roots, targetDepth) {
+    const results = [];
+    const walk = node => {
+      const depth = node.path.length - 1;
+      if (depth === targetDepth) {
+        results.push(node);
+        return;
+      }
+      if (depth < targetDepth) node.children.forEach(walk);
+    };
+    roots.forEach(walk);
+    return results;
+  }
+
+  function activeBaseDepth() {
+    const focusDepth = state.focus ? state.focus.path.length - 1 : 0;
+    return Math.max(focusDepth, state.startDepth);
+  }
+
+  function viewRoots() {
+    const scopeRoots = state.focus ? [state.focus] : state.roots;
+    const baseDepth = activeBaseDepth();
+    const roots = descendantsAtDepth(scopeRoots, baseDepth);
+    return roots.length ? roots : scopeRoots;
+  }
+
+  function relativeMaxDepth(nodes, baseDepth) {
+    let maximum = 0;
+    const walk = node => {
+      maximum = Math.max(maximum, node.path.length - 1 - baseDepth);
+      node.children.forEach(walk);
+    };
+    nodes.forEach(walk);
+    return maximum;
+  }
+
+  function layoutIcicle() {
+    if (!state.canvas) return [];
+    const rect = state.canvas.getBoundingClientRect();
+    const roots = viewRoots();
+    const baseDepth = activeBaseDepth();
+    const maxDepth = relativeMaxDepth(roots, baseDepth);
+    const columns = maxDepth + 1;
+    const columnWidth = rect.width / columns;
+    const totalLeaves = roots.reduce((sum, node) => sum + node.leafCount, 0);
+    const rows = [];
+    let yCursor = 0;
+
+    const place = (node, y, height) => {
+      const relativeDepth = node.path.length - 1 - baseDepth;
+      rows.push({
+        node,
+        x: relativeDepth * columnWidth,
+        y,
+        width: columnWidth,
+        height
+      });
+      let childY = y;
+      for (const child of node.children) {
+        const childHeight = height * (child.leafCount / node.leafCount);
+        place(child, childY, childHeight);
+        childY += childHeight;
+      }
+    };
+
+    for (const root of roots) {
+      const height = rect.height * (root.leafCount / totalLeaves);
+      place(root, yCursor, height);
+      yCursor += height;
+    }
+    renderDepthAxis(baseDepth);
+    return rows;
+  }
+
+  function renderDepthAxis(baseDepth) {
+    const axis = document.querySelector("#depth-axis");
+    axis.style.gridTemplateColumns = `repeat(${state.maxDepth + 1}, minmax(38px, 1fr))`;
+    axis.innerHTML = Array.from({ length: state.maxDepth + 1 }, (_, depth) => {
+      return `<button class="${depth === baseDepth ? "active" : ""}" data-start-depth="${depth}">${depth === 0 ? "根" : `${depth} 级`}</button>`;
+    }).join("");
+  }
+
+  function renderBreadcrumb() {
+    const target = document.querySelector("#breadcrumb-bar");
+    const pathNodes = [];
+    let current = state.selectedNode || state.focus;
+    while (current) {
+      pathNodes.unshift(current);
+      current = current.parent;
+    }
+    target.innerHTML = pathNodes.length
+      ? pathNodes.map((node, index) => `
+          ${index ? "<i>→</i>" : ""}
+          <button class="${index === pathNodes.length - 1 ? "current" : ""}" data-breadcrumb-id="${esc(node.id)}">${esc(node.name)}</button>
+        `).join("")
+      : `<button class="current" data-reset-breadcrumb="true">全局树</button>`;
+    const parentButton = document.querySelector("#parent-view");
+    parentButton.disabled = !(state.selectedNode?.parent || state.focus?.parent);
+  }
+
+  function updateCanvasExtent() {
+    const viewport = document.querySelector("#icicle-viewport");
+    const wrap = document.querySelector("#icicle-wrap");
+    const roots = viewRoots();
+    const totalLeaves = roots.reduce((sum, node) => sum + node.leafCount, 0);
+    const shouldExtend = state.startDepth > 0 || Boolean(state.focus);
+    const minimumLeafHeight = state.focus ? 13 : 9;
+    const desiredHeight = shouldExtend
+      ? Math.max(viewport.clientHeight, Math.min(24000, totalLeaves * minimumLeafHeight))
+      : viewport.clientHeight;
+    wrap.style.height = `${Math.max(420, desiredHeight)}px`;
+  }
+
+  function resizeCanvas() {
+    const canvas = document.querySelector("#icicle-canvas");
+    updateCanvasExtent();
+    const rect = canvas.getBoundingClientRect();
+    state.canvas = canvas;
+    state.ctx = canvas.getContext("2d");
+    state.dpr = rect.height > 3000 ? 1 : Math.min(window.devicePixelRatio || 1, 2);
+    canvas.width = Math.round(rect.width * state.dpr);
+    canvas.height = Math.round(rect.height * state.dpr);
+    state.ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
+    fitView(false);
+    drawIcicle();
+  }
+
+  function drawIcicle() {
+    if (!state.ctx || !state.flat.length) return;
+    const ctx = state.ctx;
+    const rect = state.canvas.getBoundingClientRect();
+    ctx.clearRect(0, 0, rect.width, rect.height);
+    state.baseRects = layoutIcicle();
+    state.rects = state.baseRects.map(item => ({
+      ...item,
+      x: item.x * state.view.scale + state.view.x,
+      y: item.y * state.view.scale + state.view.y,
+      width: item.width * state.view.scale,
+      height: item.height * state.view.scale
+    }));
+    const query = state.query.toLowerCase();
+
+    for (const item of state.rects) {
+      const node = item.node;
+      const score = node.displayScore;
+      const selected = state.selectedNode === node;
+      const matched = query && (
+        node.name.toLowerCase().includes(query) ||
+        node.elements.some(element => element.name.toLowerCase().includes(query))
+      );
+
+      ctx.fillStyle = heatColor(score, node.score.dataState);
+      ctx.fillRect(item.x, item.y, item.width, item.height);
+      ctx.strokeStyle = "rgba(255,255,255,.88)";
+      ctx.lineWidth = .8;
+      ctx.strokeRect(item.x, item.y, item.width, item.height);
+
+      if (selected || matched) {
+        ctx.strokeStyle = selected ? "#7c3aed" : "#1d4ed8";
+        ctx.lineWidth = selected ? 3 : 2;
+        ctx.strokeRect(item.x + 1.5, item.y + 1.5, Math.max(0, item.width - 3), Math.max(0, item.height - 3));
+      }
+
+      if (item.height >= 15 && item.width >= 54) {
+        ctx.save();
+        ctx.beginPath();
+        ctx.rect(item.x + 2, item.y + 1, item.width - 4, item.height - 2);
+        ctx.clip();
+        ctx.fillStyle = textColor(score);
+        ctx.font = `${item.height >= 30 ? 9 : 7}px Inter, "PingFang SC", sans-serif`;
+        ctx.textBaseline = "top";
+        ctx.fillText(node.name, item.x + 5, item.y + 4);
+        if (item.height >= 31) {
+          ctx.globalAlpha = .82;
+          ctx.font = '7px Inter, "PingFang SC", sans-serif';
+          ctx.fillText(score == null ? "无数据" : `Score ${formatScore(score)}`, item.x + 5, item.y + 17);
+        }
+        ctx.restore();
+      }
+    }
+    document.querySelector("#map-loading").style.display = "none";
+    document.body.dataset.viewScale = state.view.scale.toFixed(3);
+    document.body.dataset.viewX = state.view.x.toFixed(1);
+    document.body.dataset.viewY = state.view.y.toFixed(1);
+    document.body.dataset.startDepth = String(activeBaseDepth());
+    document.body.dataset.focusNode = state.focus?.name || "";
+    document.body.dataset.selectedNode = state.selectedNode?.name || "";
+    document.body.dataset.canvasHeight = String(Math.round(rect.height));
+    renderBreadcrumb();
+  }
+
+  function fitView(redraw = true) {
+    state.view = { scale: 1, x: 0, y: 0 };
+    if (redraw) {
+      const viewport = document.querySelector("#icicle-viewport");
+      viewport.scrollTop = 0;
+      viewport.scrollLeft = 0;
+      drawIcicle();
+    }
+  }
+
+  function zoomAt(factor, screenX, screenY) {
+    const oldScale = state.view.scale;
+    const nextScale = Math.max(1, Math.min(18, oldScale * factor));
+    const worldX = (screenX - state.view.x) / oldScale;
+    const worldY = (screenY - state.view.y) / oldScale;
+    state.view.scale = nextScale;
+    state.view.x = screenX - worldX * nextScale;
+    state.view.y = screenY - worldY * nextScale;
+    drawIcicle();
+  }
+
+  function nodeAt(clientX, clientY) {
+    const rect = state.canvas.getBoundingClientRect();
+    const x = clientX - rect.left;
+    const y = clientY - rect.top;
+    let match = null;
+    for (const item of state.rects) {
+      if (x >= item.x && x <= item.x + item.width && y >= item.y && y <= item.y + item.height) {
+        if (!match || item.node.path.length > match.node.path.length) match = item;
+      }
+    }
+    return match?.node || null;
+  }
+
+  function scrollSelectedNodeIntoView() {
+    if (!state.selectedNode) return;
+    const item = state.rects.find(rect => rect.node === state.selectedNode);
+    if (!item) return;
+    const viewport = document.querySelector("#icicle-viewport");
+    viewport.scrollTop = Math.max(0, item.y - viewport.clientHeight * .38);
+    viewport.scrollLeft = Math.max(0, item.x - viewport.clientWidth * .18);
+  }
+
+  function demandInFocus(demand) {
+    if (!state.focus) return true;
+    const focusPath = state.focus.path.join(" / ");
+    return demand.attachments.some(item => item.path.startsWith(focusPath));
+  }
+
+  function filteredDemands() {
+    const query = state.query.toLowerCase();
+    return state.demands.filter(demand => {
+      if (state.granularity !== "all" && demand.granularity !== state.granularity) return false;
+      if (!demandInFocus(demand)) return false;
+      if (!query) return true;
+      return demand.name.toLowerCase().includes(query)
+        || demand.attachments.some(item => item.path.toLowerCase().includes(query));
+    });
+  }
+
+  function renderDemandList() {
+    const demands = filteredDemands();
+    document.querySelector("#demand-count").textContent = demands.length.toLocaleString("zh-CN");
+    document.querySelector("#demand-context").textContent = state.focus
+      ? `当前聚焦“${state.focus.name}”分支;点击回到全局可恢复全部结果。`
+      : "从全树自动识别,不要求逐节点点击。";
+    document.querySelector("#demand-list").innerHTML = demands.slice(0, 250).map(demand => `
+      <article class="demand-card ${state.selectedDemand === demand ? "selected" : ""}" data-demand-id="${esc(demand.id)}">
+        <div class="demand-card-head">
+          <h3>${esc(demand.name)}</h3>
+          <span class="granularity">${esc(demand.granularity)}需求</span>
+        </div>
+        <div class="score-row">
+          <span>${demand.heatScore == null ? "热度待验证" : `热度 ${formatScore(demand.heatScore)}`}</span>
+          <span>可信度 ${formatScore(demand.confidence)}</span>
+          <span class="path-count">${demand.attachments.length} 个挂靠点</span>
+          ${demand.hasObservedData ? "" : `<span class="missing-data">后验待验证</span>`}
+        </div>
+        <p class="card-reason"><strong>模型 Reason:</strong>${esc(demand.modelReason)}</p>
+      </article>
+    `).join("") || `<div class="empty-state">当前条件下没有匹配需求。冷区节点仍保留在左侧全局图中。</div>`;
+  }
+
+  function renderDemandAttachments(demand) {
+    const target = document.querySelector("#demand-attachments");
+    if (!demand) {
+      target.className = "demand-attachments";
+      target.innerHTML = "";
+      return;
+    }
+    target.className = "demand-attachments visible";
+    target.innerHTML = `
+      <div class="demand-attachments-head">
+        <strong>“${esc(demand.name)}”的完整挂靠路径</strong>
+        <span>${demand.attachments.length} 个挂靠点 · 点击定位</span>
+      </div>
+      ${demand.attachments.map((attachment, index) => `
+        <button class="attachment-button ${state.selectedNode && String(state.selectedNode.id) === attachment.nodeId ? "active" : ""}"
+          data-attachment-demand="${esc(demand.id)}" data-attachment-node="${esc(attachment.nodeId)}">
+          <b>${index + 1}</b>
+          <span>${esc(attachment.path)}</span>
+          <em>定位 →</em>
+        </button>
+      `).join("")}`;
+  }
+
+  function renderMapSummary() {
+    const isScoped = Boolean(state.focus) || state.startDepth > 0;
+    const scope = isScoped
+      ? [...new Map(state.rects.map(item => [item.node.id, item.node])).values()]
+      : state.flat;
+    const observed = scope.filter(node => node.score.dataState === "observed");
+    const hot = scope.filter(node => (node.displayScore ?? -1) >= .6);
+    const cold = scope.filter(node => node.displayScore != null && node.displayScore < .2);
+    const missing = scope.filter(node => node.score.dataState === "missing");
+    document.querySelector("#map-summary").innerHTML = `
+      <div><strong>${hot.length.toLocaleString("zh-CN")} 个升温或高热节点</strong><br>用于定位当前信号集中的分支。</div>
+      <div><strong>${cold.length.toLocaleString("zh-CN")} 个冷区节点</strong><br>继续保留,形成全局比较基线。</div>
+      <div><strong>${missing.length.toLocaleString("zh-CN")} 个无直接数据节点</strong><br>无数据不等于 Score 为 0,也不等于无需求。</div>`;
+    document.querySelector("#map-context").textContent = state.focus
+      ? `${state.focus.path.join(" / ")} · 保留父子层级的当前分支 · ${scope.length.toLocaleString("zh-CN")} 个节点`
+      : state.startDepth > 0
+        ? `从第 ${state.startDepth} 级开始 · 保留向下树结构 · ${scope.length.toLocaleString("zh-CN")} 个节点`
+        : `全局视图 · ${observed.length.toLocaleString("zh-CN")} 个节点具有直接热度数据`;
+  }
+
+  function relatedDemandsForNode(node) {
+    const path = node.path.join(" / ");
+    return state.demands
+      .filter(demand => demand.attachments.some(item => item.path === path || item.path.startsWith(`${path} /`)))
+      .slice(0, 8);
+  }
+
+  function renderNodeExplanation(node) {
+    const related = relatedDemandsForNode(node);
+    const siblings = node.parent
+      ? node.parent.children.filter(item => item !== node).slice(0, 10)
+      : state.roots.filter(item => item !== node);
+    const dimensions = DIMENSIONS.map(dimension => {
+      const raw = rawNumber(node.weights?.[dimension.key]);
+      return `${dimension.label}:${raw > 0 ? formatRaw(raw) : "无数据"}`;
+    }).join(";");
+    document.querySelector("#explanation-panel").innerHTML = `
+      <article>
+        <h3>分类节点</h3>
+        <h2 class="demand-title">${esc(node.name)}</h2>
+        <p>${esc(node.path.join(" / "))}</p>
+        <div class="score-row">
+          <span>${node.displayScore == null ? "热度无数据" : `热度 ${formatScore(node.displayScore)}`}</span>
+          <span>可信度 ${formatScore(node.score.confidence)}</span>
+          <span class="path-count">${node.leafCount} 个叶节点</span>
+        </div>
+      </article>
+      <article class="fact-card">
+        <h3>上游数据事实</h3>
+        <h2>六维信号与元素</h2>
+        <p>${esc(dimensions)}</p>
+        <div class="attachment-list">${node.elements.slice(0, 8).map(element =>
+          `<span><strong>${esc(element.name)}</strong>${element.reason ? ` · 原始 reason:${esc(element.reason)}` : ""}</span>`
+        ).join("") || "<span>该节点没有直接下挂元素。</span>"}</div>
+      </article>
+      <article class="model-card">
+        <h3>模型解释</h3>
+        <h2>该节点如何参与需求形成</h2>
+        <p>${node.score.dataState === "missing"
+          ? "该节点没有直接表现数据,但仍保留在全局树中。系统会继续检查其名称是否具有搜索意义、是否存在具体元素,以及父子和跨树关系。"
+          : "该节点的颜色反映综合热度;颜色不直接决定它是否为需求。系统还会独立判断名称能否表达外部搜索意图。"}</p>
+        <div class="attachment-list">${related.map(demand =>
+          `<span>${esc(demand.name)} · ${esc(demand.granularity)}需求 · 热度 ${formatScore(demand.heatScore)}</span>`
+        ).join("") || "<span>当前没有从该节点及其子树识别出需求。</span>"}</div>
+        ${siblings.length ? `<p class="raw-reason">同级对比:${siblings.map(item =>
+          `${esc(item.name)} ${formatScore(item.displayScore)}`
+        ).join(";")}</p>` : ""}
+      </article>`;
+  }
+
+  function renderDemandExplanation(demand) {
+    const selectedPathNode = state.selectedNode || state.byId.get(demand.attachments[0]?.nodeId);
+    const pathNodes = [];
+    let current = selectedPathNode;
+    while (current) {
+      pathNodes.unshift(current);
+      current = current.parent;
+    }
+    document.querySelector("#explanation-panel").innerHTML = `
+      <article>
+        <h3>${esc(demand.granularity)}需求</h3>
+        <h2 class="demand-title">${esc(demand.name)}</h2>
+        <div class="score-row">
+          <span>${demand.heatScore == null ? "热度待验证" : `热度 ${formatScore(demand.heatScore)}`}</span>
+          <span>可信度 ${formatScore(demand.confidence)}</span>
+          <span class="path-count">${demand.attachments.length} 个挂靠点</span>
+        </div>
+        <div class="attachment-list">${demand.attachments.slice(0, 12).map(item =>
+          `<span>${esc(item.path)}</span>`
+        ).join("")}</div>
+      </article>
+      <article class="fact-card">
+        <h3>上游数据事实</h3>
+        <h2>这个结论依赖了什么数据</h2>
+        <p>${esc(demand.factReason)}</p>
+        <div class="attachment-list">${demand.attachments.slice(0, 8).map(item => {
+          const posterior = rawNumber(item.weights.real_rov_7d) > 0 || rawNumber(item.weights.real_vov_7d) > 0
+            ? `真实 ROV ${formatRaw(item.weights.real_rov_7d)} / 真实 VOV ${formatRaw(item.weights.real_vov_7d)}`
+            : "真实 ROV / VOV 待验证";
+          return `<span>${esc(item.nodeName)} · ${esc(posterior)}</span>`;
+        }).join("")}</div>
+      </article>
+      <article class="model-card">
+        <h3>模型 Reason</h3>
+        <h2>为什么它被识别为需求</h2>
+        <p>${esc(demand.modelReason)}</p>
+        <p class="raw-reason">说明:这里是基于名称、树路径、元素和六维数据生成的模型解释,不是上游原始 reason。</p>
+      </article>
+      <article class="path-context-card">
+        <h3>完整路径节点信息</h3>
+        <h2>沿路径查看父节点及其子树</h2>
+        <div class="path-node-grid">${pathNodes.map((node, index) => `
+          <button class="${node === selectedPathNode ? "active" : ""}" data-path-info-node="${esc(node.id)}">
+            <b>${index + 1}</b>
+            <span><strong>${esc(node.name)}</strong><small>热度 ${formatScore(node.displayScore)} · ${node.children.length} 个直接子节点 · ${node.elements.length} 条元素</small></span>
+            <em>查看父级子树 →</em>
+          </button>
+        `).join("")}</div>
+      </article>`;
+  }
+
+  function selectNode(node, focus = false) {
+    state.selectedNode = node;
+    state.selectedDemand = null;
+    renderDemandAttachments(null);
+    if (focus) {
+      state.focus = node;
+      state.startDepth = node.path.length - 1;
+    }
+    resizeCanvas();
+    renderDemandList();
+    renderMapSummary();
+    renderNodeExplanation(node);
+  }
+
+  function selectDemand(demand) {
+    state.selectedDemand = demand;
+    const primary = demand.attachments[0];
+    state.selectedNode = state.byId.get(primary.nodeId) || null;
+    drawIcicle();
+    renderDemandList();
+    renderDemandAttachments(demand);
+    renderDemandExplanation(demand);
+  }
+
+  function contextAncestor(node, levels = 3) {
+    let current = node;
+    for (let index = 0; index < levels && current.parent; index += 1) current = current.parent;
+    return current;
+  }
+
+  function focusDemandAttachment(demand, nodeId) {
+    const node = state.byId.get(String(nodeId));
+    if (!node) return;
+    const context = contextAncestor(node, 3);
+    state.selectedDemand = demand;
+    state.selectedNode = node;
+    state.focus = context;
+    state.startDepth = context.path.length - 1;
+    resizeCanvas();
+    scrollSelectedNodeIntoView();
+    renderDemandList();
+    renderDemandAttachments(demand);
+    renderMapSummary();
+    renderDemandExplanation(demand);
+  }
+
+  function focusDemandPathNode(demand, node) {
+    state.selectedDemand = demand;
+    state.selectedNode = node;
+    state.focus = node;
+    state.startDepth = node.path.length - 1;
+    resizeCanvas();
+    document.querySelector("#icicle-viewport").scrollTop = 0;
+    renderDemandList();
+    renderDemandAttachments(demand);
+    renderMapSummary();
+    renderDemandExplanation(demand);
+  }
+
+  function resetGlobalView() {
+    state.focus = null;
+    state.startDepth = 0;
+    state.selectedNode = null;
+    state.selectedDemand = null;
+    renderDemandAttachments(null);
+    resizeCanvas();
+    renderDemandList();
+    renderMapSummary();
+    document.querySelector("#explanation-panel").innerHTML =
+      `<div class="explanation-empty">选择热力图节点或右侧需求,查看路径、多个挂靠点、数据事实和模型 Reason。</div>`;
+  }
+
+  function bindEvents() {
+    document.querySelector("#reset-view").addEventListener("click", resetGlobalView);
+    document.querySelector("#fit-view").addEventListener("click", () => fitView(true));
+    document.querySelector("#zoom-in").addEventListener("click", () => {
+      const rect = state.canvas.getBoundingClientRect();
+      zoomAt(1.35, rect.width / 2, rect.height / 2);
+    });
+    document.querySelector("#zoom-out").addEventListener("click", () => {
+      const rect = state.canvas.getBoundingClientRect();
+      zoomAt(.74, rect.width / 2, rect.height / 2);
+    });
+    document.querySelector("#parent-view").addEventListener("click", () => {
+      const current = state.selectedNode || state.focus;
+      if (!current?.parent) return;
+      if (state.selectedDemand) focusDemandPathNode(state.selectedDemand, current.parent);
+      else selectNode(current.parent, true);
+    });
+    document.querySelector("#depth-axis").addEventListener("click", event => {
+      const button = event.target.closest("[data-start-depth]");
+      if (!button) return;
+      const selectedDepth = Number(button.dataset.startDepth);
+      if (selectedDepth === 0) {
+        resetGlobalView();
+        return;
+      }
+      state.focus = null;
+      state.selectedNode = null;
+      state.selectedDemand = null;
+      state.startDepth = selectedDepth;
+      renderDemandAttachments(null);
+      resizeCanvas();
+      document.querySelector("#icicle-viewport").scrollTop = 0;
+      renderDemandList();
+      renderMapSummary();
+    });
+    document.querySelector("#breadcrumb-bar").addEventListener("click", event => {
+      if (event.target.closest("[data-reset-breadcrumb]")) {
+        resetGlobalView();
+        return;
+      }
+      const button = event.target.closest("[data-breadcrumb-id]");
+      if (!button) return;
+      const node = state.byId.get(button.dataset.breadcrumbId);
+      if (!node) return;
+      if (state.selectedDemand) focusDemandPathNode(state.selectedDemand, node);
+      else selectNode(node, true);
+    });
+    document.querySelector("#global-search").addEventListener("input", event => {
+      state.query = event.target.value.trim();
+      drawIcicle();
+      renderDemandList();
+    });
+    document.querySelector("#demand-filters").addEventListener("click", event => {
+      const button = event.target.closest("[data-granularity]");
+      if (!button) return;
+      state.granularity = button.dataset.granularity;
+      document.querySelectorAll("[data-granularity]").forEach(item =>
+        item.classList.toggle("active", item === button)
+      );
+      renderDemandList();
+    });
+    document.querySelector("#demand-list").addEventListener("click", event => {
+      const card = event.target.closest("[data-demand-id]");
+      if (!card) return;
+      selectDemand(state.demandById.get(card.dataset.demandId));
+    });
+    document.querySelector("#demand-attachments").addEventListener("click", event => {
+      const button = event.target.closest("[data-attachment-node]");
+      if (!button) return;
+      const demand = state.demandById.get(button.dataset.attachmentDemand);
+      if (demand) focusDemandAttachment(demand, button.dataset.attachmentNode);
+    });
+    document.querySelector("#explanation-panel").addEventListener("click", event => {
+      const button = event.target.closest("[data-path-info-node]");
+      if (!button || !state.selectedDemand) return;
+      const node = state.byId.get(button.dataset.pathInfoNode);
+      if (node) focusDemandPathNode(state.selectedDemand, node);
+    });
+
+    const canvas = document.querySelector("#icicle-canvas");
+    canvas.addEventListener("wheel", event => {
+      if (!(event.ctrlKey || event.metaKey)) return;
+      event.preventDefault();
+      const rect = canvas.getBoundingClientRect();
+      zoomAt(event.deltaY < 0 ? 1.15 : .87, event.clientX - rect.left, event.clientY - rect.top);
+    }, { passive: false });
+    canvas.addEventListener("pointerdown", event => {
+      canvas.setPointerCapture(event.pointerId);
+      state.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY, type: event.pointerType });
+      state.drag.active = true;
+      state.drag.moved = false;
+      state.drag.x = event.clientX;
+      state.drag.y = event.clientY;
+      if (state.pointers.size === 2) {
+        const points = [...state.pointers.values()];
+        state.drag.pinchDistance = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
+      }
+      document.querySelector("#icicle-wrap").classList.add("dragging");
+    });
+    canvas.addEventListener("pointermove", event => {
+      if (state.pointers.has(event.pointerId)) {
+        state.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY, type: event.pointerType });
+        if (state.pointers.size === 2) {
+          const points = [...state.pointers.values()];
+          const distance = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
+          const centerX = (points[0].x + points[1].x) / 2 - canvas.getBoundingClientRect().left;
+          const centerY = (points[0].y + points[1].y) / 2 - canvas.getBoundingClientRect().top;
+          if (state.drag.pinchDistance) zoomAt(distance / state.drag.pinchDistance, centerX, centerY);
+          state.drag.pinchDistance = distance;
+          state.drag.moved = true;
+          return;
+        }
+        if (state.drag.active) {
+          const dx = event.clientX - state.drag.x;
+          const dy = event.clientY - state.drag.y;
+          if (Math.abs(dx) + Math.abs(dy) > 2) state.drag.moved = true;
+          if (event.pointerType === "touch") {
+            const viewport = document.querySelector("#icicle-viewport");
+            viewport.scrollLeft -= dx;
+            viewport.scrollTop -= dy;
+          } else {
+            state.view.x += dx;
+            state.view.y += dy;
+          }
+          state.drag.x = event.clientX;
+          state.drag.y = event.clientY;
+          if (event.pointerType !== "touch") drawIcicle();
+          return;
+        }
+      }
+      const node = nodeAt(event.clientX, event.clientY);
+      const tooltip = document.querySelector("#map-tooltip");
+      if (!node) {
+        tooltip.style.display = "none";
+        return;
+      }
+      const rect = canvas.getBoundingClientRect();
+      tooltip.style.display = "block";
+      tooltip.style.left = `${Math.min(rect.width - 245, event.clientX - rect.left + 12)}px`;
+      tooltip.style.top = `${Math.min(rect.height - 100, event.clientY - rect.top + 12)}px`;
+      tooltip.innerHTML = `<strong>${esc(node.name)}</strong>
+        <span>${esc(node.path.join(" / "))}</span>
+        <span>热度 ${formatScore(node.displayScore)} · 可信度 ${formatScore(node.score.confidence)}</span>
+        <span>${node.score.dataState === "missing" ? "该节点无直接数据,颜色可能来自子树信号" : "具有直接六维数据"}</span>`;
+    });
+    canvas.addEventListener("pointerleave", () => {
+      document.querySelector("#map-tooltip").style.display = "none";
+    });
+    const endPointer = event => {
+      const wasMoved = state.drag.moved;
+      state.pointers.delete(event.pointerId);
+      if (state.pointers.size < 2) state.drag.pinchDistance = 0;
+      if (!state.pointers.size) {
+        state.drag.active = false;
+        document.querySelector("#icicle-wrap").classList.remove("dragging");
+        if (!wasMoved) {
+          const node = nodeAt(event.clientX, event.clientY);
+          if (node) selectNode(node, true);
+        }
+      }
+    };
+    canvas.addEventListener("pointerup", endPointer);
+    canvas.addEventListener("pointercancel", endPointer);
+    canvas.addEventListener("dblclick", event => {
+      const rect = canvas.getBoundingClientRect();
+      zoomAt(1.8, event.clientX - rect.left, event.clientY - rect.top);
+    });
+  }
+
+  function renderHeader(data) {
+    const elementCount = Object.values(state.elementsByCategory).reduce((sum, items) => sum + items.length, 0);
+    const observed = state.flat.filter(node => node.score.dataState === "observed").length;
+    document.querySelector("#source-stats").innerHTML = `
+      <span>${state.flat.length.toLocaleString("zh-CN")} 个分类节点</span>
+      <span>${elementCount.toLocaleString("zh-CN")} 条上游元素</span>
+      <span>${state.demands.length.toLocaleString("zh-CN")} 条平台需求</span>
+      <span>${state.candidateCount.toLocaleString("zh-CN")} 条候选经过判断</span>
+      <span>${observed.toLocaleString("zh-CN")} 个节点有热度数据</span>
+      <span>数据日期 ${esc(data.bizDt || "—")}</span>`;
+    document.body.dataset.demandCount = String(state.demands.length);
+    document.body.dataset.hasWarDemand = String(state.demands.some(demand => demand.name === "战争"));
+    document.body.dataset.hasAppearanceDemand = String(state.demands.some(demand => demand.name === "表象"));
+    document.body.dataset.scoreRangeValid = String(state.flat.every(node =>
+      node.score.heatScore == null || (node.score.heatScore >= 0 && node.score.heatScore <= 1)
+    ));
+  }
+
+  async function init() {
+    bindEvents();
+    try {
+      const html = await fetch(SOURCE_URL).then(response => {
+        if (!response.ok) throw new Error(`HTTP ${response.status}`);
+        return response.text();
+      });
+      const source = new DOMParser().parseFromString(html, "text/html");
+      const dataScript = source.querySelector("#export-data");
+      if (!dataScript) throw new Error("原始页面中没有找到 export-data");
+      const data = JSON.parse(dataScript.textContent);
+      prepareData(data);
+      renderHeader(data);
+      resizeCanvas();
+      renderDemandList();
+      renderMapSummary();
+      state.resizeObserver = new ResizeObserver(resizeCanvas);
+      state.resizeObserver.observe(document.querySelector("#icicle-viewport"));
+    } catch (error) {
+      document.querySelector("#map-loading").textContent = `读取全局树失败:${error.message}`;
+      document.querySelector("#demand-list").innerHTML =
+        `<div class="empty-state">读取全局树失败:${esc(error.message)}</div>`;
+      console.error(error);
+    }
+  }
+
+  init();
+})();

+ 94 - 0
visualization/pages/全局分类树-需求热度与关系分析.html

@@ -0,0 +1,94 @@
+<link rel="stylesheet" href="/pages/global-tree-analysis.css">
+
+<main class="demand-map-app">
+  <header class="page-header">
+    <div>
+      <h1>平台全局需求地图</h1>
+      <p>从完整分类树中识别全局热冷分布,并直接形成具有外部搜索意义的需求。</p>
+    </div>
+    <div class="source-stats" id="source-stats"><span>正在读取上游全局树…</span></div>
+  </header>
+
+  <section class="principle-strip">
+    <div><strong>热度 Score 0~1</strong><small>颜色只表示冷热,不决定是否为需求</small></div>
+    <div><strong>冷区完整保留</strong><small>全量节点都在图中占有位置</small></div>
+    <div><strong>按搜索意义识别需求</strong><small>宽泛、主题级、具体和长尾需求都可成立</small></div>
+    <div><strong>Reason 可追溯</strong><small>上游事实与模型解释分开呈现</small></div>
+  </section>
+
+  <section class="workspace">
+    <section class="map-panel">
+      <div class="map-toolbar">
+        <div>
+          <h2>完整分类树热力地形</h2>
+          <p id="map-context">全局视图 · 横向为层级,纵向为分支规模</p>
+        </div>
+        <div class="heat-legend">
+          <span><i class="heat-high"></i>0.80–1 高热</span>
+          <span><i class="heat-rise"></i>0.60–0.79</span>
+          <span><i class="heat-mid"></i>0.40–0.59</span>
+          <span><i class="heat-low"></i>0.20–0.39</span>
+          <span><i class="heat-cold"></i>0–0.19</span>
+          <span><i class="heat-missing"></i>无数据</span>
+        </div>
+        <label class="search-box">
+          <span>⌕</span>
+          <input id="global-search" type="search" placeholder="搜索分类节点或需求">
+        </label>
+        <div class="view-actions">
+          <button class="global-button" id="parent-view" disabled>上一级</button>
+          <button class="global-button" id="reset-view">回到全局</button>
+        </div>
+      </div>
+
+      <div class="depth-axis" id="depth-axis"></div>
+      <nav class="breadcrumb-bar" id="breadcrumb-bar" aria-label="当前节点祖先路径"></nav>
+      <div class="icicle-viewport" id="icicle-viewport">
+        <div class="icicle-wrap" id="icicle-wrap">
+          <canvas id="icicle-canvas" aria-label="全局分类树热力图"></canvas>
+          <div class="map-loading" id="map-loading">正在计算全局热力地形…</div>
+          <div class="map-tooltip" id="map-tooltip"></div>
+        </div>
+        <div class="map-guide">
+          <strong>阅读方式</strong>
+          <span>先看红橙热点在全树中的位置,再与蓝白冷区比较。</span>
+          <span>滚轮/单指上下浏览,Ctrl/⌘+滚轮或双指缩放,点击节点聚焦分支。</span>
+        </div>
+        <div class="zoom-controls">
+          <button id="zoom-out" title="缩小">−</button>
+          <button id="fit-view" title="适配当前视图">适配</button>
+          <button id="zoom-in" title="放大">+</button>
+        </div>
+      </div>
+
+      <div class="map-summary" id="map-summary"></div>
+    </section>
+
+    <aside class="demand-panel">
+      <div class="demand-panel-head">
+        <div>
+          <h2>系统形成的需求</h2>
+          <p id="demand-context">从全树自动识别,不要求逐节点点击。</p>
+        </div>
+        <strong id="demand-count">—</strong>
+      </div>
+      <div class="demand-filters" id="demand-filters">
+        <button class="active" data-granularity="all">全部</button>
+        <button data-granularity="宽泛">宽泛</button>
+        <button data-granularity="主题级">主题级</button>
+        <button data-granularity="具体">具体</button>
+        <button data-granularity="长尾">长尾</button>
+      </div>
+      <div class="demand-attachments" id="demand-attachments"></div>
+      <div class="demand-list" id="demand-list">
+        <div class="empty-state">正在从上游元素和分类节点中识别需求…</div>
+      </div>
+    </aside>
+  </section>
+
+  <section class="explanation-panel" id="explanation-panel">
+    <div class="explanation-empty">选择热力图节点或右侧需求,查看路径、多个挂靠点、数据事实和模型 Reason。</div>
+  </section>
+</main>
+
+<script src="/pages/global-tree-analysis.js"></script>

+ 7 - 0
visualization/service/serve.py

@@ -17,6 +17,11 @@ VISUALIZATION_DIR = Path(__file__).resolve().parents[1]
 PAGES_DIR = VISUALIZATION_DIR / "pages"
 
 PAGES: tuple[tuple[str, str, str], ...] = (
+    (
+        "全局分类树-需求热度与关系分析.html",
+        "00 · 平台全局需求地图",
+        "用全量冰柱热力图呈现冷热分布,并从上游数据直接形成数百条平台需求和可追溯 Reason。",
+    ),
     (
         "graph-structure-options.html",
         "01 · 图结构方案比较",
@@ -163,6 +168,7 @@ def _document(title: str, body: str, *, subtitle: str = "") -> bytes:
 <head>
   <meta charset="utf-8">
   <meta name="viewport" content="width=device-width, initial-scale=1">
+  <link rel="icon" href="data:,">
   <title>{html.escape(title)} · SupplyAgent</title>
   <style>{BASE_CSS}</style>
 </head>
@@ -210,6 +216,7 @@ def _gallery() -> bytes:
   <a href="/assets/tree.jpeg" target="_blank" rel="noreferrer"><img src="/assets/tree.jpeg" alt="全局分类树局部参考图"></a>
   <a href="/assets/tree2.png" target="_blank" rel="noreferrer"><img src="/assets/tree2.png" alt="分类节点下挂元素参考图"></a>
   <a href="/assets/case.jpeg" target="_blank" rel="noreferrer"><img src="/assets/case.jpeg" alt="元素扩展为语言和讨论参考图"></a>
+  <a href="/assets/liubai.png" target="_blank" rel="noreferrer"><img src="/assets/liubai.png" alt="全局需求地图留白调整参考图"></a>
 </section>
 """
     return _document("可视化目录", body, subtitle="本地设计资料")

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 549 - 0
visualization/全局分类树.html


+ 166 - 0
visualization/全局需求热力图实施计划.md

@@ -0,0 +1,166 @@
+# 全局需求热力图实施计划
+
+> **执行方式:** 当前会话内逐项实现和验证,不修改 `visualization/` 之外的文件。
+
+**目标:** 将 00 页面改造成读取真实全局树数据的分层冰柱热力图,并批量输出具有外部搜索意义的需求、0~1 热度 Score、可信度、粒度和可追溯 Reason。
+
+**架构:** 保留 `全局分类树.html` 作为唯一数据源。浏览器端解析其 `export-data`,分别完成数据标准化、子树布局、热度计算、需求识别、Reason 生成和交互渲染。主图使用 Canvas 绘制全量冰柱色块,右侧使用 HTML 输出需求结果。
+
+**技术栈:** 原生 HTML、CSS、JavaScript、Canvas 2D、本地 Python 静态服务。
+
+## 全局约束
+
+- 所有项目文件操作仅限 `visualization/`。
+- 不修改原始 `visualization/全局分类树.html`。
+- 后验指标保持“真实 ROV / 真实 VOV”名称。
+- 不展示 P/H 或百分位术语。
+- 热度 Score 和可信度统一为 `0~1`。
+- 数据完全缺失与 Score 0 必须区分。
+- 宽泛、主题级、具体、长尾只描述需求粒度,不评价好坏。
+- “战争”等具有外部搜索意义的宽泛词是需求。
+- “表象”等只承担分类结构作用、无法独立表达搜索意图的词不是需求。
+- Reason 必须区分“数据事实”和“模型解释”。
+
+---
+
+### 任务 1:重构页面信息架构
+
+**文件:**
+
+- 修改:`visualization/pages/全局分类树-需求热度与关系分析.html`
+- 修改:`visualization/pages/global-tree-analysis.css`
+
+**产出:**
+
+- 顶部全局概览和单一热度图例。
+- 左侧全量冰柱热力图。
+- 右侧批量需求输出区。
+- 下方选中节点的路径、数据事实、模型 Reason 和多个挂靠点。
+
+**验证:**
+
+- 页面不出现“综合观察”“先验热点”“P80”“H80”等旧术语。
+- 首屏主视觉与 05 页面保持蓝色结构、橙色需求、紫色证据的视觉语言。
+
+### 任务 2:实现真实数据的 0~1 Score
+
+**文件:**
+
+- 修改:`visualization/pages/global-tree-analysis.js`
+
+**接口:**
+
+- `buildMetricModel(nodes)`:生成六项指标的稳健归一化参数。
+- `computeNodeScores(node, metricModel)`:返回 `{ heatScore, confidence, dataState, priorScore, posteriorScore }`。
+- `computeSubtreeScore(node)`:基于自身和子树信号生成全局展示 Score。
+
+**规则:**
+
+- 六项原始指标先使用 `log1p` 抑制极端值,再按全局 95% 分位截断并归一化到 `0~1`。
+- 四项先验保留独立贡献;真实 ROV / VOV 作为后验校正。
+- 缺少真实 ROV / VOV 时降低可信度,但不把需求判为 0。
+- 六项指标全部缺失或为 0 时标记 `dataState: "missing"`。
+
+**验证:**
+
+- 所有非缺失 Score 位于 `[0, 1]`。
+- “真实 ROV / VOV”有样本的节点比同等先验但无样本的节点具有更高可信度。
+
+### 任务 3:实现需求识别和粒度判断
+
+**文件:**
+
+- 修改:`visualization/pages/global-tree-analysis.js`
+
+**接口:**
+
+- `classifySearchIntent(candidate)`:返回 `{ isDemand, granularity, reasonCode }`。
+- `extractDemandCandidates(nodes, elements)`:从分类节点和元素生成候选需求。
+- `mergeDemandCandidates(candidates)`:合并同名需求并保留多个挂靠点。
+
+**规则:**
+
+- 优先采用上游元素名称,其次采用能够独立表达搜索意义的分类节点名称。
+- 分类根词、结构词和孤立平台动作词进入排除词表。
+- “战争”保留为宽泛需求。
+- 同名需求合并为一个需求实体,并保留所有分类路径。
+- 粒度通过词语范围、路径深度和对象/事件限定程度判断为宽泛、主题级、具体或长尾。
+
+**验证:**
+
+- “战争”输出为宽泛需求。
+- “家庭清洁”输出为主题级或具体需求。
+- “驱蚊技巧”即使位于冷区也输出为探索需求。
+- “表象”不进入需求结果。
+
+### 任务 4:实现数据驱动的 Reason
+
+**文件:**
+
+- 修改:`visualization/pages/global-tree-analysis.js`
+
+**接口:**
+
+- `buildFactReason(demand)`:只描述上游可验证事实。
+- `buildModelReason(demand)`:描述搜索意义、粒度、数据缺口和判断结论。
+
+**规则:**
+
+- 数据事实列出挂靠路径、主要热度来源、真实 ROV / VOV 覆盖、元素原始 reason。
+- 模型解释不得声称上游没有提供的事实。
+- 数据缺失必须表述为“待验证”或“数据覆盖不足”,不能表述为负反馈。
+
+**验证:**
+
+- 每条需求同时具有 `factReason` 和 `modelReason`。
+- 页面对两类 Reason 使用不同标题和颜色。
+
+### 任务 5:实现全量冰柱布局与交互
+
+**文件:**
+
+- 修改:`visualization/pages/global-tree-analysis.js`
+- 修改:`visualization/pages/global-tree-analysis.css`
+
+**接口:**
+
+- `layoutIcicle(roots)`:按子树叶节点数量分配纵向面积,按深度分配横向列。
+- `drawIcicle()`:一次绘制全部节点色块。
+- `focusBranch(node)`:点击节点后放大该分支。
+- `resetGlobalView()`:回到完整全局视图。
+
+**交互:**
+
+- 全局态始终保留冷区。
+- 悬停显示名称、Score、可信度和数据状态。
+- 点击放大分支并同步筛选右侧需求。
+- 搜索词可以定位分类节点或需求。
+
+**验证:**
+
+- 2205 个节点全部拥有布局坐标。
+- 全局截图中同时可见红橙热点和蓝白冷区。
+- 不绘制全量父子曲线。
+
+### 任务 6:接入服务并完成浏览器验证
+
+**文件:**
+
+- 修改:`visualization/README.md`
+- 保持:`visualization/service/serve.py` 中 00 页面入口。
+
+**验证命令:**
+
+```bash
+python3 -m py_compile visualization/service/serve.py
+node --check visualization/pages/global-tree-analysis.js
+curl -fsS http://localhost:8765/pages/global-tree-analysis.js >/dev/null
+```
+
+使用本地 Chrome 无头渲染桌面截图,并检查:
+
+- 2205 个分类节点和 3008 条元素成功加载。
+- 页面无 P/H、百分位和旧模式按钮。
+- 页面出现热度 Score、可信度、宽泛需求、数据事实和模型 Reason。
+- “战争”进入需求结果,“表象”只保留为分类节点。
+- 全局态热区与冷区同时清晰可见。

+ 80 - 0
visualization/全局需求热力图设计说明.md

@@ -0,0 +1,80 @@
+# 全局需求热力图设计说明
+
+## 目标
+
+从全局分类树中持续识别并输出大量具有外部搜索意义的平台需求。页面首先回答“全局哪里热、哪里冷”,同时直接给出需求结果;点击和下钻只用于追溯,不是产生需求的前置操作。
+
+## 全局图
+
+采用从左到右的分层冰柱热力图:
+
+- 横向表示树的层级。
+- 纵向完整容纳各个分支。
+- 节点面积表示该节点所覆盖的子树规模。
+- 节点颜色表示 `0~1` 的热度 Score。
+- 冷区和暂无数据区域必须保留,作为全局比较基线。
+- 全局状态不强制显示全部节点名称;悬停和下钻后再显示名称、关系和详细证据。
+- 不为 2205 个节点绘制大量显式曲线,避免视觉噪声。
+- 全局适配只是初始视野,不锁死画布尺寸;普通滚轮和单指负责浏览纵向长树,`Ctrl/⌘ + 滚轮`、双指和按钮负责缩放。
+- 横轴选择层级时只隐藏更上层列,不把节点平铺成列表;被选层级仍作为多棵子树的根,后续父子层级继续向右展开。
+- 从需求挂靠点定位时保留祖先语境;需求详情中的完整路径节点可继续切换到任一父节点,并展示该父节点的整棵子树。
+- 横轴层级可以被选择为新的观察起点。例如选择三级后,根、一、二级不再占据画布宽度。
+- 点击任意节点都提供完整祖先路径、“上一级”入口和同父级兄弟节点对比,不针对某个示例节点硬编码。
+
+## 指标表达
+
+页面不使用 P/H、百分位等技术表达,只展示:
+
+- 热度 Score:`0~1`,综合四项先验与真实 ROV / VOV。
+- 判断可信度:`0~1`,表达数据覆盖和多项证据的一致程度。
+- 数据完全缺失必须单独标记,不得直接等同于热度 0。
+
+颜色建议:
+
+- `0.80~1.00`:高热。
+- `0.60~0.79`:升温。
+- `0.40~0.59`:绿色,中等。
+- `0.20~0.39`:深蓝色,偏冷。
+- `0~0.19`:浅蓝色,冷区。
+- 数据完全缺失:灰色。
+
+## 需求定义
+
+需求是可以在外部平台表达真实搜索意图的词或语言:
+
+- “战争”是宽泛需求,范围较大不代表不是需求。
+- “抗日战争”是主题级需求。
+- “毛泽东在抗日战争中的经历”是具体需求。
+- “表象”是分类结构词,不是需求。
+- “转发”单独出现通常是孤立动作,不是需求。
+- “视频怎么转发给朋友”具有完整问题语义,是需求。
+
+需求粒度分为宽泛、主题级、具体、长尾。粒度只描述范围,不评价需求好坏。
+
+## 需求判断
+
+需求是否成立不能由固定维度组合机械决定:
+
+- 某个维度缺失,可能代表数据空白,不等于需求不存在。
+- 热度很高但缺少独立搜索意义的结构词,不形成需求。
+- 热度较低但搜索意图明确的词,可以成为需求或探索需求。
+- 宽泛词可以成为父级需求,并继续下挂具体子需求。
+- 需求可以拥有多个树上挂靠点。
+
+## Reason
+
+每条需求都输出两类说明:
+
+- 数据事实:上游分类位置、四项先验、真实 ROV / VOV、元素及原始 reason。
+- 模型解释:为什么该词具有搜索意义、为什么采用当前粒度、数据缺失如何处理、为什么形成或不形成需求。
+
+模型解释必须明确标识,不能伪装成上游原始数据。
+
+## 页面结构
+
+1. 顶部:全局概览、热度图例和数据覆盖说明。
+2. 主区域左侧:完整分层冰柱热力图。
+3. 主区域右侧:系统批量生成的需求清单。
+4. 下钻状态:放大选中分支,展示子需求、多个挂靠点、数据事实和模型 Reason。
+
+视觉语言参考现有“05 · 最终输出结构”:蓝色主框架、橙色需求结果、紫色证据说明、充足留白和少量必要关系线。

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio