| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114 |
- (() => {
- 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 `<button class="${depth === baseDepth ? "active" : ""}" data-start-depth="${depth}">${depth === 0 ? "根" : `${depth} 级`}</button>`;
- }).join("");
- }
- function renderBreadcrumb() {
- const target = document.querySelector("#breadcrumb-bar");
- const pathNodes = [];
- let current = state.selectedNode || state.focus;
- while (current) {
- pathNodes.unshift(current);
- current = current.parent;
- }
- target.innerHTML = pathNodes.length
- ? pathNodes.map((node, index) => `
- ${index ? "<i>→</i>" : ""}
- <button class="${index === pathNodes.length - 1 ? "current" : ""}" data-breadcrumb-id="${esc(node.id)}">${esc(node.name)}</button>
- `).join("")
- : `<button class="current" data-reset-breadcrumb="true">全局树</button>`;
- const parentButton = document.querySelector("#parent-view");
- parentButton.disabled = !(state.selectedNode?.parent || state.focus?.parent);
- }
- function updateCanvasExtent() {
- const viewport = document.querySelector("#icicle-viewport");
- const wrap = document.querySelector("#icicle-wrap");
- const roots = viewRoots();
- const totalLeaves = roots.reduce((sum, node) => sum + node.leafCount, 0);
- const shouldExtend = state.startDepth > 0 || Boolean(state.focus);
- const minimumLeafHeight = state.focus ? 13 : 9;
- const desiredHeight = shouldExtend
- ? Math.max(viewport.clientHeight, Math.min(24000, totalLeaves * minimumLeafHeight))
- : viewport.clientHeight;
- wrap.style.height = `${Math.max(420, desiredHeight)}px`;
- }
- function resizeCanvas() {
- const canvas = document.querySelector("#icicle-canvas");
- updateCanvasExtent();
- const rect = canvas.getBoundingClientRect();
- state.canvas = canvas;
- state.ctx = canvas.getContext("2d");
- state.dpr = rect.height > 3000 ? 1 : Math.min(window.devicePixelRatio || 1, 2);
- canvas.width = Math.round(rect.width * state.dpr);
- canvas.height = Math.round(rect.height * state.dpr);
- state.ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
- fitView(false);
- drawIcicle();
- }
- function drawIcicle() {
- if (!state.ctx || !state.flat.length) return;
- const ctx = state.ctx;
- const rect = state.canvas.getBoundingClientRect();
- ctx.clearRect(0, 0, rect.width, rect.height);
- state.baseRects = layoutIcicle();
- state.rects = state.baseRects.map(item => ({
- ...item,
- x: item.x * state.view.scale + state.view.x,
- y: item.y * state.view.scale + state.view.y,
- width: item.width * state.view.scale,
- height: item.height * state.view.scale
- }));
- const query = state.query.toLowerCase();
- for (const item of state.rects) {
- const node = item.node;
- const score = node.displayScore;
- const selected = state.selectedNode === node;
- const matched = query && (
- node.name.toLowerCase().includes(query) ||
- node.elements.some(element => element.name.toLowerCase().includes(query))
- );
- ctx.fillStyle = heatColor(score, node.score.dataState);
- ctx.fillRect(item.x, item.y, item.width, item.height);
- ctx.strokeStyle = "rgba(255,255,255,.88)";
- ctx.lineWidth = .8;
- ctx.strokeRect(item.x, item.y, item.width, item.height);
- if (selected || matched) {
- ctx.strokeStyle = selected ? "#7c3aed" : "#1d4ed8";
- ctx.lineWidth = selected ? 3 : 2;
- ctx.strokeRect(item.x + 1.5, item.y + 1.5, Math.max(0, item.width - 3), Math.max(0, item.height - 3));
- }
- if (item.height >= 15 && item.width >= 54) {
- ctx.save();
- ctx.beginPath();
- ctx.rect(item.x + 2, item.y + 1, item.width - 4, item.height - 2);
- ctx.clip();
- ctx.fillStyle = textColor(score);
- ctx.font = `${item.height >= 30 ? 9 : 7}px Inter, "PingFang SC", sans-serif`;
- ctx.textBaseline = "top";
- ctx.fillText(node.name, item.x + 5, item.y + 4);
- if (item.height >= 31) {
- ctx.globalAlpha = .82;
- ctx.font = '7px Inter, "PingFang SC", sans-serif';
- ctx.fillText(score == null ? "无数据" : `Score ${formatScore(score)}`, item.x + 5, item.y + 17);
- }
- ctx.restore();
- }
- }
- document.querySelector("#map-loading").style.display = "none";
- document.body.dataset.viewScale = state.view.scale.toFixed(3);
- document.body.dataset.viewX = state.view.x.toFixed(1);
- document.body.dataset.viewY = state.view.y.toFixed(1);
- document.body.dataset.startDepth = String(activeBaseDepth());
- document.body.dataset.focusNode = state.focus?.name || "";
- document.body.dataset.selectedNode = state.selectedNode?.name || "";
- document.body.dataset.canvasHeight = String(Math.round(rect.height));
- renderBreadcrumb();
- }
- function fitView(redraw = true) {
- state.view = { scale: 1, x: 0, y: 0 };
- if (redraw) {
- const viewport = document.querySelector("#icicle-viewport");
- viewport.scrollTop = 0;
- viewport.scrollLeft = 0;
- drawIcicle();
- }
- }
- function zoomAt(factor, screenX, screenY) {
- const oldScale = state.view.scale;
- const nextScale = Math.max(1, Math.min(18, oldScale * factor));
- const worldX = (screenX - state.view.x) / oldScale;
- const worldY = (screenY - state.view.y) / oldScale;
- state.view.scale = nextScale;
- state.view.x = screenX - worldX * nextScale;
- state.view.y = screenY - worldY * nextScale;
- drawIcicle();
- }
- function nodeAt(clientX, clientY) {
- const rect = state.canvas.getBoundingClientRect();
- const x = clientX - rect.left;
- const y = clientY - rect.top;
- let match = null;
- for (const item of state.rects) {
- if (x >= item.x && x <= item.x + item.width && y >= item.y && y <= item.y + item.height) {
- if (!match || item.node.path.length > match.node.path.length) match = item;
- }
- }
- return match?.node || null;
- }
- function scrollSelectedNodeIntoView() {
- if (!state.selectedNode) return;
- const item = state.rects.find(rect => rect.node === state.selectedNode);
- if (!item) return;
- const viewport = document.querySelector("#icicle-viewport");
- viewport.scrollTop = Math.max(0, item.y - viewport.clientHeight * .38);
- viewport.scrollLeft = Math.max(0, item.x - viewport.clientWidth * .18);
- }
- function demandInFocus(demand) {
- if (!state.focus) return true;
- const focusPath = state.focus.path.join(" / ");
- return demand.attachments.some(item => item.path.startsWith(focusPath));
- }
- function filteredDemands() {
- const query = state.query.toLowerCase();
- return state.demands.filter(demand => {
- if (state.granularity !== "all" && demand.granularity !== state.granularity) return false;
- if (!demandInFocus(demand)) return false;
- if (!query) return true;
- return demand.name.toLowerCase().includes(query)
- || demand.attachments.some(item => item.path.toLowerCase().includes(query));
- });
- }
- function renderDemandList() {
- const demands = filteredDemands();
- document.querySelector("#demand-count").textContent = demands.length.toLocaleString("zh-CN");
- document.querySelector("#demand-context").textContent = state.focus
- ? `当前聚焦“${state.focus.name}”分支;点击回到全局可恢复全部结果。`
- : "从全树自动识别,不要求逐节点点击。";
- document.querySelector("#demand-list").innerHTML = demands.slice(0, 250).map(demand => `
- <article class="demand-card ${state.selectedDemand === demand ? "selected" : ""}" data-demand-id="${esc(demand.id)}">
- <div class="demand-card-head">
- <h3>${esc(demand.name)}</h3>
- <span class="granularity">${esc(demand.granularity)}需求</span>
- </div>
- <div class="score-row">
- <span>${demand.heatScore == null ? "热度待验证" : `热度 ${formatScore(demand.heatScore)}`}</span>
- <span>可信度 ${formatScore(demand.confidence)}</span>
- <span class="path-count">${demand.attachments.length} 个挂靠点</span>
- ${demand.hasObservedData ? "" : `<span class="missing-data">后验待验证</span>`}
- </div>
- <p class="card-reason"><strong>模型 Reason:</strong>${esc(demand.modelReason)}</p>
- </article>
- `).join("") || `<div class="empty-state">当前条件下没有匹配需求。冷区节点仍保留在左侧全局图中。</div>`;
- }
- function renderDemandAttachments(demand) {
- const target = document.querySelector("#demand-attachments");
- if (!demand) {
- target.className = "demand-attachments";
- target.innerHTML = "";
- return;
- }
- target.className = "demand-attachments visible";
- target.innerHTML = `
- <div class="demand-attachments-head">
- <strong>“${esc(demand.name)}”的完整挂靠路径</strong>
- <span>${demand.attachments.length} 个挂靠点 · 点击定位</span>
- </div>
- ${demand.attachments.map((attachment, index) => `
- <button class="attachment-button ${state.selectedNode && String(state.selectedNode.id) === attachment.nodeId ? "active" : ""}"
- data-attachment-demand="${esc(demand.id)}" data-attachment-node="${esc(attachment.nodeId)}">
- <b>${index + 1}</b>
- <span>${esc(attachment.path)}</span>
- <em>定位 →</em>
- </button>
- `).join("")}`;
- }
- function renderMapSummary() {
- const isScoped = Boolean(state.focus) || state.startDepth > 0;
- const scope = isScoped
- ? [...new Map(state.rects.map(item => [item.node.id, item.node])).values()]
- : state.flat;
- const observed = scope.filter(node => node.score.dataState === "observed");
- const hot = scope.filter(node => (node.displayScore ?? -1) >= .6);
- const cold = scope.filter(node => node.displayScore != null && node.displayScore < .2);
- const missing = scope.filter(node => node.score.dataState === "missing");
- document.querySelector("#map-summary").innerHTML = `
- <div><strong>${hot.length.toLocaleString("zh-CN")} 个升温或高热节点</strong><br>用于定位当前信号集中的分支。</div>
- <div><strong>${cold.length.toLocaleString("zh-CN")} 个冷区节点</strong><br>继续保留,形成全局比较基线。</div>
- <div><strong>${missing.length.toLocaleString("zh-CN")} 个无直接数据节点</strong><br>无数据不等于 Score 为 0,也不等于无需求。</div>`;
- document.querySelector("#map-context").textContent = state.focus
- ? `${state.focus.path.join(" / ")} · 保留父子层级的当前分支 · ${scope.length.toLocaleString("zh-CN")} 个节点`
- : state.startDepth > 0
- ? `从第 ${state.startDepth} 级开始 · 保留向下树结构 · ${scope.length.toLocaleString("zh-CN")} 个节点`
- : `全局视图 · ${observed.length.toLocaleString("zh-CN")} 个节点具有直接热度数据`;
- }
- function relatedDemandsForNode(node) {
- const path = node.path.join(" / ");
- return state.demands
- .filter(demand => demand.attachments.some(item => item.path === path || item.path.startsWith(`${path} /`)))
- .slice(0, 8);
- }
- function renderNodeExplanation(node) {
- const related = relatedDemandsForNode(node);
- const siblings = node.parent
- ? node.parent.children.filter(item => item !== node).slice(0, 10)
- : state.roots.filter(item => item !== node);
- const dimensions = DIMENSIONS.map(dimension => {
- const raw = rawNumber(node.weights?.[dimension.key]);
- return `${dimension.label}:${raw > 0 ? formatRaw(raw) : "无数据"}`;
- }).join(";");
- document.querySelector("#explanation-panel").innerHTML = `
- <article>
- <h3>分类节点</h3>
- <h2 class="demand-title">${esc(node.name)}</h2>
- <p>${esc(node.path.join(" / "))}</p>
- <div class="score-row">
- <span>${node.displayScore == null ? "热度无数据" : `热度 ${formatScore(node.displayScore)}`}</span>
- <span>可信度 ${formatScore(node.score.confidence)}</span>
- <span class="path-count">${node.leafCount} 个叶节点</span>
- </div>
- </article>
- <article class="fact-card">
- <h3>上游数据事实</h3>
- <h2>六维信号与元素</h2>
- <p>${esc(dimensions)}</p>
- <div class="attachment-list">${node.elements.slice(0, 8).map(element =>
- `<span><strong>${esc(element.name)}</strong>${element.reason ? ` · 原始 reason:${esc(element.reason)}` : ""}</span>`
- ).join("") || "<span>该节点没有直接下挂元素。</span>"}</div>
- </article>
- <article class="model-card">
- <h3>模型解释</h3>
- <h2>该节点如何参与需求形成</h2>
- <p>${node.score.dataState === "missing"
- ? "该节点没有直接表现数据,但仍保留在全局树中。系统会继续检查其名称是否具有搜索意义、是否存在具体元素,以及父子和跨树关系。"
- : "该节点的颜色反映综合热度;颜色不直接决定它是否为需求。系统还会独立判断名称能否表达外部搜索意图。"}</p>
- <div class="attachment-list">${related.map(demand =>
- `<span>${esc(demand.name)} · ${esc(demand.granularity)}需求 · 热度 ${formatScore(demand.heatScore)}</span>`
- ).join("") || "<span>当前没有从该节点及其子树识别出需求。</span>"}</div>
- ${siblings.length ? `<p class="raw-reason">同级对比:${siblings.map(item =>
- `${esc(item.name)} ${formatScore(item.displayScore)}`
- ).join(";")}</p>` : ""}
- </article>`;
- }
- function renderDemandExplanation(demand) {
- const selectedPathNode = state.selectedNode || state.byId.get(demand.attachments[0]?.nodeId);
- const pathNodes = [];
- let current = selectedPathNode;
- while (current) {
- pathNodes.unshift(current);
- current = current.parent;
- }
- document.querySelector("#explanation-panel").innerHTML = `
- <article>
- <h3>${esc(demand.granularity)}需求</h3>
- <h2 class="demand-title">${esc(demand.name)}</h2>
- <div class="score-row">
- <span>${demand.heatScore == null ? "热度待验证" : `热度 ${formatScore(demand.heatScore)}`}</span>
- <span>可信度 ${formatScore(demand.confidence)}</span>
- <span class="path-count">${demand.attachments.length} 个挂靠点</span>
- </div>
- <div class="attachment-list">${demand.attachments.slice(0, 12).map(item =>
- `<span>${esc(item.path)}</span>`
- ).join("")}</div>
- </article>
- <article class="fact-card">
- <h3>上游数据事实</h3>
- <h2>这个结论依赖了什么数据</h2>
- <p>${esc(demand.factReason)}</p>
- <div class="attachment-list">${demand.attachments.slice(0, 8).map(item => {
- const posterior = rawNumber(item.weights.real_rov_7d) > 0 || rawNumber(item.weights.real_vov_7d) > 0
- ? `真实 ROV ${formatRaw(item.weights.real_rov_7d)} / 真实 VOV ${formatRaw(item.weights.real_vov_7d)}`
- : "真实 ROV / VOV 待验证";
- return `<span>${esc(item.nodeName)} · ${esc(posterior)}</span>`;
- }).join("")}</div>
- </article>
- <article class="model-card">
- <h3>模型 Reason</h3>
- <h2>为什么它被识别为需求</h2>
- <p>${esc(demand.modelReason)}</p>
- <p class="raw-reason">说明:这里是基于名称、树路径、元素和六维数据生成的模型解释,不是上游原始 reason。</p>
- </article>
- <article class="path-context-card">
- <h3>完整路径节点信息</h3>
- <h2>沿路径查看父节点及其子树</h2>
- <div class="path-node-grid">${pathNodes.map((node, index) => `
- <button class="${node === selectedPathNode ? "active" : ""}" data-path-info-node="${esc(node.id)}">
- <b>${index + 1}</b>
- <span><strong>${esc(node.name)}</strong><small>热度 ${formatScore(node.displayScore)} · ${node.children.length} 个直接子节点 · ${node.elements.length} 条元素</small></span>
- <em>查看父级子树 →</em>
- </button>
- `).join("")}</div>
- </article>`;
- }
- function selectNode(node, focus = false) {
- state.selectedNode = node;
- state.selectedDemand = null;
- renderDemandAttachments(null);
- if (focus) {
- state.focus = node;
- state.startDepth = node.path.length - 1;
- }
- resizeCanvas();
- renderDemandList();
- renderMapSummary();
- renderNodeExplanation(node);
- }
- function selectDemand(demand) {
- state.selectedDemand = demand;
- const primary = demand.attachments[0];
- state.selectedNode = state.byId.get(primary.nodeId) || null;
- drawIcicle();
- renderDemandList();
- renderDemandAttachments(demand);
- renderDemandExplanation(demand);
- }
- function contextAncestor(node, levels = 3) {
- let current = node;
- for (let index = 0; index < levels && current.parent; index += 1) current = current.parent;
- return current;
- }
- function focusDemandAttachment(demand, nodeId) {
- const node = state.byId.get(String(nodeId));
- if (!node) return;
- const context = contextAncestor(node, 3);
- state.selectedDemand = demand;
- state.selectedNode = node;
- state.focus = context;
- state.startDepth = context.path.length - 1;
- resizeCanvas();
- scrollSelectedNodeIntoView();
- renderDemandList();
- renderDemandAttachments(demand);
- renderMapSummary();
- renderDemandExplanation(demand);
- }
- function focusDemandPathNode(demand, node) {
- state.selectedDemand = demand;
- state.selectedNode = node;
- state.focus = node;
- state.startDepth = node.path.length - 1;
- resizeCanvas();
- document.querySelector("#icicle-viewport").scrollTop = 0;
- renderDemandList();
- renderDemandAttachments(demand);
- renderMapSummary();
- renderDemandExplanation(demand);
- }
- function resetGlobalView() {
- state.focus = null;
- state.startDepth = 0;
- state.selectedNode = null;
- state.selectedDemand = null;
- renderDemandAttachments(null);
- resizeCanvas();
- renderDemandList();
- renderMapSummary();
- document.querySelector("#explanation-panel").innerHTML =
- `<div class="explanation-empty">选择热力图节点或右侧需求,查看路径、多个挂靠点、数据事实和模型 Reason。</div>`;
- }
- function bindEvents() {
- document.querySelector("#reset-view").addEventListener("click", resetGlobalView);
- document.querySelector("#fit-view").addEventListener("click", () => fitView(true));
- document.querySelector("#zoom-in").addEventListener("click", () => {
- const rect = state.canvas.getBoundingClientRect();
- zoomAt(1.35, rect.width / 2, rect.height / 2);
- });
- document.querySelector("#zoom-out").addEventListener("click", () => {
- const rect = state.canvas.getBoundingClientRect();
- zoomAt(.74, rect.width / 2, rect.height / 2);
- });
- document.querySelector("#parent-view").addEventListener("click", () => {
- const current = state.selectedNode || state.focus;
- if (!current?.parent) return;
- if (state.selectedDemand) focusDemandPathNode(state.selectedDemand, current.parent);
- else selectNode(current.parent, true);
- });
- document.querySelector("#depth-axis").addEventListener("click", event => {
- const button = event.target.closest("[data-start-depth]");
- if (!button) return;
- const selectedDepth = Number(button.dataset.startDepth);
- if (selectedDepth === 0) {
- resetGlobalView();
- return;
- }
- state.focus = null;
- state.selectedNode = null;
- state.selectedDemand = null;
- state.startDepth = selectedDepth;
- renderDemandAttachments(null);
- resizeCanvas();
- document.querySelector("#icicle-viewport").scrollTop = 0;
- renderDemandList();
- renderMapSummary();
- });
- document.querySelector("#breadcrumb-bar").addEventListener("click", event => {
- if (event.target.closest("[data-reset-breadcrumb]")) {
- resetGlobalView();
- return;
- }
- const button = event.target.closest("[data-breadcrumb-id]");
- if (!button) return;
- const node = state.byId.get(button.dataset.breadcrumbId);
- if (!node) return;
- if (state.selectedDemand) focusDemandPathNode(state.selectedDemand, node);
- else selectNode(node, true);
- });
- document.querySelector("#global-search").addEventListener("input", event => {
- state.query = event.target.value.trim();
- drawIcicle();
- renderDemandList();
- });
- document.querySelector("#demand-filters").addEventListener("click", event => {
- const button = event.target.closest("[data-granularity]");
- if (!button) return;
- state.granularity = button.dataset.granularity;
- document.querySelectorAll("[data-granularity]").forEach(item =>
- item.classList.toggle("active", item === button)
- );
- renderDemandList();
- });
- document.querySelector("#demand-list").addEventListener("click", event => {
- const card = event.target.closest("[data-demand-id]");
- if (!card) return;
- selectDemand(state.demandById.get(card.dataset.demandId));
- });
- document.querySelector("#demand-attachments").addEventListener("click", event => {
- const button = event.target.closest("[data-attachment-node]");
- if (!button) return;
- const demand = state.demandById.get(button.dataset.attachmentDemand);
- if (demand) focusDemandAttachment(demand, button.dataset.attachmentNode);
- });
- document.querySelector("#explanation-panel").addEventListener("click", event => {
- const button = event.target.closest("[data-path-info-node]");
- if (!button || !state.selectedDemand) return;
- const node = state.byId.get(button.dataset.pathInfoNode);
- if (node) focusDemandPathNode(state.selectedDemand, node);
- });
- const canvas = document.querySelector("#icicle-canvas");
- canvas.addEventListener("wheel", event => {
- if (!(event.ctrlKey || event.metaKey)) return;
- event.preventDefault();
- const rect = canvas.getBoundingClientRect();
- zoomAt(event.deltaY < 0 ? 1.15 : .87, event.clientX - rect.left, event.clientY - rect.top);
- }, { passive: false });
- canvas.addEventListener("pointerdown", event => {
- canvas.setPointerCapture(event.pointerId);
- state.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY, type: event.pointerType });
- state.drag.active = true;
- state.drag.moved = false;
- state.drag.x = event.clientX;
- state.drag.y = event.clientY;
- if (state.pointers.size === 2) {
- const points = [...state.pointers.values()];
- state.drag.pinchDistance = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
- }
- document.querySelector("#icicle-wrap").classList.add("dragging");
- });
- canvas.addEventListener("pointermove", event => {
- if (state.pointers.has(event.pointerId)) {
- state.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY, type: event.pointerType });
- if (state.pointers.size === 2) {
- const points = [...state.pointers.values()];
- const distance = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
- const centerX = (points[0].x + points[1].x) / 2 - canvas.getBoundingClientRect().left;
- const centerY = (points[0].y + points[1].y) / 2 - canvas.getBoundingClientRect().top;
- if (state.drag.pinchDistance) zoomAt(distance / state.drag.pinchDistance, centerX, centerY);
- state.drag.pinchDistance = distance;
- state.drag.moved = true;
- return;
- }
- if (state.drag.active) {
- const dx = event.clientX - state.drag.x;
- const dy = event.clientY - state.drag.y;
- if (Math.abs(dx) + Math.abs(dy) > 2) state.drag.moved = true;
- if (event.pointerType === "touch") {
- const viewport = document.querySelector("#icicle-viewport");
- viewport.scrollLeft -= dx;
- viewport.scrollTop -= dy;
- } else {
- state.view.x += dx;
- state.view.y += dy;
- }
- state.drag.x = event.clientX;
- state.drag.y = event.clientY;
- if (event.pointerType !== "touch") drawIcicle();
- return;
- }
- }
- const node = nodeAt(event.clientX, event.clientY);
- const tooltip = document.querySelector("#map-tooltip");
- if (!node) {
- tooltip.style.display = "none";
- return;
- }
- const rect = canvas.getBoundingClientRect();
- tooltip.style.display = "block";
- tooltip.style.left = `${Math.min(rect.width - 245, event.clientX - rect.left + 12)}px`;
- tooltip.style.top = `${Math.min(rect.height - 100, event.clientY - rect.top + 12)}px`;
- tooltip.innerHTML = `<strong>${esc(node.name)}</strong>
- <span>${esc(node.path.join(" / "))}</span>
- <span>热度 ${formatScore(node.displayScore)} · 可信度 ${formatScore(node.score.confidence)}</span>
- <span>${node.score.dataState === "missing" ? "该节点无直接数据,颜色可能来自子树信号" : "具有直接六维数据"}</span>`;
- });
- canvas.addEventListener("pointerleave", () => {
- document.querySelector("#map-tooltip").style.display = "none";
- });
- const endPointer = event => {
- const wasMoved = state.drag.moved;
- state.pointers.delete(event.pointerId);
- if (state.pointers.size < 2) state.drag.pinchDistance = 0;
- if (!state.pointers.size) {
- state.drag.active = false;
- document.querySelector("#icicle-wrap").classList.remove("dragging");
- if (!wasMoved) {
- const node = nodeAt(event.clientX, event.clientY);
- if (node) selectNode(node, true);
- }
- }
- };
- canvas.addEventListener("pointerup", endPointer);
- canvas.addEventListener("pointercancel", endPointer);
- canvas.addEventListener("dblclick", event => {
- const rect = canvas.getBoundingClientRect();
- zoomAt(1.8, event.clientX - rect.left, event.clientY - rect.top);
- });
- }
- function renderHeader(data) {
- const elementCount = Object.values(state.elementsByCategory).reduce((sum, items) => sum + items.length, 0);
- const observed = state.flat.filter(node => node.score.dataState === "observed").length;
- document.querySelector("#source-stats").innerHTML = `
- <span>${state.flat.length.toLocaleString("zh-CN")} 个分类节点</span>
- <span>${elementCount.toLocaleString("zh-CN")} 条上游元素</span>
- <span>${state.demands.length.toLocaleString("zh-CN")} 条平台需求</span>
- <span>${state.candidateCount.toLocaleString("zh-CN")} 条候选经过判断</span>
- <span>${observed.toLocaleString("zh-CN")} 个节点有热度数据</span>
- <span>数据日期 ${esc(data.bizDt || "—")}</span>`;
- document.body.dataset.demandCount = String(state.demands.length);
- document.body.dataset.hasWarDemand = String(state.demands.some(demand => demand.name === "战争"));
- document.body.dataset.hasAppearanceDemand = String(state.demands.some(demand => demand.name === "表象"));
- document.body.dataset.scoreRangeValid = String(state.flat.every(node =>
- node.score.heatScore == null || (node.score.heatScore >= 0 && node.score.heatScore <= 1)
- ));
- }
- async function init() {
- bindEvents();
- try {
- const html = await fetch(SOURCE_URL).then(response => {
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
- return response.text();
- });
- const source = new DOMParser().parseFromString(html, "text/html");
- const dataScript = source.querySelector("#export-data");
- if (!dataScript) throw new Error("原始页面中没有找到 export-data");
- const data = JSON.parse(dataScript.textContent);
- prepareData(data);
- renderHeader(data);
- resizeCanvas();
- renderDemandList();
- renderMapSummary();
- state.resizeObserver = new ResizeObserver(resizeCanvas);
- state.resizeObserver.observe(document.querySelector("#icicle-viewport"));
- } catch (error) {
- document.querySelector("#map-loading").textContent = `读取全局树失败:${error.message}`;
- document.querySelector("#demand-list").innerHTML =
- `<div class="empty-state">读取全局树失败:${esc(error.message)}</div>`;
- console.error(error);
- }
- }
- init();
- })();
|