(() => {
  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 => (
    { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 POSTERIOR_EFFECT_ACCEPTABLE_FLOOR = -0.2;
  const hasPosteriorSample = (weights, key) => {
    const raw = weights?.[key];
    return raw != null && raw !== "" && !Number.isNaN(Number(raw));
  };
  const classifyPosteriorEffect = value => {
    const num = Number(value);
    if (Number.isNaN(num)) return null;
    if (num > 0) return "效果非常好";
    if (num >= POSTERIOR_EFFECT_ACCEPTABLE_FLOOR) return "可接受";
    return "效果不佳";
  };
  const formatPosteriorDiff = value => {
    const num = Number(value);
    if (Number.isNaN(num)) return "无数据";
    const effect = classifyPosteriorEffect(num);
    const sign = num > 0 ? "+" : "";
    return `${sign}${num.toFixed(4)} [${effect}]`;
  };
  const formatPosteriorPair = weights => {
    const hasRov = hasPosteriorSample(weights, "real_rov_7d");
    const hasVov = hasPosteriorSample(weights, "real_vov_7d");
    if (!hasRov && !hasVov) return "后验 diff 暂无样本";
    return `ROV相对差 ${hasRov ? formatPosteriorDiff(weights.real_rov_7d) : "无数据"}、VOV相对差 ${hasVov ? formatPosteriorDiff(weights.real_vov_7d) : "无数据"}`;
  };
  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 => {
          const raw = node.weights?.[dimension.key];
          if (dimension.group === "posterior") {
            return hasPosteriorSample(node.weights, dimension.key) ? Number(raw) : null;
          }
          return rawNumber(raw) > 0 ? rawNumber(raw) : null;
        })
        .filter(value => value != null)
        .map(value => dimension.group === "posterior" ? 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 dimension = DIMENSIONS.find(item => item.key === key);
    if (dimension?.group === "posterior") {
      if (raw == null || raw === "" || Number.isNaN(Number(raw))) return null;
      const value = Number(raw);
      if (value > 0) return clamp01(0.7 + Math.min(value, 0.5) * 0.6);
      if (value >= POSTERIOR_EFFECT_ACCEPTABLE_FLOOR) {
        return clamp01(0.35 + ((value - POSTERIOR_EFFECT_ACCEPTABLE_FLOOR) / -POSTERIOR_EFFECT_ACCEPTABLE_FLOOR) * 0.35);
      }
      return clamp01(Math.max(0, 0.35 + (value - POSTERIOR_EFFECT_ACCEPTABLE_FLOOR) * 0.5));
    }
    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 = formatPosteriorPair(primary.weights);
    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 = node.weights?.[dimension.key];
      if (dimension.group === "posterior") {
        return `${dimension.label}：${hasPosteriorSample(node.weights, dimension.key) ? formatPosteriorDiff(raw) : "无数据"}`;
      }
      const value = rawNumber(raw);
      return `${dimension.label}：${value > 0 ? formatRaw(value) : "无数据"}`;
    }).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 = formatPosteriorPair(item.weights);
          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();
})();
