global-tree-analysis.js 48 KB

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