global-tree-analysis.js 46 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114
  1. (() => {
  2. const SOURCE_URL = "/全局分类树.html";
  3. const DIMENSIONS = [
  4. { key: "ext_pop", label: "外部热度", group: "prior", weight: .30 },
  5. { key: "plat_sust_pop", label: "平台持续热度", group: "prior", weight: .23 },
  6. { key: "plat_ly_pop", label: "去年同期热度", group: "prior", weight: .17 },
  7. { key: "recent_pop", label: "近期热度", group: "prior", weight: .30 },
  8. { key: "real_rov_7d", label: "真实 ROV", group: "posterior", weight: .50 },
  9. { key: "real_vov_7d", label: "真实 VOV", group: "posterior", weight: .50 }
  10. ];
  11. const STRUCTURE_ONLY = new Set([
  12. "表象", "理念", "实体", "行为", "知识", "现象", "事件", "观念",
  13. "内容形态", "场景", "声音", "符号", "视觉", "对象", "分类", "其他",
  14. "个体", "方法", "概念", "社会", "个人", "事物", "属性", "关系"
  15. ]);
  16. const ISOLATED_ACTIONS = new Set([
  17. "转发", "点赞", "评论", "收藏", "关注", "播放", "浏览", "点击", "分享",
  18. "上传", "下载", "发布", "互动", "推荐"
  19. ]);
  20. const BROAD_DEMANDS = new Set([
  21. "战争", "历史", "人物", "诗词", "音乐", "电影", "健康", "教育", "科技",
  22. "美食", "旅游", "体育", "情感", "家庭", "生活", "文化", "军事", "政治"
  23. ]);
  24. const state = {
  25. roots: [],
  26. flat: [],
  27. byId: new Map(),
  28. elementsByCategory: {},
  29. metricModel: {},
  30. candidateCount: 0,
  31. demands: [],
  32. demandById: new Map(),
  33. focus: null,
  34. startDepth: 0,
  35. maxDepth: 0,
  36. selectedNode: null,
  37. selectedDemand: null,
  38. granularity: "all",
  39. query: "",
  40. canvas: null,
  41. ctx: null,
  42. dpr: 1,
  43. baseRects: [],
  44. rects: [],
  45. view: { scale: 1, x: 0, y: 0 },
  46. pointers: new Map(),
  47. drag: { active: false, moved: false, x: 0, y: 0, pinchDistance: 0 },
  48. resizeObserver: null
  49. };
  50. const esc = value => String(value ?? "").replace(/[&<>"']/g, char => (
  51. { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]
  52. ));
  53. const clamp01 = value => Math.max(0, Math.min(1, Number(value) || 0));
  54. const formatScore = value => value == null ? "无数据" : clamp01(value).toFixed(2);
  55. const rawNumber = value => Number(value) || 0;
  56. const formatRaw = value => rawNumber(value).toLocaleString("zh-CN", { maximumFractionDigits: 4 });
  57. const normalizeName = name => String(name || "").replace(/\s+/g, "").trim();
  58. function quantile(sorted, q) {
  59. if (!sorted.length) return 0;
  60. const position = (sorted.length - 1) * q;
  61. const base = Math.floor(position);
  62. const rest = position - base;
  63. return sorted[base + 1] == null
  64. ? sorted[base]
  65. : sorted[base] + rest * (sorted[base + 1] - sorted[base]);
  66. }
  67. function buildMetricModel(nodes) {
  68. const model = {};
  69. for (const dimension of DIMENSIONS) {
  70. const values = nodes
  71. .map(node => rawNumber(node.weights?.[dimension.key]))
  72. .filter(value => value > 0)
  73. .map(value => Math.log1p(value))
  74. .sort((a, b) => a - b);
  75. model[dimension.key] = {
  76. cap: quantile(values, .95) || Math.max(...values, 1),
  77. nonZeroCount: values.length
  78. };
  79. }
  80. return model;
  81. }
  82. function normalizedMetric(key, raw) {
  83. const value = rawNumber(raw);
  84. if (value <= 0) return null;
  85. const cap = state.metricModel[key]?.cap || 1;
  86. return clamp01(Math.log1p(value) / cap);
  87. }
  88. function weightedGroup(node, group) {
  89. const dimensions = DIMENSIONS.filter(item => item.group === group);
  90. let weighted = 0;
  91. let weight = 0;
  92. let covered = 0;
  93. for (const dimension of dimensions) {
  94. const score = normalizedMetric(dimension.key, node.weights?.[dimension.key]);
  95. if (score == null) continue;
  96. weighted += score * dimension.weight;
  97. weight += dimension.weight;
  98. covered += 1;
  99. }
  100. return {
  101. score: weight ? weighted / weight : null,
  102. coverage: covered / dimensions.length,
  103. count: covered
  104. };
  105. }
  106. function computeNodeScores(node) {
  107. const prior = weightedGroup(node, "prior");
  108. const posterior = weightedGroup(node, "posterior");
  109. const hasAnyData = prior.count + posterior.count > 0;
  110. let heatScore = null;
  111. if (prior.score != null && posterior.score != null) {
  112. heatScore = prior.score * .58 + posterior.score * .42;
  113. } else if (prior.score != null) {
  114. heatScore = prior.score;
  115. } else if (posterior.score != null) {
  116. heatScore = posterior.score;
  117. }
  118. const dimensionCoverage = (prior.count + posterior.count) / DIMENSIONS.length;
  119. // 单一维度很强仍然是有效信号,但不应与多维共同升温显示成同样热。
  120. // 覆盖度只校正“热度”,不参与是否为需求的判断。
  121. if (heatScore != null) {
  122. heatScore *= .50 + dimensionCoverage * .50;
  123. }
  124. const agreement = prior.score != null && posterior.score != null
  125. ? 1 - Math.min(1, Math.abs(prior.score - posterior.score))
  126. : .46;
  127. const confidence = hasAnyData
  128. ? clamp01(dimensionCoverage * .62 + agreement * .25 + (node.elements.length ? .13 : 0))
  129. : clamp01(node.elements.length ? .18 : 0);
  130. node.score = {
  131. heatScore: heatScore == null ? null : clamp01(heatScore),
  132. confidence,
  133. dataState: hasAnyData ? "observed" : "missing",
  134. priorScore: prior.score,
  135. posteriorScore: posterior.score,
  136. priorCoverage: prior.coverage,
  137. posteriorCoverage: posterior.coverage
  138. };
  139. }
  140. function flatten(nodes, parent = null, path = []) {
  141. for (const node of nodes || []) {
  142. node.parent = parent;
  143. node.path = [...path, node.name];
  144. node.children = node.children || [];
  145. node.elements = state.elementsByCategory[String(node.id)] || [];
  146. state.flat.push(node);
  147. state.maxDepth = Math.max(state.maxDepth, node.path.length - 1);
  148. state.byId.set(String(node.id), node);
  149. flatten(node.children, node, node.path);
  150. }
  151. }
  152. function computeSubtreeScore(node) {
  153. node.leafCount = node.children.length
  154. ? node.children.reduce((sum, child) => sum + computeSubtreeScore(child).leafCount, 0)
  155. : 1;
  156. const childScores = node.children
  157. .map(child => child.displayScore)
  158. .filter(value => value != null)
  159. .sort((a, b) => b - a);
  160. const strongChildren = childScores.slice(0, Math.max(1, Math.ceil(childScores.length * .25)));
  161. const childSignal = strongChildren.length
  162. ? strongChildren.reduce((sum, value) => sum + value, 0) / strongChildren.length
  163. : null;
  164. if (node.score.heatScore != null && childSignal != null) {
  165. node.displayScore = clamp01(node.score.heatScore * .68 + childSignal * .32);
  166. } else {
  167. node.displayScore = node.score.heatScore ?? (childSignal == null ? null : childSignal * .78);
  168. }
  169. return node;
  170. }
  171. function classifySearchIntent(name, sourceType, node) {
  172. const normalized = normalizeName(name);
  173. if (!normalized || normalized.length < 2) {
  174. return { isDemand: false, granularity: null, reasonCode: "too_short" };
  175. }
  176. if (STRUCTURE_ONLY.has(normalized)) {
  177. return { isDemand: false, granularity: null, reasonCode: "structure_only" };
  178. }
  179. if (ISOLATED_ACTIONS.has(normalized)) {
  180. return { isDemand: false, granularity: null, reasonCode: "isolated_action" };
  181. }
  182. if (/^(其他|相关|类别|类型|内容|信息|方面|部分|综合|未知)/.test(normalized)) {
  183. return { isDemand: false, granularity: null, reasonCode: "generic_structure" };
  184. }
  185. let granularity;
  186. if (BROAD_DEMANDS.has(normalized) || normalized.length <= 2) granularity = "宽泛";
  187. else if (normalized.length <= 5) granularity = "主题级";
  188. else if (normalized.length <= 10) granularity = "具体";
  189. else granularity = "长尾";
  190. if (sourceType === "node" && node && node.path.length <= 2 && normalized.length <= 3) {
  191. return { isDemand: false, granularity: null, reasonCode: "upper_structure" };
  192. }
  193. return { isDemand: true, granularity, reasonCode: "searchable" };
  194. }
  195. function inheritedHeat(node) {
  196. if (node.score.heatScore != null) return node.score.heatScore;
  197. let current = node.parent;
  198. let decay = .64;
  199. while (current) {
  200. if (current.score.heatScore != null) return clamp01(current.score.heatScore * decay);
  201. current = current.parent;
  202. decay *= .72;
  203. }
  204. return null;
  205. }
  206. function createCandidate(name, node, sourceType, rawReason = "") {
  207. const intent = classifySearchIntent(name, sourceType, node);
  208. if (!intent.isDemand) return null;
  209. return {
  210. name: normalizeName(name),
  211. sourceType,
  212. granularity: intent.granularity,
  213. attachment: {
  214. nodeId: String(node.id),
  215. nodeName: node.name,
  216. path: node.path.join(" / "),
  217. heatScore: inheritedHeat(node),
  218. ownHeatScore: node.score.heatScore,
  219. confidence: node.score.confidence,
  220. dataState: node.score.dataState,
  221. weights: node.weights || {},
  222. rawReason: rawReason || "",
  223. elementName: sourceType === "element" ? name : ""
  224. }
  225. };
  226. }
  227. function extractDemandCandidates() {
  228. const candidates = [];
  229. for (const node of state.flat) {
  230. for (const element of node.elements) {
  231. const candidate = createCandidate(element.name, node, "element", element.reason);
  232. if (candidate) candidates.push(candidate);
  233. }
  234. const nodeCandidate = createCandidate(node.name, node, "node", node.description || "");
  235. if (nodeCandidate) candidates.push(nodeCandidate);
  236. }
  237. return candidates;
  238. }
  239. function mergeDemandCandidates(candidates) {
  240. const merged = new Map();
  241. for (const candidate of candidates) {
  242. const key = candidate.name.toLowerCase();
  243. if (!merged.has(key)) {
  244. merged.set(key, {
  245. id: `demand-${merged.size + 1}`,
  246. name: candidate.name,
  247. granularity: candidate.granularity,
  248. attachments: [],
  249. sourceTypes: new Set()
  250. });
  251. }
  252. const demand = merged.get(key);
  253. demand.sourceTypes.add(candidate.sourceType);
  254. const existingAttachment = demand.attachments.find(item =>
  255. item.nodeId === candidate.attachment.nodeId
  256. );
  257. if (!existingAttachment) {
  258. demand.attachments.push(candidate.attachment);
  259. } else if (
  260. candidate.attachment.rawReason &&
  261. !existingAttachment.rawReason.includes(candidate.attachment.rawReason)
  262. ) {
  263. existingAttachment.rawReason = existingAttachment.rawReason
  264. ? `${existingAttachment.rawReason};${candidate.attachment.rawReason}`
  265. : candidate.attachment.rawReason;
  266. }
  267. if (candidate.granularity === "长尾") demand.granularity = "长尾";
  268. else if (candidate.granularity === "具体" && demand.granularity !== "长尾") demand.granularity = "具体";
  269. }
  270. const results = [...merged.values()];
  271. for (const demand of results) {
  272. const heatValues = demand.attachments.map(item => item.heatScore).filter(value => value != null);
  273. const confidenceValues = demand.attachments.map(item => item.confidence).filter(value => value != null);
  274. demand.heatScore = heatValues.length ? Math.max(...heatValues) : null;
  275. demand.confidence = confidenceValues.length
  276. ? clamp01(confidenceValues.reduce((sum, value) => sum + value, 0) / confidenceValues.length)
  277. : 0;
  278. demand.hasObservedData = demand.attachments.some(item => item.dataState === "observed");
  279. demand.priority = (demand.heatScore ?? .08) * .56
  280. + demand.confidence * .24
  281. + Math.min(1, demand.attachments.length / 4) * .12
  282. + (demand.sourceTypes.has("element") ? .08 : 0);
  283. demand.factReason = buildFactReason(demand);
  284. demand.modelReason = buildModelReason(demand);
  285. }
  286. return results.sort((a, b) => b.priority - a.priority || b.attachments.length - a.attachments.length);
  287. }
  288. function selectDemandPortfolio(candidates) {
  289. // 输出规模保持在“数百条”。主队列由多维数据支持,探索队列专门保留
  290. // 冷区或数据缺失但具备明确搜索意义的需求,避免热度阈值一刀切。
  291. const supported = candidates.filter(demand =>
  292. demand.hasObservedData && (demand.heatScore ?? 0) >= .12
  293. );
  294. const exploration = candidates.filter(demand =>
  295. !demand.hasObservedData || (demand.heatScore ?? 0) < .12
  296. );
  297. const selected = new Map();
  298. supported.slice(0, 650).forEach(demand => selected.set(demand.name.toLowerCase(), demand));
  299. exploration
  300. .sort((a, b) => {
  301. const aSpecific = a.sourceTypes.has("element") ? 1 : 0;
  302. const bSpecific = b.sourceTypes.has("element") ? 1 : 0;
  303. return bSpecific - aSpecific || b.priority - a.priority;
  304. })
  305. .slice(0, 250)
  306. .forEach(demand => selected.set(demand.name.toLowerCase(), demand));
  307. // 用户明确确认的宽泛需求必须保留,不能因为配额或粒度被过滤。
  308. for (const demand of candidates) {
  309. if (BROAD_DEMANDS.has(demand.name)) selected.set(demand.name.toLowerCase(), demand);
  310. }
  311. return [...selected.values()].sort((a, b) => b.priority - a.priority);
  312. }
  313. function strongestDimensions(attachment) {
  314. return DIMENSIONS
  315. .map(dimension => ({
  316. label: dimension.label,
  317. raw: rawNumber(attachment.weights?.[dimension.key]),
  318. normalized: normalizedMetric(dimension.key, attachment.weights?.[dimension.key])
  319. }))
  320. .filter(item => item.normalized != null)
  321. .sort((a, b) => b.normalized - a.normalized)
  322. .slice(0, 2);
  323. }
  324. function buildFactReason(demand) {
  325. const primary = demand.attachments
  326. .slice()
  327. .sort((a, b) => (b.heatScore ?? -1) - (a.heatScore ?? -1))[0];
  328. const dimensions = strongestDimensions(primary);
  329. const signals = dimensions.length
  330. ? dimensions.map(item => `${item.label} ${formatRaw(item.raw)}`).join("、")
  331. : "六项表现数据当前缺失";
  332. const posterior = rawNumber(primary.weights.real_rov_7d) > 0 || rawNumber(primary.weights.real_vov_7d) > 0
  333. ? `真实 ROV ${formatRaw(primary.weights.real_rov_7d)}、真实 VOV ${formatRaw(primary.weights.real_vov_7d)}`
  334. : "真实 ROV / VOV 暂无样本";
  335. const rawReason = demand.attachments.find(item => item.rawReason)?.rawReason;
  336. return `主要挂靠“${primary.path}”;${signals};${posterior}`
  337. + (rawReason ? `;上游原始 reason:“${rawReason}”` : "。");
  338. }
  339. function buildModelReason(demand) {
  340. const heat = demand.heatScore;
  341. const dataPhrase = heat == null
  342. ? "当前表现数据不足,热度需要后续验证"
  343. : heat >= .8 ? "当前处于高热区域"
  344. : heat >= .6 ? "当前处于升温区域"
  345. : heat >= .4 ? "当前热度中等"
  346. : heat >= .2 ? "当前偏冷但仍具有搜索意义"
  347. : "当前位于冷区,但冷区不等于无需求";
  348. const breadth = {
  349. 宽泛: "它能够独立表达外部搜索意图,范围较大,因此作为父级宽泛需求保留,并继续下挂更具体需求",
  350. 主题级: "它能够独立表达明确主题,可承接多个具体内容方向",
  351. 具体: "它表达了较明确的搜索对象或问题,可以直接用于内容供给",
  352. 长尾: "它包含较完整的限定条件,适合作为长尾搜索需求"
  353. }[demand.granularity];
  354. const relation = demand.attachments.length > 1
  355. ? `;该需求具有 ${demand.attachments.length} 个挂靠点,说明它跨越多个分类语义`
  356. : "";
  357. return `${breadth}。${dataPhrase}${relation}。`;
  358. }
  359. function prepareData(data) {
  360. state.roots = data.nodes || [];
  361. state.elementsByCategory = data.demandsByCategory || {};
  362. flatten(state.roots);
  363. state.metricModel = buildMetricModel(state.flat);
  364. state.flat.forEach(computeNodeScores);
  365. state.roots.forEach(computeSubtreeScore);
  366. const candidates = mergeDemandCandidates(extractDemandCandidates());
  367. state.candidateCount = candidates.length;
  368. state.demands = selectDemandPortfolio(candidates);
  369. state.demands.forEach(demand => state.demandById.set(demand.id, demand));
  370. }
  371. function heatColor(score, dataState) {
  372. if (dataState === "missing" && score == null) return "#f1f5f9";
  373. if (score == null) return "#f1f5f9";
  374. if (score >= .8) return "#ef4444";
  375. if (score >= .6) return "#f59e0b";
  376. if (score >= .4) return "#22a06b";
  377. if (score >= .2) return "#2563eb";
  378. return "#bfdbfe";
  379. }
  380. function textColor(score) {
  381. return score == null || score < .2 ? "#475569" : "#ffffff";
  382. }
  383. function descendantsAtDepth(roots, targetDepth) {
  384. const results = [];
  385. const walk = node => {
  386. const depth = node.path.length - 1;
  387. if (depth === targetDepth) {
  388. results.push(node);
  389. return;
  390. }
  391. if (depth < targetDepth) node.children.forEach(walk);
  392. };
  393. roots.forEach(walk);
  394. return results;
  395. }
  396. function activeBaseDepth() {
  397. const focusDepth = state.focus ? state.focus.path.length - 1 : 0;
  398. return Math.max(focusDepth, state.startDepth);
  399. }
  400. function viewRoots() {
  401. const scopeRoots = state.focus ? [state.focus] : state.roots;
  402. const baseDepth = activeBaseDepth();
  403. const roots = descendantsAtDepth(scopeRoots, baseDepth);
  404. return roots.length ? roots : scopeRoots;
  405. }
  406. function relativeMaxDepth(nodes, baseDepth) {
  407. let maximum = 0;
  408. const walk = node => {
  409. maximum = Math.max(maximum, node.path.length - 1 - baseDepth);
  410. node.children.forEach(walk);
  411. };
  412. nodes.forEach(walk);
  413. return maximum;
  414. }
  415. function layoutIcicle() {
  416. if (!state.canvas) return [];
  417. const rect = state.canvas.getBoundingClientRect();
  418. const roots = viewRoots();
  419. const baseDepth = activeBaseDepth();
  420. const maxDepth = relativeMaxDepth(roots, baseDepth);
  421. const columns = maxDepth + 1;
  422. const columnWidth = rect.width / columns;
  423. const totalLeaves = roots.reduce((sum, node) => sum + node.leafCount, 0);
  424. const rows = [];
  425. let yCursor = 0;
  426. const place = (node, y, height) => {
  427. const relativeDepth = node.path.length - 1 - baseDepth;
  428. rows.push({
  429. node,
  430. x: relativeDepth * columnWidth,
  431. y,
  432. width: columnWidth,
  433. height
  434. });
  435. let childY = y;
  436. for (const child of node.children) {
  437. const childHeight = height * (child.leafCount / node.leafCount);
  438. place(child, childY, childHeight);
  439. childY += childHeight;
  440. }
  441. };
  442. for (const root of roots) {
  443. const height = rect.height * (root.leafCount / totalLeaves);
  444. place(root, yCursor, height);
  445. yCursor += height;
  446. }
  447. renderDepthAxis(baseDepth);
  448. return rows;
  449. }
  450. function renderDepthAxis(baseDepth) {
  451. const axis = document.querySelector("#depth-axis");
  452. axis.style.gridTemplateColumns = `repeat(${state.maxDepth + 1}, minmax(38px, 1fr))`;
  453. axis.innerHTML = Array.from({ length: state.maxDepth + 1 }, (_, depth) => {
  454. return `<button class="${depth === baseDepth ? "active" : ""}" data-start-depth="${depth}">${depth === 0 ? "根" : `${depth} 级`}</button>`;
  455. }).join("");
  456. }
  457. function renderBreadcrumb() {
  458. const target = document.querySelector("#breadcrumb-bar");
  459. const pathNodes = [];
  460. let current = state.selectedNode || state.focus;
  461. while (current) {
  462. pathNodes.unshift(current);
  463. current = current.parent;
  464. }
  465. target.innerHTML = pathNodes.length
  466. ? pathNodes.map((node, index) => `
  467. ${index ? "<i>→</i>" : ""}
  468. <button class="${index === pathNodes.length - 1 ? "current" : ""}" data-breadcrumb-id="${esc(node.id)}">${esc(node.name)}</button>
  469. `).join("")
  470. : `<button class="current" data-reset-breadcrumb="true">全局树</button>`;
  471. const parentButton = document.querySelector("#parent-view");
  472. parentButton.disabled = !(state.selectedNode?.parent || state.focus?.parent);
  473. }
  474. function updateCanvasExtent() {
  475. const viewport = document.querySelector("#icicle-viewport");
  476. const wrap = document.querySelector("#icicle-wrap");
  477. const roots = viewRoots();
  478. const totalLeaves = roots.reduce((sum, node) => sum + node.leafCount, 0);
  479. const shouldExtend = state.startDepth > 0 || Boolean(state.focus);
  480. const minimumLeafHeight = state.focus ? 13 : 9;
  481. const desiredHeight = shouldExtend
  482. ? Math.max(viewport.clientHeight, Math.min(24000, totalLeaves * minimumLeafHeight))
  483. : viewport.clientHeight;
  484. wrap.style.height = `${Math.max(420, desiredHeight)}px`;
  485. }
  486. function resizeCanvas() {
  487. const canvas = document.querySelector("#icicle-canvas");
  488. updateCanvasExtent();
  489. const rect = canvas.getBoundingClientRect();
  490. state.canvas = canvas;
  491. state.ctx = canvas.getContext("2d");
  492. state.dpr = rect.height > 3000 ? 1 : Math.min(window.devicePixelRatio || 1, 2);
  493. canvas.width = Math.round(rect.width * state.dpr);
  494. canvas.height = Math.round(rect.height * state.dpr);
  495. state.ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
  496. fitView(false);
  497. drawIcicle();
  498. }
  499. function drawIcicle() {
  500. if (!state.ctx || !state.flat.length) return;
  501. const ctx = state.ctx;
  502. const rect = state.canvas.getBoundingClientRect();
  503. ctx.clearRect(0, 0, rect.width, rect.height);
  504. state.baseRects = layoutIcicle();
  505. state.rects = state.baseRects.map(item => ({
  506. ...item,
  507. x: item.x * state.view.scale + state.view.x,
  508. y: item.y * state.view.scale + state.view.y,
  509. width: item.width * state.view.scale,
  510. height: item.height * state.view.scale
  511. }));
  512. const query = state.query.toLowerCase();
  513. for (const item of state.rects) {
  514. const node = item.node;
  515. const score = node.displayScore;
  516. const selected = state.selectedNode === node;
  517. const matched = query && (
  518. node.name.toLowerCase().includes(query) ||
  519. node.elements.some(element => element.name.toLowerCase().includes(query))
  520. );
  521. ctx.fillStyle = heatColor(score, node.score.dataState);
  522. ctx.fillRect(item.x, item.y, item.width, item.height);
  523. ctx.strokeStyle = "rgba(255,255,255,.88)";
  524. ctx.lineWidth = .8;
  525. ctx.strokeRect(item.x, item.y, item.width, item.height);
  526. if (selected || matched) {
  527. ctx.strokeStyle = selected ? "#7c3aed" : "#1d4ed8";
  528. ctx.lineWidth = selected ? 3 : 2;
  529. ctx.strokeRect(item.x + 1.5, item.y + 1.5, Math.max(0, item.width - 3), Math.max(0, item.height - 3));
  530. }
  531. if (item.height >= 15 && item.width >= 54) {
  532. ctx.save();
  533. ctx.beginPath();
  534. ctx.rect(item.x + 2, item.y + 1, item.width - 4, item.height - 2);
  535. ctx.clip();
  536. ctx.fillStyle = textColor(score);
  537. ctx.font = `${item.height >= 30 ? 9 : 7}px Inter, "PingFang SC", sans-serif`;
  538. ctx.textBaseline = "top";
  539. ctx.fillText(node.name, item.x + 5, item.y + 4);
  540. if (item.height >= 31) {
  541. ctx.globalAlpha = .82;
  542. ctx.font = '7px Inter, "PingFang SC", sans-serif';
  543. ctx.fillText(score == null ? "无数据" : `Score ${formatScore(score)}`, item.x + 5, item.y + 17);
  544. }
  545. ctx.restore();
  546. }
  547. }
  548. document.querySelector("#map-loading").style.display = "none";
  549. document.body.dataset.viewScale = state.view.scale.toFixed(3);
  550. document.body.dataset.viewX = state.view.x.toFixed(1);
  551. document.body.dataset.viewY = state.view.y.toFixed(1);
  552. document.body.dataset.startDepth = String(activeBaseDepth());
  553. document.body.dataset.focusNode = state.focus?.name || "";
  554. document.body.dataset.selectedNode = state.selectedNode?.name || "";
  555. document.body.dataset.canvasHeight = String(Math.round(rect.height));
  556. renderBreadcrumb();
  557. }
  558. function fitView(redraw = true) {
  559. state.view = { scale: 1, x: 0, y: 0 };
  560. if (redraw) {
  561. const viewport = document.querySelector("#icicle-viewport");
  562. viewport.scrollTop = 0;
  563. viewport.scrollLeft = 0;
  564. drawIcicle();
  565. }
  566. }
  567. function zoomAt(factor, screenX, screenY) {
  568. const oldScale = state.view.scale;
  569. const nextScale = Math.max(1, Math.min(18, oldScale * factor));
  570. const worldX = (screenX - state.view.x) / oldScale;
  571. const worldY = (screenY - state.view.y) / oldScale;
  572. state.view.scale = nextScale;
  573. state.view.x = screenX - worldX * nextScale;
  574. state.view.y = screenY - worldY * nextScale;
  575. drawIcicle();
  576. }
  577. function nodeAt(clientX, clientY) {
  578. const rect = state.canvas.getBoundingClientRect();
  579. const x = clientX - rect.left;
  580. const y = clientY - rect.top;
  581. let match = null;
  582. for (const item of state.rects) {
  583. if (x >= item.x && x <= item.x + item.width && y >= item.y && y <= item.y + item.height) {
  584. if (!match || item.node.path.length > match.node.path.length) match = item;
  585. }
  586. }
  587. return match?.node || null;
  588. }
  589. function scrollSelectedNodeIntoView() {
  590. if (!state.selectedNode) return;
  591. const item = state.rects.find(rect => rect.node === state.selectedNode);
  592. if (!item) return;
  593. const viewport = document.querySelector("#icicle-viewport");
  594. viewport.scrollTop = Math.max(0, item.y - viewport.clientHeight * .38);
  595. viewport.scrollLeft = Math.max(0, item.x - viewport.clientWidth * .18);
  596. }
  597. function demandInFocus(demand) {
  598. if (!state.focus) return true;
  599. const focusPath = state.focus.path.join(" / ");
  600. return demand.attachments.some(item => item.path.startsWith(focusPath));
  601. }
  602. function filteredDemands() {
  603. const query = state.query.toLowerCase();
  604. return state.demands.filter(demand => {
  605. if (state.granularity !== "all" && demand.granularity !== state.granularity) return false;
  606. if (!demandInFocus(demand)) return false;
  607. if (!query) return true;
  608. return demand.name.toLowerCase().includes(query)
  609. || demand.attachments.some(item => item.path.toLowerCase().includes(query));
  610. });
  611. }
  612. function renderDemandList() {
  613. const demands = filteredDemands();
  614. document.querySelector("#demand-count").textContent = demands.length.toLocaleString("zh-CN");
  615. document.querySelector("#demand-context").textContent = state.focus
  616. ? `当前聚焦“${state.focus.name}”分支;点击回到全局可恢复全部结果。`
  617. : "从全树自动识别,不要求逐节点点击。";
  618. document.querySelector("#demand-list").innerHTML = demands.slice(0, 250).map(demand => `
  619. <article class="demand-card ${state.selectedDemand === demand ? "selected" : ""}" data-demand-id="${esc(demand.id)}">
  620. <div class="demand-card-head">
  621. <h3>${esc(demand.name)}</h3>
  622. <span class="granularity">${esc(demand.granularity)}需求</span>
  623. </div>
  624. <div class="score-row">
  625. <span>${demand.heatScore == null ? "热度待验证" : `热度 ${formatScore(demand.heatScore)}`}</span>
  626. <span>可信度 ${formatScore(demand.confidence)}</span>
  627. <span class="path-count">${demand.attachments.length} 个挂靠点</span>
  628. ${demand.hasObservedData ? "" : `<span class="missing-data">后验待验证</span>`}
  629. </div>
  630. <p class="card-reason"><strong>模型 Reason:</strong>${esc(demand.modelReason)}</p>
  631. </article>
  632. `).join("") || `<div class="empty-state">当前条件下没有匹配需求。冷区节点仍保留在左侧全局图中。</div>`;
  633. }
  634. function renderDemandAttachments(demand) {
  635. const target = document.querySelector("#demand-attachments");
  636. if (!demand) {
  637. target.className = "demand-attachments";
  638. target.innerHTML = "";
  639. return;
  640. }
  641. target.className = "demand-attachments visible";
  642. target.innerHTML = `
  643. <div class="demand-attachments-head">
  644. <strong>“${esc(demand.name)}”的完整挂靠路径</strong>
  645. <span>${demand.attachments.length} 个挂靠点 · 点击定位</span>
  646. </div>
  647. ${demand.attachments.map((attachment, index) => `
  648. <button class="attachment-button ${state.selectedNode && String(state.selectedNode.id) === attachment.nodeId ? "active" : ""}"
  649. data-attachment-demand="${esc(demand.id)}" data-attachment-node="${esc(attachment.nodeId)}">
  650. <b>${index + 1}</b>
  651. <span>${esc(attachment.path)}</span>
  652. <em>定位 →</em>
  653. </button>
  654. `).join("")}`;
  655. }
  656. function renderMapSummary() {
  657. const isScoped = Boolean(state.focus) || state.startDepth > 0;
  658. const scope = isScoped
  659. ? [...new Map(state.rects.map(item => [item.node.id, item.node])).values()]
  660. : state.flat;
  661. const observed = scope.filter(node => node.score.dataState === "observed");
  662. const hot = scope.filter(node => (node.displayScore ?? -1) >= .6);
  663. const cold = scope.filter(node => node.displayScore != null && node.displayScore < .2);
  664. const missing = scope.filter(node => node.score.dataState === "missing");
  665. document.querySelector("#map-summary").innerHTML = `
  666. <div><strong>${hot.length.toLocaleString("zh-CN")} 个升温或高热节点</strong><br>用于定位当前信号集中的分支。</div>
  667. <div><strong>${cold.length.toLocaleString("zh-CN")} 个冷区节点</strong><br>继续保留,形成全局比较基线。</div>
  668. <div><strong>${missing.length.toLocaleString("zh-CN")} 个无直接数据节点</strong><br>无数据不等于 Score 为 0,也不等于无需求。</div>`;
  669. document.querySelector("#map-context").textContent = state.focus
  670. ? `${state.focus.path.join(" / ")} · 保留父子层级的当前分支 · ${scope.length.toLocaleString("zh-CN")} 个节点`
  671. : state.startDepth > 0
  672. ? `从第 ${state.startDepth} 级开始 · 保留向下树结构 · ${scope.length.toLocaleString("zh-CN")} 个节点`
  673. : `全局视图 · ${observed.length.toLocaleString("zh-CN")} 个节点具有直接热度数据`;
  674. }
  675. function relatedDemandsForNode(node) {
  676. const path = node.path.join(" / ");
  677. return state.demands
  678. .filter(demand => demand.attachments.some(item => item.path === path || item.path.startsWith(`${path} /`)))
  679. .slice(0, 8);
  680. }
  681. function renderNodeExplanation(node) {
  682. const related = relatedDemandsForNode(node);
  683. const siblings = node.parent
  684. ? node.parent.children.filter(item => item !== node).slice(0, 10)
  685. : state.roots.filter(item => item !== node);
  686. const dimensions = DIMENSIONS.map(dimension => {
  687. const raw = rawNumber(node.weights?.[dimension.key]);
  688. return `${dimension.label}:${raw > 0 ? formatRaw(raw) : "无数据"}`;
  689. }).join(";");
  690. document.querySelector("#explanation-panel").innerHTML = `
  691. <article>
  692. <h3>分类节点</h3>
  693. <h2 class="demand-title">${esc(node.name)}</h2>
  694. <p>${esc(node.path.join(" / "))}</p>
  695. <div class="score-row">
  696. <span>${node.displayScore == null ? "热度无数据" : `热度 ${formatScore(node.displayScore)}`}</span>
  697. <span>可信度 ${formatScore(node.score.confidence)}</span>
  698. <span class="path-count">${node.leafCount} 个叶节点</span>
  699. </div>
  700. </article>
  701. <article class="fact-card">
  702. <h3>上游数据事实</h3>
  703. <h2>六维信号与元素</h2>
  704. <p>${esc(dimensions)}</p>
  705. <div class="attachment-list">${node.elements.slice(0, 8).map(element =>
  706. `<span><strong>${esc(element.name)}</strong>${element.reason ? ` · 原始 reason:${esc(element.reason)}` : ""}</span>`
  707. ).join("") || "<span>该节点没有直接下挂元素。</span>"}</div>
  708. </article>
  709. <article class="model-card">
  710. <h3>模型解释</h3>
  711. <h2>该节点如何参与需求形成</h2>
  712. <p>${node.score.dataState === "missing"
  713. ? "该节点没有直接表现数据,但仍保留在全局树中。系统会继续检查其名称是否具有搜索意义、是否存在具体元素,以及父子和跨树关系。"
  714. : "该节点的颜色反映综合热度;颜色不直接决定它是否为需求。系统还会独立判断名称能否表达外部搜索意图。"}</p>
  715. <div class="attachment-list">${related.map(demand =>
  716. `<span>${esc(demand.name)} · ${esc(demand.granularity)}需求 · 热度 ${formatScore(demand.heatScore)}</span>`
  717. ).join("") || "<span>当前没有从该节点及其子树识别出需求。</span>"}</div>
  718. ${siblings.length ? `<p class="raw-reason">同级对比:${siblings.map(item =>
  719. `${esc(item.name)} ${formatScore(item.displayScore)}`
  720. ).join(";")}</p>` : ""}
  721. </article>`;
  722. }
  723. function renderDemandExplanation(demand) {
  724. const selectedPathNode = state.selectedNode || state.byId.get(demand.attachments[0]?.nodeId);
  725. const pathNodes = [];
  726. let current = selectedPathNode;
  727. while (current) {
  728. pathNodes.unshift(current);
  729. current = current.parent;
  730. }
  731. document.querySelector("#explanation-panel").innerHTML = `
  732. <article>
  733. <h3>${esc(demand.granularity)}需求</h3>
  734. <h2 class="demand-title">${esc(demand.name)}</h2>
  735. <div class="score-row">
  736. <span>${demand.heatScore == null ? "热度待验证" : `热度 ${formatScore(demand.heatScore)}`}</span>
  737. <span>可信度 ${formatScore(demand.confidence)}</span>
  738. <span class="path-count">${demand.attachments.length} 个挂靠点</span>
  739. </div>
  740. <div class="attachment-list">${demand.attachments.slice(0, 12).map(item =>
  741. `<span>${esc(item.path)}</span>`
  742. ).join("")}</div>
  743. </article>
  744. <article class="fact-card">
  745. <h3>上游数据事实</h3>
  746. <h2>这个结论依赖了什么数据</h2>
  747. <p>${esc(demand.factReason)}</p>
  748. <div class="attachment-list">${demand.attachments.slice(0, 8).map(item => {
  749. const posterior = rawNumber(item.weights.real_rov_7d) > 0 || rawNumber(item.weights.real_vov_7d) > 0
  750. ? `真实 ROV ${formatRaw(item.weights.real_rov_7d)} / 真实 VOV ${formatRaw(item.weights.real_vov_7d)}`
  751. : "真实 ROV / VOV 待验证";
  752. return `<span>${esc(item.nodeName)} · ${esc(posterior)}</span>`;
  753. }).join("")}</div>
  754. </article>
  755. <article class="model-card">
  756. <h3>模型 Reason</h3>
  757. <h2>为什么它被识别为需求</h2>
  758. <p>${esc(demand.modelReason)}</p>
  759. <p class="raw-reason">说明:这里是基于名称、树路径、元素和六维数据生成的模型解释,不是上游原始 reason。</p>
  760. </article>
  761. <article class="path-context-card">
  762. <h3>完整路径节点信息</h3>
  763. <h2>沿路径查看父节点及其子树</h2>
  764. <div class="path-node-grid">${pathNodes.map((node, index) => `
  765. <button class="${node === selectedPathNode ? "active" : ""}" data-path-info-node="${esc(node.id)}">
  766. <b>${index + 1}</b>
  767. <span><strong>${esc(node.name)}</strong><small>热度 ${formatScore(node.displayScore)} · ${node.children.length} 个直接子节点 · ${node.elements.length} 条元素</small></span>
  768. <em>查看父级子树 →</em>
  769. </button>
  770. `).join("")}</div>
  771. </article>`;
  772. }
  773. function selectNode(node, focus = false) {
  774. state.selectedNode = node;
  775. state.selectedDemand = null;
  776. renderDemandAttachments(null);
  777. if (focus) {
  778. state.focus = node;
  779. state.startDepth = node.path.length - 1;
  780. }
  781. resizeCanvas();
  782. renderDemandList();
  783. renderMapSummary();
  784. renderNodeExplanation(node);
  785. }
  786. function selectDemand(demand) {
  787. state.selectedDemand = demand;
  788. const primary = demand.attachments[0];
  789. state.selectedNode = state.byId.get(primary.nodeId) || null;
  790. drawIcicle();
  791. renderDemandList();
  792. renderDemandAttachments(demand);
  793. renderDemandExplanation(demand);
  794. }
  795. function contextAncestor(node, levels = 3) {
  796. let current = node;
  797. for (let index = 0; index < levels && current.parent; index += 1) current = current.parent;
  798. return current;
  799. }
  800. function focusDemandAttachment(demand, nodeId) {
  801. const node = state.byId.get(String(nodeId));
  802. if (!node) return;
  803. const context = contextAncestor(node, 3);
  804. state.selectedDemand = demand;
  805. state.selectedNode = node;
  806. state.focus = context;
  807. state.startDepth = context.path.length - 1;
  808. resizeCanvas();
  809. scrollSelectedNodeIntoView();
  810. renderDemandList();
  811. renderDemandAttachments(demand);
  812. renderMapSummary();
  813. renderDemandExplanation(demand);
  814. }
  815. function focusDemandPathNode(demand, node) {
  816. state.selectedDemand = demand;
  817. state.selectedNode = node;
  818. state.focus = node;
  819. state.startDepth = node.path.length - 1;
  820. resizeCanvas();
  821. document.querySelector("#icicle-viewport").scrollTop = 0;
  822. renderDemandList();
  823. renderDemandAttachments(demand);
  824. renderMapSummary();
  825. renderDemandExplanation(demand);
  826. }
  827. function resetGlobalView() {
  828. state.focus = null;
  829. state.startDepth = 0;
  830. state.selectedNode = null;
  831. state.selectedDemand = null;
  832. renderDemandAttachments(null);
  833. resizeCanvas();
  834. renderDemandList();
  835. renderMapSummary();
  836. document.querySelector("#explanation-panel").innerHTML =
  837. `<div class="explanation-empty">选择热力图节点或右侧需求,查看路径、多个挂靠点、数据事实和模型 Reason。</div>`;
  838. }
  839. function bindEvents() {
  840. document.querySelector("#reset-view").addEventListener("click", resetGlobalView);
  841. document.querySelector("#fit-view").addEventListener("click", () => fitView(true));
  842. document.querySelector("#zoom-in").addEventListener("click", () => {
  843. const rect = state.canvas.getBoundingClientRect();
  844. zoomAt(1.35, rect.width / 2, rect.height / 2);
  845. });
  846. document.querySelector("#zoom-out").addEventListener("click", () => {
  847. const rect = state.canvas.getBoundingClientRect();
  848. zoomAt(.74, rect.width / 2, rect.height / 2);
  849. });
  850. document.querySelector("#parent-view").addEventListener("click", () => {
  851. const current = state.selectedNode || state.focus;
  852. if (!current?.parent) return;
  853. if (state.selectedDemand) focusDemandPathNode(state.selectedDemand, current.parent);
  854. else selectNode(current.parent, true);
  855. });
  856. document.querySelector("#depth-axis").addEventListener("click", event => {
  857. const button = event.target.closest("[data-start-depth]");
  858. if (!button) return;
  859. const selectedDepth = Number(button.dataset.startDepth);
  860. if (selectedDepth === 0) {
  861. resetGlobalView();
  862. return;
  863. }
  864. state.focus = null;
  865. state.selectedNode = null;
  866. state.selectedDemand = null;
  867. state.startDepth = selectedDepth;
  868. renderDemandAttachments(null);
  869. resizeCanvas();
  870. document.querySelector("#icicle-viewport").scrollTop = 0;
  871. renderDemandList();
  872. renderMapSummary();
  873. });
  874. document.querySelector("#breadcrumb-bar").addEventListener("click", event => {
  875. if (event.target.closest("[data-reset-breadcrumb]")) {
  876. resetGlobalView();
  877. return;
  878. }
  879. const button = event.target.closest("[data-breadcrumb-id]");
  880. if (!button) return;
  881. const node = state.byId.get(button.dataset.breadcrumbId);
  882. if (!node) return;
  883. if (state.selectedDemand) focusDemandPathNode(state.selectedDemand, node);
  884. else selectNode(node, true);
  885. });
  886. document.querySelector("#global-search").addEventListener("input", event => {
  887. state.query = event.target.value.trim();
  888. drawIcicle();
  889. renderDemandList();
  890. });
  891. document.querySelector("#demand-filters").addEventListener("click", event => {
  892. const button = event.target.closest("[data-granularity]");
  893. if (!button) return;
  894. state.granularity = button.dataset.granularity;
  895. document.querySelectorAll("[data-granularity]").forEach(item =>
  896. item.classList.toggle("active", item === button)
  897. );
  898. renderDemandList();
  899. });
  900. document.querySelector("#demand-list").addEventListener("click", event => {
  901. const card = event.target.closest("[data-demand-id]");
  902. if (!card) return;
  903. selectDemand(state.demandById.get(card.dataset.demandId));
  904. });
  905. document.querySelector("#demand-attachments").addEventListener("click", event => {
  906. const button = event.target.closest("[data-attachment-node]");
  907. if (!button) return;
  908. const demand = state.demandById.get(button.dataset.attachmentDemand);
  909. if (demand) focusDemandAttachment(demand, button.dataset.attachmentNode);
  910. });
  911. document.querySelector("#explanation-panel").addEventListener("click", event => {
  912. const button = event.target.closest("[data-path-info-node]");
  913. if (!button || !state.selectedDemand) return;
  914. const node = state.byId.get(button.dataset.pathInfoNode);
  915. if (node) focusDemandPathNode(state.selectedDemand, node);
  916. });
  917. const canvas = document.querySelector("#icicle-canvas");
  918. canvas.addEventListener("wheel", event => {
  919. if (!(event.ctrlKey || event.metaKey)) return;
  920. event.preventDefault();
  921. const rect = canvas.getBoundingClientRect();
  922. zoomAt(event.deltaY < 0 ? 1.15 : .87, event.clientX - rect.left, event.clientY - rect.top);
  923. }, { passive: false });
  924. canvas.addEventListener("pointerdown", event => {
  925. canvas.setPointerCapture(event.pointerId);
  926. state.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY, type: event.pointerType });
  927. state.drag.active = true;
  928. state.drag.moved = false;
  929. state.drag.x = event.clientX;
  930. state.drag.y = event.clientY;
  931. if (state.pointers.size === 2) {
  932. const points = [...state.pointers.values()];
  933. state.drag.pinchDistance = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
  934. }
  935. document.querySelector("#icicle-wrap").classList.add("dragging");
  936. });
  937. canvas.addEventListener("pointermove", event => {
  938. if (state.pointers.has(event.pointerId)) {
  939. state.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY, type: event.pointerType });
  940. if (state.pointers.size === 2) {
  941. const points = [...state.pointers.values()];
  942. const distance = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
  943. const centerX = (points[0].x + points[1].x) / 2 - canvas.getBoundingClientRect().left;
  944. const centerY = (points[0].y + points[1].y) / 2 - canvas.getBoundingClientRect().top;
  945. if (state.drag.pinchDistance) zoomAt(distance / state.drag.pinchDistance, centerX, centerY);
  946. state.drag.pinchDistance = distance;
  947. state.drag.moved = true;
  948. return;
  949. }
  950. if (state.drag.active) {
  951. const dx = event.clientX - state.drag.x;
  952. const dy = event.clientY - state.drag.y;
  953. if (Math.abs(dx) + Math.abs(dy) > 2) state.drag.moved = true;
  954. if (event.pointerType === "touch") {
  955. const viewport = document.querySelector("#icicle-viewport");
  956. viewport.scrollLeft -= dx;
  957. viewport.scrollTop -= dy;
  958. } else {
  959. state.view.x += dx;
  960. state.view.y += dy;
  961. }
  962. state.drag.x = event.clientX;
  963. state.drag.y = event.clientY;
  964. if (event.pointerType !== "touch") drawIcicle();
  965. return;
  966. }
  967. }
  968. const node = nodeAt(event.clientX, event.clientY);
  969. const tooltip = document.querySelector("#map-tooltip");
  970. if (!node) {
  971. tooltip.style.display = "none";
  972. return;
  973. }
  974. const rect = canvas.getBoundingClientRect();
  975. tooltip.style.display = "block";
  976. tooltip.style.left = `${Math.min(rect.width - 245, event.clientX - rect.left + 12)}px`;
  977. tooltip.style.top = `${Math.min(rect.height - 100, event.clientY - rect.top + 12)}px`;
  978. tooltip.innerHTML = `<strong>${esc(node.name)}</strong>
  979. <span>${esc(node.path.join(" / "))}</span>
  980. <span>热度 ${formatScore(node.displayScore)} · 可信度 ${formatScore(node.score.confidence)}</span>
  981. <span>${node.score.dataState === "missing" ? "该节点无直接数据,颜色可能来自子树信号" : "具有直接六维数据"}</span>`;
  982. });
  983. canvas.addEventListener("pointerleave", () => {
  984. document.querySelector("#map-tooltip").style.display = "none";
  985. });
  986. const endPointer = event => {
  987. const wasMoved = state.drag.moved;
  988. state.pointers.delete(event.pointerId);
  989. if (state.pointers.size < 2) state.drag.pinchDistance = 0;
  990. if (!state.pointers.size) {
  991. state.drag.active = false;
  992. document.querySelector("#icicle-wrap").classList.remove("dragging");
  993. if (!wasMoved) {
  994. const node = nodeAt(event.clientX, event.clientY);
  995. if (node) selectNode(node, true);
  996. }
  997. }
  998. };
  999. canvas.addEventListener("pointerup", endPointer);
  1000. canvas.addEventListener("pointercancel", endPointer);
  1001. canvas.addEventListener("dblclick", event => {
  1002. const rect = canvas.getBoundingClientRect();
  1003. zoomAt(1.8, event.clientX - rect.left, event.clientY - rect.top);
  1004. });
  1005. }
  1006. function renderHeader(data) {
  1007. const elementCount = Object.values(state.elementsByCategory).reduce((sum, items) => sum + items.length, 0);
  1008. const observed = state.flat.filter(node => node.score.dataState === "observed").length;
  1009. document.querySelector("#source-stats").innerHTML = `
  1010. <span>${state.flat.length.toLocaleString("zh-CN")} 个分类节点</span>
  1011. <span>${elementCount.toLocaleString("zh-CN")} 条上游元素</span>
  1012. <span>${state.demands.length.toLocaleString("zh-CN")} 条平台需求</span>
  1013. <span>${state.candidateCount.toLocaleString("zh-CN")} 条候选经过判断</span>
  1014. <span>${observed.toLocaleString("zh-CN")} 个节点有热度数据</span>
  1015. <span>数据日期 ${esc(data.bizDt || "—")}</span>`;
  1016. document.body.dataset.demandCount = String(state.demands.length);
  1017. document.body.dataset.hasWarDemand = String(state.demands.some(demand => demand.name === "战争"));
  1018. document.body.dataset.hasAppearanceDemand = String(state.demands.some(demand => demand.name === "表象"));
  1019. document.body.dataset.scoreRangeValid = String(state.flat.every(node =>
  1020. node.score.heatScore == null || (node.score.heatScore >= 0 && node.score.heatScore <= 1)
  1021. ));
  1022. }
  1023. async function init() {
  1024. bindEvents();
  1025. try {
  1026. const html = await fetch(SOURCE_URL).then(response => {
  1027. if (!response.ok) throw new Error(`HTTP ${response.status}`);
  1028. return response.text();
  1029. });
  1030. const source = new DOMParser().parseFromString(html, "text/html");
  1031. const dataScript = source.querySelector("#export-data");
  1032. if (!dataScript) throw new Error("原始页面中没有找到 export-data");
  1033. const data = JSON.parse(dataScript.textContent);
  1034. prepareData(data);
  1035. renderHeader(data);
  1036. resizeCanvas();
  1037. renderDemandList();
  1038. renderMapSummary();
  1039. state.resizeObserver = new ResizeObserver(resizeCanvas);
  1040. state.resizeObserver.observe(document.querySelector("#icicle-viewport"));
  1041. } catch (error) {
  1042. document.querySelector("#map-loading").textContent = `读取全局树失败:${error.message}`;
  1043. document.querySelector("#demand-list").innerHTML =
  1044. `<div class="empty-state">读取全局树失败:${esc(error.message)}</div>`;
  1045. console.error(error);
  1046. }
  1047. }
  1048. init();
  1049. })();