|
@@ -0,0 +1,1114 @@
|
|
|
|
|
+(() => {
|
|
|
|
|
+ const SOURCE_URL = "/全局分类树.html";
|
|
|
|
|
+ const DIMENSIONS = [
|
|
|
|
|
+ { key: "ext_pop", label: "外部热度", group: "prior", weight: .30 },
|
|
|
|
|
+ { key: "plat_sust_pop", label: "平台持续热度", group: "prior", weight: .23 },
|
|
|
|
|
+ { key: "plat_ly_pop", label: "去年同期热度", group: "prior", weight: .17 },
|
|
|
|
|
+ { key: "recent_pop", label: "近期热度", group: "prior", weight: .30 },
|
|
|
|
|
+ { key: "real_rov_7d", label: "真实 ROV", group: "posterior", weight: .50 },
|
|
|
|
|
+ { key: "real_vov_7d", label: "真实 VOV", group: "posterior", weight: .50 }
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ const STRUCTURE_ONLY = new Set([
|
|
|
|
|
+ "表象", "理念", "实体", "行为", "知识", "现象", "事件", "观念",
|
|
|
|
|
+ "内容形态", "场景", "声音", "符号", "视觉", "对象", "分类", "其他",
|
|
|
|
|
+ "个体", "方法", "概念", "社会", "个人", "事物", "属性", "关系"
|
|
|
|
|
+ ]);
|
|
|
|
|
+ const ISOLATED_ACTIONS = new Set([
|
|
|
|
|
+ "转发", "点赞", "评论", "收藏", "关注", "播放", "浏览", "点击", "分享",
|
|
|
|
|
+ "上传", "下载", "发布", "互动", "推荐"
|
|
|
|
|
+ ]);
|
|
|
|
|
+ const BROAD_DEMANDS = new Set([
|
|
|
|
|
+ "战争", "历史", "人物", "诗词", "音乐", "电影", "健康", "教育", "科技",
|
|
|
|
|
+ "美食", "旅游", "体育", "情感", "家庭", "生活", "文化", "军事", "政治"
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ const state = {
|
|
|
|
|
+ roots: [],
|
|
|
|
|
+ flat: [],
|
|
|
|
|
+ byId: new Map(),
|
|
|
|
|
+ elementsByCategory: {},
|
|
|
|
|
+ metricModel: {},
|
|
|
|
|
+ candidateCount: 0,
|
|
|
|
|
+ demands: [],
|
|
|
|
|
+ demandById: new Map(),
|
|
|
|
|
+ focus: null,
|
|
|
|
|
+ startDepth: 0,
|
|
|
|
|
+ maxDepth: 0,
|
|
|
|
|
+ selectedNode: null,
|
|
|
|
|
+ selectedDemand: null,
|
|
|
|
|
+ granularity: "all",
|
|
|
|
|
+ query: "",
|
|
|
|
|
+ canvas: null,
|
|
|
|
|
+ ctx: null,
|
|
|
|
|
+ dpr: 1,
|
|
|
|
|
+ baseRects: [],
|
|
|
|
|
+ rects: [],
|
|
|
|
|
+ view: { scale: 1, x: 0, y: 0 },
|
|
|
|
|
+ pointers: new Map(),
|
|
|
|
|
+ drag: { active: false, moved: false, x: 0, y: 0, pinchDistance: 0 },
|
|
|
|
|
+ resizeObserver: null
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const esc = value => String(value ?? "").replace(/[&<>"']/g, char => (
|
|
|
|
|
+ { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
|
|
|
|
|
+})();
|