(() => { const SOURCE_URL = "/全局分类树.html"; const DIMENSIONS = [ { key: "ext_pop", label: "外部热度", group: "prior", weight: .30 }, { key: "plat_sust_pop", label: "平台持续热度", group: "prior", weight: .23 }, { key: "plat_ly_pop", label: "去年同期热度", group: "prior", weight: .17 }, { key: "recent_pop", label: "近期热度", group: "prior", weight: .30 }, { key: "real_rov_7d", label: "真实 ROV", group: "posterior", weight: .50 }, { key: "real_vov_7d", label: "真实 VOV", group: "posterior", weight: .50 } ]; const STRUCTURE_ONLY = new Set([ "表象", "理念", "实体", "行为", "知识", "现象", "事件", "观念", "内容形态", "场景", "声音", "符号", "视觉", "对象", "分类", "其他", "个体", "方法", "概念", "社会", "个人", "事物", "属性", "关系" ]); const ISOLATED_ACTIONS = new Set([ "转发", "点赞", "评论", "收藏", "关注", "播放", "浏览", "点击", "分享", "上传", "下载", "发布", "互动", "推荐" ]); const BROAD_DEMANDS = new Set([ "战争", "历史", "人物", "诗词", "音乐", "电影", "健康", "教育", "科技", "美食", "旅游", "体育", "情感", "家庭", "生活", "文化", "军事", "政治" ]); const state = { roots: [], flat: [], byId: new Map(), elementsByCategory: {}, metricModel: {}, candidateCount: 0, demands: [], demandById: new Map(), focus: null, startDepth: 0, maxDepth: 0, selectedNode: null, selectedDemand: null, granularity: "all", query: "", canvas: null, ctx: null, dpr: 1, baseRects: [], rects: [], view: { scale: 1, x: 0, y: 0 }, pointers: new Map(), drag: { active: false, moved: false, x: 0, y: 0, pinchDistance: 0 }, resizeObserver: null }; const esc = value => String(value ?? "").replace(/[&<>"']/g, char => ( { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char] )); const clamp01 = value => Math.max(0, Math.min(1, Number(value) || 0)); const formatScore = value => value == null ? "无数据" : clamp01(value).toFixed(2); const rawNumber = value => Number(value) || 0; const formatRaw = value => rawNumber(value).toLocaleString("zh-CN", { maximumFractionDigits: 4 }); const normalizeName = name => String(name || "").replace(/\s+/g, "").trim(); function quantile(sorted, q) { if (!sorted.length) return 0; const position = (sorted.length - 1) * q; const base = Math.floor(position); const rest = position - base; return sorted[base + 1] == null ? sorted[base] : sorted[base] + rest * (sorted[base + 1] - sorted[base]); } function buildMetricModel(nodes) { const model = {}; for (const dimension of DIMENSIONS) { const values = nodes .map(node => rawNumber(node.weights?.[dimension.key])) .filter(value => value > 0) .map(value => Math.log1p(value)) .sort((a, b) => a - b); model[dimension.key] = { cap: quantile(values, .95) || Math.max(...values, 1), nonZeroCount: values.length }; } return model; } function normalizedMetric(key, raw) { const value = rawNumber(raw); if (value <= 0) return null; const cap = state.metricModel[key]?.cap || 1; return clamp01(Math.log1p(value) / cap); } function weightedGroup(node, group) { const dimensions = DIMENSIONS.filter(item => item.group === group); let weighted = 0; let weight = 0; let covered = 0; for (const dimension of dimensions) { const score = normalizedMetric(dimension.key, node.weights?.[dimension.key]); if (score == null) continue; weighted += score * dimension.weight; weight += dimension.weight; covered += 1; } return { score: weight ? weighted / weight : null, coverage: covered / dimensions.length, count: covered }; } function computeNodeScores(node) { const prior = weightedGroup(node, "prior"); const posterior = weightedGroup(node, "posterior"); const hasAnyData = prior.count + posterior.count > 0; let heatScore = null; if (prior.score != null && posterior.score != null) { heatScore = prior.score * .58 + posterior.score * .42; } else if (prior.score != null) { heatScore = prior.score; } else if (posterior.score != null) { heatScore = posterior.score; } const dimensionCoverage = (prior.count + posterior.count) / DIMENSIONS.length; // 单一维度很强仍然是有效信号,但不应与多维共同升温显示成同样热。 // 覆盖度只校正“热度”,不参与是否为需求的判断。 if (heatScore != null) { heatScore *= .50 + dimensionCoverage * .50; } const agreement = prior.score != null && posterior.score != null ? 1 - Math.min(1, Math.abs(prior.score - posterior.score)) : .46; const confidence = hasAnyData ? clamp01(dimensionCoverage * .62 + agreement * .25 + (node.elements.length ? .13 : 0)) : clamp01(node.elements.length ? .18 : 0); node.score = { heatScore: heatScore == null ? null : clamp01(heatScore), confidence, dataState: hasAnyData ? "observed" : "missing", priorScore: prior.score, posteriorScore: posterior.score, priorCoverage: prior.coverage, posteriorCoverage: posterior.coverage }; } function flatten(nodes, parent = null, path = []) { for (const node of nodes || []) { node.parent = parent; node.path = [...path, node.name]; node.children = node.children || []; node.elements = state.elementsByCategory[String(node.id)] || []; state.flat.push(node); state.maxDepth = Math.max(state.maxDepth, node.path.length - 1); state.byId.set(String(node.id), node); flatten(node.children, node, node.path); } } function computeSubtreeScore(node) { node.leafCount = node.children.length ? node.children.reduce((sum, child) => sum + computeSubtreeScore(child).leafCount, 0) : 1; const childScores = node.children .map(child => child.displayScore) .filter(value => value != null) .sort((a, b) => b - a); const strongChildren = childScores.slice(0, Math.max(1, Math.ceil(childScores.length * .25))); const childSignal = strongChildren.length ? strongChildren.reduce((sum, value) => sum + value, 0) / strongChildren.length : null; if (node.score.heatScore != null && childSignal != null) { node.displayScore = clamp01(node.score.heatScore * .68 + childSignal * .32); } else { node.displayScore = node.score.heatScore ?? (childSignal == null ? null : childSignal * .78); } return node; } function classifySearchIntent(name, sourceType, node) { const normalized = normalizeName(name); if (!normalized || normalized.length < 2) { return { isDemand: false, granularity: null, reasonCode: "too_short" }; } if (STRUCTURE_ONLY.has(normalized)) { return { isDemand: false, granularity: null, reasonCode: "structure_only" }; } if (ISOLATED_ACTIONS.has(normalized)) { return { isDemand: false, granularity: null, reasonCode: "isolated_action" }; } if (/^(其他|相关|类别|类型|内容|信息|方面|部分|综合|未知)/.test(normalized)) { return { isDemand: false, granularity: null, reasonCode: "generic_structure" }; } let granularity; if (BROAD_DEMANDS.has(normalized) || normalized.length <= 2) granularity = "宽泛"; else if (normalized.length <= 5) granularity = "主题级"; else if (normalized.length <= 10) granularity = "具体"; else granularity = "长尾"; if (sourceType === "node" && node && node.path.length <= 2 && normalized.length <= 3) { return { isDemand: false, granularity: null, reasonCode: "upper_structure" }; } return { isDemand: true, granularity, reasonCode: "searchable" }; } function inheritedHeat(node) { if (node.score.heatScore != null) return node.score.heatScore; let current = node.parent; let decay = .64; while (current) { if (current.score.heatScore != null) return clamp01(current.score.heatScore * decay); current = current.parent; decay *= .72; } return null; } function createCandidate(name, node, sourceType, rawReason = "") { const intent = classifySearchIntent(name, sourceType, node); if (!intent.isDemand) return null; return { name: normalizeName(name), sourceType, granularity: intent.granularity, attachment: { nodeId: String(node.id), nodeName: node.name, path: node.path.join(" / "), heatScore: inheritedHeat(node), ownHeatScore: node.score.heatScore, confidence: node.score.confidence, dataState: node.score.dataState, weights: node.weights || {}, rawReason: rawReason || "", elementName: sourceType === "element" ? name : "" } }; } function extractDemandCandidates() { const candidates = []; for (const node of state.flat) { for (const element of node.elements) { const candidate = createCandidate(element.name, node, "element", element.reason); if (candidate) candidates.push(candidate); } const nodeCandidate = createCandidate(node.name, node, "node", node.description || ""); if (nodeCandidate) candidates.push(nodeCandidate); } return candidates; } function mergeDemandCandidates(candidates) { const merged = new Map(); for (const candidate of candidates) { const key = candidate.name.toLowerCase(); if (!merged.has(key)) { merged.set(key, { id: `demand-${merged.size + 1}`, name: candidate.name, granularity: candidate.granularity, attachments: [], sourceTypes: new Set() }); } const demand = merged.get(key); demand.sourceTypes.add(candidate.sourceType); const existingAttachment = demand.attachments.find(item => item.nodeId === candidate.attachment.nodeId ); if (!existingAttachment) { demand.attachments.push(candidate.attachment); } else if ( candidate.attachment.rawReason && !existingAttachment.rawReason.includes(candidate.attachment.rawReason) ) { existingAttachment.rawReason = existingAttachment.rawReason ? `${existingAttachment.rawReason};${candidate.attachment.rawReason}` : candidate.attachment.rawReason; } if (candidate.granularity === "长尾") demand.granularity = "长尾"; else if (candidate.granularity === "具体" && demand.granularity !== "长尾") demand.granularity = "具体"; } const results = [...merged.values()]; for (const demand of results) { const heatValues = demand.attachments.map(item => item.heatScore).filter(value => value != null); const confidenceValues = demand.attachments.map(item => item.confidence).filter(value => value != null); demand.heatScore = heatValues.length ? Math.max(...heatValues) : null; demand.confidence = confidenceValues.length ? clamp01(confidenceValues.reduce((sum, value) => sum + value, 0) / confidenceValues.length) : 0; demand.hasObservedData = demand.attachments.some(item => item.dataState === "observed"); demand.priority = (demand.heatScore ?? .08) * .56 + demand.confidence * .24 + Math.min(1, demand.attachments.length / 4) * .12 + (demand.sourceTypes.has("element") ? .08 : 0); demand.factReason = buildFactReason(demand); demand.modelReason = buildModelReason(demand); } return results.sort((a, b) => b.priority - a.priority || b.attachments.length - a.attachments.length); } function selectDemandPortfolio(candidates) { // 输出规模保持在“数百条”。主队列由多维数据支持,探索队列专门保留 // 冷区或数据缺失但具备明确搜索意义的需求,避免热度阈值一刀切。 const supported = candidates.filter(demand => demand.hasObservedData && (demand.heatScore ?? 0) >= .12 ); const exploration = candidates.filter(demand => !demand.hasObservedData || (demand.heatScore ?? 0) < .12 ); const selected = new Map(); supported.slice(0, 650).forEach(demand => selected.set(demand.name.toLowerCase(), demand)); exploration .sort((a, b) => { const aSpecific = a.sourceTypes.has("element") ? 1 : 0; const bSpecific = b.sourceTypes.has("element") ? 1 : 0; return bSpecific - aSpecific || b.priority - a.priority; }) .slice(0, 250) .forEach(demand => selected.set(demand.name.toLowerCase(), demand)); // 用户明确确认的宽泛需求必须保留,不能因为配额或粒度被过滤。 for (const demand of candidates) { if (BROAD_DEMANDS.has(demand.name)) selected.set(demand.name.toLowerCase(), demand); } return [...selected.values()].sort((a, b) => b.priority - a.priority); } function strongestDimensions(attachment) { return DIMENSIONS .map(dimension => ({ label: dimension.label, raw: rawNumber(attachment.weights?.[dimension.key]), normalized: normalizedMetric(dimension.key, attachment.weights?.[dimension.key]) })) .filter(item => item.normalized != null) .sort((a, b) => b.normalized - a.normalized) .slice(0, 2); } function buildFactReason(demand) { const primary = demand.attachments .slice() .sort((a, b) => (b.heatScore ?? -1) - (a.heatScore ?? -1))[0]; const dimensions = strongestDimensions(primary); const signals = dimensions.length ? dimensions.map(item => `${item.label} ${formatRaw(item.raw)}`).join("、") : "六项表现数据当前缺失"; const posterior = rawNumber(primary.weights.real_rov_7d) > 0 || rawNumber(primary.weights.real_vov_7d) > 0 ? `真实 ROV ${formatRaw(primary.weights.real_rov_7d)}、真实 VOV ${formatRaw(primary.weights.real_vov_7d)}` : "真实 ROV / VOV 暂无样本"; const rawReason = demand.attachments.find(item => item.rawReason)?.rawReason; return `主要挂靠“${primary.path}”;${signals};${posterior}` + (rawReason ? `;上游原始 reason:“${rawReason}”` : "。"); } function buildModelReason(demand) { const heat = demand.heatScore; const dataPhrase = heat == null ? "当前表现数据不足,热度需要后续验证" : heat >= .8 ? "当前处于高热区域" : heat >= .6 ? "当前处于升温区域" : heat >= .4 ? "当前热度中等" : heat >= .2 ? "当前偏冷但仍具有搜索意义" : "当前位于冷区,但冷区不等于无需求"; const breadth = { 宽泛: "它能够独立表达外部搜索意图,范围较大,因此作为父级宽泛需求保留,并继续下挂更具体需求", 主题级: "它能够独立表达明确主题,可承接多个具体内容方向", 具体: "它表达了较明确的搜索对象或问题,可以直接用于内容供给", 长尾: "它包含较完整的限定条件,适合作为长尾搜索需求" }[demand.granularity]; const relation = demand.attachments.length > 1 ? `;该需求具有 ${demand.attachments.length} 个挂靠点,说明它跨越多个分类语义` : ""; return `${breadth}。${dataPhrase}${relation}。`; } function prepareData(data) { state.roots = data.nodes || []; state.elementsByCategory = data.demandsByCategory || {}; flatten(state.roots); state.metricModel = buildMetricModel(state.flat); state.flat.forEach(computeNodeScores); state.roots.forEach(computeSubtreeScore); const candidates = mergeDemandCandidates(extractDemandCandidates()); state.candidateCount = candidates.length; state.demands = selectDemandPortfolio(candidates); state.demands.forEach(demand => state.demandById.set(demand.id, demand)); } function heatColor(score, dataState) { if (dataState === "missing" && score == null) return "#f1f5f9"; if (score == null) return "#f1f5f9"; if (score >= .8) return "#ef4444"; if (score >= .6) return "#f59e0b"; if (score >= .4) return "#22a06b"; if (score >= .2) return "#2563eb"; return "#bfdbfe"; } function textColor(score) { return score == null || score < .2 ? "#475569" : "#ffffff"; } function descendantsAtDepth(roots, targetDepth) { const results = []; const walk = node => { const depth = node.path.length - 1; if (depth === targetDepth) { results.push(node); return; } if (depth < targetDepth) node.children.forEach(walk); }; roots.forEach(walk); return results; } function activeBaseDepth() { const focusDepth = state.focus ? state.focus.path.length - 1 : 0; return Math.max(focusDepth, state.startDepth); } function viewRoots() { const scopeRoots = state.focus ? [state.focus] : state.roots; const baseDepth = activeBaseDepth(); const roots = descendantsAtDepth(scopeRoots, baseDepth); return roots.length ? roots : scopeRoots; } function relativeMaxDepth(nodes, baseDepth) { let maximum = 0; const walk = node => { maximum = Math.max(maximum, node.path.length - 1 - baseDepth); node.children.forEach(walk); }; nodes.forEach(walk); return maximum; } function layoutIcicle() { if (!state.canvas) return []; const rect = state.canvas.getBoundingClientRect(); const roots = viewRoots(); const baseDepth = activeBaseDepth(); const maxDepth = relativeMaxDepth(roots, baseDepth); const columns = maxDepth + 1; const columnWidth = rect.width / columns; const totalLeaves = roots.reduce((sum, node) => sum + node.leafCount, 0); const rows = []; let yCursor = 0; const place = (node, y, height) => { const relativeDepth = node.path.length - 1 - baseDepth; rows.push({ node, x: relativeDepth * columnWidth, y, width: columnWidth, height }); let childY = y; for (const child of node.children) { const childHeight = height * (child.leafCount / node.leafCount); place(child, childY, childHeight); childY += childHeight; } }; for (const root of roots) { const height = rect.height * (root.leafCount / totalLeaves); place(root, yCursor, height); yCursor += height; } renderDepthAxis(baseDepth); return rows; } function renderDepthAxis(baseDepth) { const axis = document.querySelector("#depth-axis"); axis.style.gridTemplateColumns = `repeat(${state.maxDepth + 1}, minmax(38px, 1fr))`; axis.innerHTML = Array.from({ length: state.maxDepth + 1 }, (_, depth) => { return ``; }).join(""); } function renderBreadcrumb() { const target = document.querySelector("#breadcrumb-bar"); const pathNodes = []; let current = state.selectedNode || state.focus; while (current) { pathNodes.unshift(current); current = current.parent; } target.innerHTML = pathNodes.length ? pathNodes.map((node, index) => ` ${index ? "" : ""} `).join("") : ``; const parentButton = document.querySelector("#parent-view"); parentButton.disabled = !(state.selectedNode?.parent || state.focus?.parent); } function updateCanvasExtent() { const viewport = document.querySelector("#icicle-viewport"); const wrap = document.querySelector("#icicle-wrap"); const roots = viewRoots(); const totalLeaves = roots.reduce((sum, node) => sum + node.leafCount, 0); const shouldExtend = state.startDepth > 0 || Boolean(state.focus); const minimumLeafHeight = state.focus ? 13 : 9; const desiredHeight = shouldExtend ? Math.max(viewport.clientHeight, Math.min(24000, totalLeaves * minimumLeafHeight)) : viewport.clientHeight; wrap.style.height = `${Math.max(420, desiredHeight)}px`; } function resizeCanvas() { const canvas = document.querySelector("#icicle-canvas"); updateCanvasExtent(); const rect = canvas.getBoundingClientRect(); state.canvas = canvas; state.ctx = canvas.getContext("2d"); state.dpr = rect.height > 3000 ? 1 : Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.round(rect.width * state.dpr); canvas.height = Math.round(rect.height * state.dpr); state.ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0); fitView(false); drawIcicle(); } function drawIcicle() { if (!state.ctx || !state.flat.length) return; const ctx = state.ctx; const rect = state.canvas.getBoundingClientRect(); ctx.clearRect(0, 0, rect.width, rect.height); state.baseRects = layoutIcicle(); state.rects = state.baseRects.map(item => ({ ...item, x: item.x * state.view.scale + state.view.x, y: item.y * state.view.scale + state.view.y, width: item.width * state.view.scale, height: item.height * state.view.scale })); const query = state.query.toLowerCase(); for (const item of state.rects) { const node = item.node; const score = node.displayScore; const selected = state.selectedNode === node; const matched = query && ( node.name.toLowerCase().includes(query) || node.elements.some(element => element.name.toLowerCase().includes(query)) ); ctx.fillStyle = heatColor(score, node.score.dataState); ctx.fillRect(item.x, item.y, item.width, item.height); ctx.strokeStyle = "rgba(255,255,255,.88)"; ctx.lineWidth = .8; ctx.strokeRect(item.x, item.y, item.width, item.height); if (selected || matched) { ctx.strokeStyle = selected ? "#7c3aed" : "#1d4ed8"; ctx.lineWidth = selected ? 3 : 2; ctx.strokeRect(item.x + 1.5, item.y + 1.5, Math.max(0, item.width - 3), Math.max(0, item.height - 3)); } if (item.height >= 15 && item.width >= 54) { ctx.save(); ctx.beginPath(); ctx.rect(item.x + 2, item.y + 1, item.width - 4, item.height - 2); ctx.clip(); ctx.fillStyle = textColor(score); ctx.font = `${item.height >= 30 ? 9 : 7}px Inter, "PingFang SC", sans-serif`; ctx.textBaseline = "top"; ctx.fillText(node.name, item.x + 5, item.y + 4); if (item.height >= 31) { ctx.globalAlpha = .82; ctx.font = '7px Inter, "PingFang SC", sans-serif'; ctx.fillText(score == null ? "无数据" : `Score ${formatScore(score)}`, item.x + 5, item.y + 17); } ctx.restore(); } } document.querySelector("#map-loading").style.display = "none"; document.body.dataset.viewScale = state.view.scale.toFixed(3); document.body.dataset.viewX = state.view.x.toFixed(1); document.body.dataset.viewY = state.view.y.toFixed(1); document.body.dataset.startDepth = String(activeBaseDepth()); document.body.dataset.focusNode = state.focus?.name || ""; document.body.dataset.selectedNode = state.selectedNode?.name || ""; document.body.dataset.canvasHeight = String(Math.round(rect.height)); renderBreadcrumb(); } function fitView(redraw = true) { state.view = { scale: 1, x: 0, y: 0 }; if (redraw) { const viewport = document.querySelector("#icicle-viewport"); viewport.scrollTop = 0; viewport.scrollLeft = 0; drawIcicle(); } } function zoomAt(factor, screenX, screenY) { const oldScale = state.view.scale; const nextScale = Math.max(1, Math.min(18, oldScale * factor)); const worldX = (screenX - state.view.x) / oldScale; const worldY = (screenY - state.view.y) / oldScale; state.view.scale = nextScale; state.view.x = screenX - worldX * nextScale; state.view.y = screenY - worldY * nextScale; drawIcicle(); } function nodeAt(clientX, clientY) { const rect = state.canvas.getBoundingClientRect(); const x = clientX - rect.left; const y = clientY - rect.top; let match = null; for (const item of state.rects) { if (x >= item.x && x <= item.x + item.width && y >= item.y && y <= item.y + item.height) { if (!match || item.node.path.length > match.node.path.length) match = item; } } return match?.node || null; } function scrollSelectedNodeIntoView() { if (!state.selectedNode) return; const item = state.rects.find(rect => rect.node === state.selectedNode); if (!item) return; const viewport = document.querySelector("#icicle-viewport"); viewport.scrollTop = Math.max(0, item.y - viewport.clientHeight * .38); viewport.scrollLeft = Math.max(0, item.x - viewport.clientWidth * .18); } function demandInFocus(demand) { if (!state.focus) return true; const focusPath = state.focus.path.join(" / "); return demand.attachments.some(item => item.path.startsWith(focusPath)); } function filteredDemands() { const query = state.query.toLowerCase(); return state.demands.filter(demand => { if (state.granularity !== "all" && demand.granularity !== state.granularity) return false; if (!demandInFocus(demand)) return false; if (!query) return true; return demand.name.toLowerCase().includes(query) || demand.attachments.some(item => item.path.toLowerCase().includes(query)); }); } function renderDemandList() { const demands = filteredDemands(); document.querySelector("#demand-count").textContent = demands.length.toLocaleString("zh-CN"); document.querySelector("#demand-context").textContent = state.focus ? `当前聚焦“${state.focus.name}”分支;点击回到全局可恢复全部结果。` : "从全树自动识别,不要求逐节点点击。"; document.querySelector("#demand-list").innerHTML = demands.slice(0, 250).map(demand => `

${esc(demand.name)}

${esc(demand.granularity)}需求
${demand.heatScore == null ? "热度待验证" : `热度 ${formatScore(demand.heatScore)}`} 可信度 ${formatScore(demand.confidence)} ${demand.attachments.length} 个挂靠点 ${demand.hasObservedData ? "" : `后验待验证`}

模型 Reason:${esc(demand.modelReason)}

`).join("") || `
当前条件下没有匹配需求。冷区节点仍保留在左侧全局图中。
`; } function renderDemandAttachments(demand) { const target = document.querySelector("#demand-attachments"); if (!demand) { target.className = "demand-attachments"; target.innerHTML = ""; return; } target.className = "demand-attachments visible"; target.innerHTML = `
“${esc(demand.name)}”的完整挂靠路径 ${demand.attachments.length} 个挂靠点 · 点击定位
${demand.attachments.map((attachment, index) => ` `).join("")}`; } function renderMapSummary() { const isScoped = Boolean(state.focus) || state.startDepth > 0; const scope = isScoped ? [...new Map(state.rects.map(item => [item.node.id, item.node])).values()] : state.flat; const observed = scope.filter(node => node.score.dataState === "observed"); const hot = scope.filter(node => (node.displayScore ?? -1) >= .6); const cold = scope.filter(node => node.displayScore != null && node.displayScore < .2); const missing = scope.filter(node => node.score.dataState === "missing"); document.querySelector("#map-summary").innerHTML = `
${hot.length.toLocaleString("zh-CN")} 个升温或高热节点
用于定位当前信号集中的分支。
${cold.length.toLocaleString("zh-CN")} 个冷区节点
继续保留,形成全局比较基线。
${missing.length.toLocaleString("zh-CN")} 个无直接数据节点
无数据不等于 Score 为 0,也不等于无需求。
`; document.querySelector("#map-context").textContent = state.focus ? `${state.focus.path.join(" / ")} · 保留父子层级的当前分支 · ${scope.length.toLocaleString("zh-CN")} 个节点` : state.startDepth > 0 ? `从第 ${state.startDepth} 级开始 · 保留向下树结构 · ${scope.length.toLocaleString("zh-CN")} 个节点` : `全局视图 · ${observed.length.toLocaleString("zh-CN")} 个节点具有直接热度数据`; } function relatedDemandsForNode(node) { const path = node.path.join(" / "); return state.demands .filter(demand => demand.attachments.some(item => item.path === path || item.path.startsWith(`${path} /`))) .slice(0, 8); } function renderNodeExplanation(node) { const related = relatedDemandsForNode(node); const siblings = node.parent ? node.parent.children.filter(item => item !== node).slice(0, 10) : state.roots.filter(item => item !== node); const dimensions = DIMENSIONS.map(dimension => { const raw = rawNumber(node.weights?.[dimension.key]); return `${dimension.label}:${raw > 0 ? formatRaw(raw) : "无数据"}`; }).join(";"); document.querySelector("#explanation-panel").innerHTML = `

分类节点

${esc(node.name)}

${esc(node.path.join(" / "))}

${node.displayScore == null ? "热度无数据" : `热度 ${formatScore(node.displayScore)}`} 可信度 ${formatScore(node.score.confidence)} ${node.leafCount} 个叶节点

上游数据事实

六维信号与元素

${esc(dimensions)}

${node.elements.slice(0, 8).map(element => `${esc(element.name)}${element.reason ? ` · 原始 reason:${esc(element.reason)}` : ""}` ).join("") || "该节点没有直接下挂元素。"}

模型解释

该节点如何参与需求形成

${node.score.dataState === "missing" ? "该节点没有直接表现数据,但仍保留在全局树中。系统会继续检查其名称是否具有搜索意义、是否存在具体元素,以及父子和跨树关系。" : "该节点的颜色反映综合热度;颜色不直接决定它是否为需求。系统还会独立判断名称能否表达外部搜索意图。"}

${related.map(demand => `${esc(demand.name)} · ${esc(demand.granularity)}需求 · 热度 ${formatScore(demand.heatScore)}` ).join("") || "当前没有从该节点及其子树识别出需求。"}
${siblings.length ? `

同级对比:${siblings.map(item => `${esc(item.name)} ${formatScore(item.displayScore)}` ).join(";")}

` : ""}
`; } function renderDemandExplanation(demand) { const selectedPathNode = state.selectedNode || state.byId.get(demand.attachments[0]?.nodeId); const pathNodes = []; let current = selectedPathNode; while (current) { pathNodes.unshift(current); current = current.parent; } document.querySelector("#explanation-panel").innerHTML = `

${esc(demand.granularity)}需求

${esc(demand.name)}

${demand.heatScore == null ? "热度待验证" : `热度 ${formatScore(demand.heatScore)}`} 可信度 ${formatScore(demand.confidence)} ${demand.attachments.length} 个挂靠点
${demand.attachments.slice(0, 12).map(item => `${esc(item.path)}` ).join("")}

上游数据事实

这个结论依赖了什么数据

${esc(demand.factReason)}

${demand.attachments.slice(0, 8).map(item => { const posterior = rawNumber(item.weights.real_rov_7d) > 0 || rawNumber(item.weights.real_vov_7d) > 0 ? `真实 ROV ${formatRaw(item.weights.real_rov_7d)} / 真实 VOV ${formatRaw(item.weights.real_vov_7d)}` : "真实 ROV / VOV 待验证"; return `${esc(item.nodeName)} · ${esc(posterior)}`; }).join("")}

模型 Reason

为什么它被识别为需求

${esc(demand.modelReason)}

说明:这里是基于名称、树路径、元素和六维数据生成的模型解释,不是上游原始 reason。

完整路径节点信息

沿路径查看父节点及其子树

${pathNodes.map((node, index) => ` `).join("")}
`; } function selectNode(node, focus = false) { state.selectedNode = node; state.selectedDemand = null; renderDemandAttachments(null); if (focus) { state.focus = node; state.startDepth = node.path.length - 1; } resizeCanvas(); renderDemandList(); renderMapSummary(); renderNodeExplanation(node); } function selectDemand(demand) { state.selectedDemand = demand; const primary = demand.attachments[0]; state.selectedNode = state.byId.get(primary.nodeId) || null; drawIcicle(); renderDemandList(); renderDemandAttachments(demand); renderDemandExplanation(demand); } function contextAncestor(node, levels = 3) { let current = node; for (let index = 0; index < levels && current.parent; index += 1) current = current.parent; return current; } function focusDemandAttachment(demand, nodeId) { const node = state.byId.get(String(nodeId)); if (!node) return; const context = contextAncestor(node, 3); state.selectedDemand = demand; state.selectedNode = node; state.focus = context; state.startDepth = context.path.length - 1; resizeCanvas(); scrollSelectedNodeIntoView(); renderDemandList(); renderDemandAttachments(demand); renderMapSummary(); renderDemandExplanation(demand); } function focusDemandPathNode(demand, node) { state.selectedDemand = demand; state.selectedNode = node; state.focus = node; state.startDepth = node.path.length - 1; resizeCanvas(); document.querySelector("#icicle-viewport").scrollTop = 0; renderDemandList(); renderDemandAttachments(demand); renderMapSummary(); renderDemandExplanation(demand); } function resetGlobalView() { state.focus = null; state.startDepth = 0; state.selectedNode = null; state.selectedDemand = null; renderDemandAttachments(null); resizeCanvas(); renderDemandList(); renderMapSummary(); document.querySelector("#explanation-panel").innerHTML = `
选择热力图节点或右侧需求,查看路径、多个挂靠点、数据事实和模型 Reason。
`; } function bindEvents() { document.querySelector("#reset-view").addEventListener("click", resetGlobalView); document.querySelector("#fit-view").addEventListener("click", () => fitView(true)); document.querySelector("#zoom-in").addEventListener("click", () => { const rect = state.canvas.getBoundingClientRect(); zoomAt(1.35, rect.width / 2, rect.height / 2); }); document.querySelector("#zoom-out").addEventListener("click", () => { const rect = state.canvas.getBoundingClientRect(); zoomAt(.74, rect.width / 2, rect.height / 2); }); document.querySelector("#parent-view").addEventListener("click", () => { const current = state.selectedNode || state.focus; if (!current?.parent) return; if (state.selectedDemand) focusDemandPathNode(state.selectedDemand, current.parent); else selectNode(current.parent, true); }); document.querySelector("#depth-axis").addEventListener("click", event => { const button = event.target.closest("[data-start-depth]"); if (!button) return; const selectedDepth = Number(button.dataset.startDepth); if (selectedDepth === 0) { resetGlobalView(); return; } state.focus = null; state.selectedNode = null; state.selectedDemand = null; state.startDepth = selectedDepth; renderDemandAttachments(null); resizeCanvas(); document.querySelector("#icicle-viewport").scrollTop = 0; renderDemandList(); renderMapSummary(); }); document.querySelector("#breadcrumb-bar").addEventListener("click", event => { if (event.target.closest("[data-reset-breadcrumb]")) { resetGlobalView(); return; } const button = event.target.closest("[data-breadcrumb-id]"); if (!button) return; const node = state.byId.get(button.dataset.breadcrumbId); if (!node) return; if (state.selectedDemand) focusDemandPathNode(state.selectedDemand, node); else selectNode(node, true); }); document.querySelector("#global-search").addEventListener("input", event => { state.query = event.target.value.trim(); drawIcicle(); renderDemandList(); }); document.querySelector("#demand-filters").addEventListener("click", event => { const button = event.target.closest("[data-granularity]"); if (!button) return; state.granularity = button.dataset.granularity; document.querySelectorAll("[data-granularity]").forEach(item => item.classList.toggle("active", item === button) ); renderDemandList(); }); document.querySelector("#demand-list").addEventListener("click", event => { const card = event.target.closest("[data-demand-id]"); if (!card) return; selectDemand(state.demandById.get(card.dataset.demandId)); }); document.querySelector("#demand-attachments").addEventListener("click", event => { const button = event.target.closest("[data-attachment-node]"); if (!button) return; const demand = state.demandById.get(button.dataset.attachmentDemand); if (demand) focusDemandAttachment(demand, button.dataset.attachmentNode); }); document.querySelector("#explanation-panel").addEventListener("click", event => { const button = event.target.closest("[data-path-info-node]"); if (!button || !state.selectedDemand) return; const node = state.byId.get(button.dataset.pathInfoNode); if (node) focusDemandPathNode(state.selectedDemand, node); }); const canvas = document.querySelector("#icicle-canvas"); canvas.addEventListener("wheel", event => { if (!(event.ctrlKey || event.metaKey)) return; event.preventDefault(); const rect = canvas.getBoundingClientRect(); zoomAt(event.deltaY < 0 ? 1.15 : .87, event.clientX - rect.left, event.clientY - rect.top); }, { passive: false }); canvas.addEventListener("pointerdown", event => { canvas.setPointerCapture(event.pointerId); state.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY, type: event.pointerType }); state.drag.active = true; state.drag.moved = false; state.drag.x = event.clientX; state.drag.y = event.clientY; if (state.pointers.size === 2) { const points = [...state.pointers.values()]; state.drag.pinchDistance = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y); } document.querySelector("#icicle-wrap").classList.add("dragging"); }); canvas.addEventListener("pointermove", event => { if (state.pointers.has(event.pointerId)) { state.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY, type: event.pointerType }); if (state.pointers.size === 2) { const points = [...state.pointers.values()]; const distance = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y); const centerX = (points[0].x + points[1].x) / 2 - canvas.getBoundingClientRect().left; const centerY = (points[0].y + points[1].y) / 2 - canvas.getBoundingClientRect().top; if (state.drag.pinchDistance) zoomAt(distance / state.drag.pinchDistance, centerX, centerY); state.drag.pinchDistance = distance; state.drag.moved = true; return; } if (state.drag.active) { const dx = event.clientX - state.drag.x; const dy = event.clientY - state.drag.y; if (Math.abs(dx) + Math.abs(dy) > 2) state.drag.moved = true; if (event.pointerType === "touch") { const viewport = document.querySelector("#icicle-viewport"); viewport.scrollLeft -= dx; viewport.scrollTop -= dy; } else { state.view.x += dx; state.view.y += dy; } state.drag.x = event.clientX; state.drag.y = event.clientY; if (event.pointerType !== "touch") drawIcicle(); return; } } const node = nodeAt(event.clientX, event.clientY); const tooltip = document.querySelector("#map-tooltip"); if (!node) { tooltip.style.display = "none"; return; } const rect = canvas.getBoundingClientRect(); tooltip.style.display = "block"; tooltip.style.left = `${Math.min(rect.width - 245, event.clientX - rect.left + 12)}px`; tooltip.style.top = `${Math.min(rect.height - 100, event.clientY - rect.top + 12)}px`; tooltip.innerHTML = `${esc(node.name)} ${esc(node.path.join(" / "))} 热度 ${formatScore(node.displayScore)} · 可信度 ${formatScore(node.score.confidence)} ${node.score.dataState === "missing" ? "该节点无直接数据,颜色可能来自子树信号" : "具有直接六维数据"}`; }); canvas.addEventListener("pointerleave", () => { document.querySelector("#map-tooltip").style.display = "none"; }); const endPointer = event => { const wasMoved = state.drag.moved; state.pointers.delete(event.pointerId); if (state.pointers.size < 2) state.drag.pinchDistance = 0; if (!state.pointers.size) { state.drag.active = false; document.querySelector("#icicle-wrap").classList.remove("dragging"); if (!wasMoved) { const node = nodeAt(event.clientX, event.clientY); if (node) selectNode(node, true); } } }; canvas.addEventListener("pointerup", endPointer); canvas.addEventListener("pointercancel", endPointer); canvas.addEventListener("dblclick", event => { const rect = canvas.getBoundingClientRect(); zoomAt(1.8, event.clientX - rect.left, event.clientY - rect.top); }); } function renderHeader(data) { const elementCount = Object.values(state.elementsByCategory).reduce((sum, items) => sum + items.length, 0); const observed = state.flat.filter(node => node.score.dataState === "observed").length; document.querySelector("#source-stats").innerHTML = ` ${state.flat.length.toLocaleString("zh-CN")} 个分类节点 ${elementCount.toLocaleString("zh-CN")} 条上游元素 ${state.demands.length.toLocaleString("zh-CN")} 条平台需求 ${state.candidateCount.toLocaleString("zh-CN")} 条候选经过判断 ${observed.toLocaleString("zh-CN")} 个节点有热度数据 数据日期 ${esc(data.bizDt || "—")}`; document.body.dataset.demandCount = String(state.demands.length); document.body.dataset.hasWarDemand = String(state.demands.some(demand => demand.name === "战争")); document.body.dataset.hasAppearanceDemand = String(state.demands.some(demand => demand.name === "表象")); document.body.dataset.scoreRangeValid = String(state.flat.every(node => node.score.heatScore == null || (node.score.heatScore >= 0 && node.score.heatScore <= 1) )); } async function init() { bindEvents(); try { const html = await fetch(SOURCE_URL).then(response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.text(); }); const source = new DOMParser().parseFromString(html, "text/html"); const dataScript = source.querySelector("#export-data"); if (!dataScript) throw new Error("原始页面中没有找到 export-data"); const data = JSON.parse(dataScript.textContent); prepareData(data); renderHeader(data); resizeCanvas(); renderDemandList(); renderMapSummary(); state.resizeObserver = new ResizeObserver(resizeCanvas); state.resizeObserver.observe(document.querySelector("#icicle-viewport")); } catch (error) { document.querySelector("#map-loading").textContent = `读取全局树失败:${error.message}`; document.querySelector("#demand-list").innerHTML = `
读取全局树失败:${esc(error.message)}
`; console.error(error); } } init(); })();