Explorar el Código

前端增加全局树,和数据地图

xueyiming hace 2 semanas
padre
commit
3b6c49162b

+ 1 - 0
web/src/App.vue

@@ -12,6 +12,7 @@ const currentTitle = computed(() => (route.meta.title as string) || 'SupplyAgent
       <RouterLink class="brand" to="/">SupplyAgent</RouterLink>
       <div class="links">
         <RouterLink to="/" exact-active-class="active">全局分类树</RouterLink>
+        <RouterLink to="/demand-map" active-class="active">平台全局需求地图</RouterLink>
         <RouterLink to="/demand-process" active-class="active">需求归类过程</RouterLink>
       </div>
       <span class="current">{{ currentTitle }}</span>

+ 1134 - 0
web/src/components/IcicleHeatTree.vue

@@ -0,0 +1,1134 @@
+<script setup lang="ts">
+import {
+  computed,
+  onMounted,
+  onUnmounted,
+  ref,
+  shallowRef,
+  watch,
+} from 'vue'
+import type { CategoryNode, WeightDimKey } from '../types/category'
+import {
+  COLUMN_WIDTH,
+  FULL_TREE_TAB,
+  HEAT_DIM_TABS,
+  activeBaseDepth,
+  collectDimScale,
+  formatNodeDimScore,
+  layoutIcicle,
+  mapHeatFill,
+  mapHeatText,
+  nodeHeatT,
+  nodeStructureFill,
+  nodeTextColor,
+  prepareTree,
+  relativeMaxDepth,
+  viewRoots,
+  type HeatTabKey,
+  type IcicleRect,
+  type PreparedNode,
+} from '../types/heatTree'
+
+/** Min cell height in leftmost two columns so labels stay readable. */
+const MIN_READABLE_HEIGHT = 18
+/** Cap when focused/view roots have no children. */
+const MAX_LEAF_BLOCK_HEIGHT = 64
+/**
+ * Max content height when drilling down.
+ * Prefer growing height (not viewScale) so column width stays 200px.
+ * Canvas itself stays viewport-sized; this only sizes the scroll area.
+ */
+const MAX_CANVAS_HEIGHT = 24000
+/** Padding around content wrap. */
+const CONTENT_PAD = 8
+
+/**
+ * Smallest leafCount among nodes in the leftmost two columns
+ * (view roots + their direct children).
+ */
+function minLeafInLeftTwoColumns(roots: PreparedNode[]): number {
+  let minLeaf = Infinity
+  for (const root of roots) {
+    minLeaf = Math.min(minLeaf, root.leafCount)
+    for (const child of root.children) {
+      minLeaf = Math.min(minLeaf, child.leafCount)
+    }
+  }
+  return Number.isFinite(minLeaf) && minLeaf > 0 ? minLeaf : 1
+}
+
+const props = defineProps<{
+  nodes: CategoryNode[]
+  bizDt?: string | null
+}>()
+
+const canvasRef = ref<HTMLCanvasElement | null>(null)
+const viewportRef = ref<HTMLElement | null>(null)
+const wrapRef = ref<HTMLElement | null>(null)
+
+const prepared = shallowRef(prepareTree([]))
+const activeTab = ref<HeatTabKey>(FULL_TREE_TAB)
+const startDepth = ref(0)
+const focus = ref<PreparedNode | null>(null)
+const selectedNode = ref<PreparedNode | null>(null)
+
+const viewScale = ref(1)
+const viewX = ref(0)
+const viewY = ref(0)
+const isDragging = ref(false)
+
+const tooltipVisible = ref(false)
+const tooltipX = ref(0)
+const tooltipY = ref(0)
+const tooltipNode = ref<PreparedNode | null>(null)
+
+/** Plain arrays — avoid Vue reactivity on thousands of rects. */
+let baseRects: IcicleRect[] = []
+let drawRects: IcicleRect[] = []
+let layoutCacheKey = ''
+let fillCache = new Map<number, string>()
+let textCache = new Map<number, string>()
+
+let resizeObserver: ResizeObserver | null = null
+let dpr = 1
+let drawRaf = 0
+let scrollPending = false
+/** After navigation: reset transform so width stays 200px/col. */
+let needResetTransform = false
+let applyingExtent = false
+let lastExtentKey = ''
+
+const drag = {
+  active: false,
+  moved: false,
+  x: 0,
+  y: 0,
+  pointerId: -1,
+}
+
+const maxDepth = computed(() => prepared.value.maxDepth)
+const baseDepth = computed(() => activeBaseDepth(startDepth.value, focus.value))
+const currentViewRoots = computed(() =>
+  viewRoots(prepared.value.roots, focus.value, startDepth.value),
+)
+
+const isFullTree = computed(() => activeTab.value === FULL_TREE_TAB)
+const activeDim = computed<WeightDimKey | null>(() =>
+  isFullTree.value ? null : (activeTab.value as WeightDimKey),
+)
+
+const weightScale = computed(() => {
+  if (!activeDim.value) return [] as number[]
+  return collectDimScale(prepared.value.flat, activeDim.value)
+})
+
+const treeTabs = computed(() => [
+  { key: FULL_TREE_TAB as HeatTabKey, label: '全局树' },
+  ...HEAT_DIM_TABS.map((d) => ({ key: d.key as HeatTabKey, label: d.label })),
+])
+
+const activeDimLabel = computed(() => {
+  if (!activeDim.value) return null
+  return HEAT_DIM_TABS.find((d) => d.key === activeDim.value)?.label ?? activeDim.value
+})
+
+const mapContext = computed(() => {
+  const total = prepared.value.flat.length
+  const heatHint = activeDim.value
+    ? ` · ${activeDimLabel.value}色阶 · 无数据为白`
+    : ''
+  if (focus.value) {
+    return `${focus.value.path.join(' / ')} · 当前分支 · 共 ${total} 个节点${heatHint}`
+  }
+  if (startDepth.value > 0) {
+    return `从第 ${startDepth.value} 级开始 · ${currentViewRoots.value.length} 个可视根 · 共 ${total} 个节点${heatHint}`
+  }
+  return `全局视图 · 横向为层级,纵向为分支规模 · ${total} 个节点${heatHint}`
+})
+
+const breadcrumbNodes = computed(() => {
+  const pathNodes: PreparedNode[] = []
+  let current = selectedNode.value || focus.value
+  while (current) {
+    pathNodes.unshift(current)
+    current = current.parent
+  }
+  return pathNodes
+})
+
+function scheduleDraw() {
+  cancelAnimationFrame(drawRaf)
+  drawRaf = requestAnimationFrame(() => draw())
+}
+
+watch(
+  () => props.nodes,
+  (nodes) => {
+    prepared.value = prepareTree(nodes)
+    invalidatePaintCache()
+    resetGlobalView()
+  },
+  { immediate: true },
+)
+
+watch(activeTab, () => {
+  invalidatePaintCache()
+  scheduleDraw()
+})
+
+function invalidatePaintCache() {
+  layoutCacheKey = ''
+  fillCache = new Map()
+  textCache = new Map()
+}
+
+function cellFill(node: PreparedNode): string {
+  const cached = fillCache.get(node.id)
+  if (cached) return cached
+  const color = !activeDim.value
+    ? nodeStructureFill(node)
+    : mapHeatFill(nodeHeatT(node, activeDim.value, weightScale.value))
+  fillCache.set(node.id, color)
+  return color
+}
+
+function cellText(node: PreparedNode): string {
+  const cached = textCache.get(node.id)
+  if (cached) return cached
+  const color = !activeDim.value
+    ? nodeTextColor()
+    : mapHeatText(nodeHeatT(node, activeDim.value, weightScale.value))
+  textCache.set(node.id, color)
+  return color
+}
+
+function onTabClick(key: HeatTabKey) {
+  if (activeTab.value === key) return
+  activeTab.value = key
+}
+
+function resetViewTransform() {
+  viewScale.value = 1
+  viewX.value = 0
+  viewY.value = 0
+}
+
+function resetGlobalView() {
+  startDepth.value = 0
+  focus.value = null
+  selectedNode.value = null
+  hideTooltip()
+  scrollPending = false
+  needResetTransform = false
+  lastExtentKey = ''
+  resetViewTransform()
+  if (viewportRef.value) {
+    viewportRef.value.scrollTop = 0
+    viewportRef.value.scrollLeft = 0
+  }
+  scheduleDraw()
+}
+
+function selectNode(node: PreparedNode, withFocus = false) {
+  selectedNode.value = node
+  if (withFocus) {
+    focus.value = node
+    startDepth.value = node.path.length - 1
+  }
+  hideTooltip()
+  scrollPending = false
+  needResetTransform = true
+  lastExtentKey = ''
+  layoutCacheKey = ''
+  resetViewTransform()
+  if (viewportRef.value) {
+    viewportRef.value.scrollTop = 0
+    viewportRef.value.scrollLeft = 0
+  }
+  scheduleDraw()
+}
+
+function onDepthClick(depth: number) {
+  if (depth === 0) {
+    resetGlobalView()
+    return
+  }
+  focus.value = null
+  selectedNode.value = null
+  startDepth.value = depth
+  hideTooltip()
+  scrollPending = false
+  needResetTransform = true
+  lastExtentKey = ''
+  layoutCacheKey = ''
+  resetViewTransform()
+  if (viewportRef.value) {
+    viewportRef.value.scrollTop = 0
+    viewportRef.value.scrollLeft = 0
+  }
+  scheduleDraw()
+}
+
+function onBreadcrumbClick(node: PreparedNode) {
+  selectNode(node, true)
+}
+
+function computeExtent(viewportHeight: number): { width: number; height: number } {
+  const roots = currentViewRoots.value
+  const depth = baseDepth.value
+  const columns = Math.max(1, relativeMaxDepth(roots, depth) + 1)
+  // Width is always N × 200px — never scaled by auto-fit.
+  const width = columns * COLUMN_WIDTH + CONTENT_PAD
+
+  if (!roots.length) {
+    return { width, height: Math.max(320, viewportHeight) }
+  }
+
+  const totalLeaves = roots.reduce((sum, node) => sum + node.leafCount, 0)
+  const scoped = startDepth.value > 0 || Boolean(focus.value)
+  const hasChildren = roots.some((node) => node.children.length > 0)
+
+  // Overview (root): fill viewport height, fixed column widths.
+  if (!scoped) {
+    return {
+      width,
+      height: Math.max(320, viewportHeight),
+    }
+  }
+
+  // Leaf-only drill-down: capped block height (already readable).
+  if (!hasChildren || totalLeaves <= 0) {
+    return {
+      width,
+      height: Math.min(
+        MAX_CANVAS_HEIGHT,
+        Math.max(MAX_LEAF_BLOCK_HEIGHT, roots.length * MAX_LEAF_BLOCK_HEIGHT),
+      ),
+    }
+  }
+
+  // Grow height only so leftmost-two-column thinnest cell >= MIN_READABLE_HEIGHT.
+  // Do not use viewScale here — that would also widen the 200px columns.
+  const minLeaf = minLeafInLeftTwoColumns(roots)
+  const idealHeight = (MIN_READABLE_HEIGHT * totalLeaves) / minLeaf
+  const height = Math.max(
+    MIN_READABLE_HEIGHT,
+    Math.min(MAX_CANVAS_HEIGHT, idealHeight),
+  )
+
+  return { width, height }
+}
+
+function applyWrapSize(width: number, height: number) {
+  const wrap = wrapRef.value
+  if (!wrap) return
+  const key = `${startDepth.value}:${focus.value?.id ?? 'none'}:${Math.round(width)}x${Math.round(height)}`
+  if (
+    key === lastExtentKey &&
+    wrap.style.height === `${height}px` &&
+    wrap.style.width === `${width}px`
+  ) {
+    return
+  }
+  lastExtentKey = key
+  applyingExtent = true
+  wrap.style.width = `${width}px`
+  wrap.style.height = `${height}px`
+  queueMicrotask(() => {
+    applyingExtent = false
+  })
+}
+
+function fitView() {
+  needResetTransform = true
+  lastExtentKey = ''
+  layoutCacheKey = ''
+  resetViewTransform()
+  if (viewportRef.value) {
+    viewportRef.value.scrollTop = 0
+    viewportRef.value.scrollLeft = 0
+  }
+  scheduleDraw()
+}
+
+function zoomAt(factor: number, screenX: number, screenY: number) {
+  const oldScale = viewScale.value
+  const nextScale = Math.max(0.5, Math.min(18, oldScale * factor))
+  const worldX = (screenX - viewX.value) / oldScale
+  const worldY = (screenY - viewY.value) / oldScale
+  viewScale.value = nextScale
+  viewX.value = screenX - worldX * nextScale
+  viewY.value = screenY - worldY * nextScale
+  scheduleDraw()
+}
+
+function ensureLayout(extentHeight: number, depth: number) {
+  const roots = currentViewRoots.value
+  const key = [
+    focus.value?.id ?? 'g',
+    startDepth.value,
+    depth,
+    Math.round(extentHeight),
+    roots.length,
+  ].join(':')
+  if (key === layoutCacheKey && baseRects.length) return
+  layoutCacheKey = key
+  baseRects = layoutIcicle(roots, depth, COLUMN_WIDTH, extentHeight)
+}
+
+function draw() {
+  const canvas = canvasRef.value
+  const viewport = viewportRef.value
+  if (!canvas || !viewport || !prepared.value.flat.length) return
+
+  const vw = viewport.clientWidth
+  const vh = viewport.clientHeight
+  if (vw <= 0 || vh <= 0) return
+
+  const extent = computeExtent(vh)
+  applyWrapSize(extent.width, extent.height)
+
+  if (needResetTransform) {
+    needResetTransform = false
+    viewScale.value = 1
+    viewX.value = 0
+    viewY.value = 0
+  }
+
+  // Canvas stays viewport-sized (sticky). Never allocate a multi-thousand-px bitmap.
+  canvas.style.width = `${vw}px`
+  canvas.style.height = `${vh}px`
+  dpr = Math.min(window.devicePixelRatio || 1, 2)
+  const bw = Math.round(vw * dpr)
+  const bh = Math.round(vh * dpr)
+  if (canvas.width !== bw || canvas.height !== bh) {
+    canvas.width = bw
+    canvas.height = bh
+  }
+
+  const ctx = canvas.getContext('2d', { alpha: false })
+  if (!ctx) return
+  ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
+  ctx.fillStyle = '#f8fafc'
+  ctx.fillRect(0, 0, vw, vh)
+
+  const depth = baseDepth.value
+  ensureLayout(extent.height, depth)
+
+  const scale = viewScale.value
+  const scrollLeft = viewport.scrollLeft
+  const scrollTop = viewport.scrollTop
+  const ox = viewX.value - scrollLeft
+  const oy = viewY.value - scrollTop
+
+  const pad = 2
+  const vx0 = -pad
+  const vy0 = -pad
+  const vx1 = vw + pad
+  const vy1 = vh + pad
+  const selected = selectedNode.value
+  const strokeColor = activeDim.value ? 'rgba(15,23,42,.08)' : 'rgba(255,255,255,.88)'
+
+  drawRects = []
+
+  for (const item of baseRects) {
+    const x = item.x * scale + ox
+    const y = item.y * scale + oy
+    const width = item.width * scale
+    const height = item.height * scale
+
+    if (x + width < vx0 || y + height < vy0 || x > vx1 || y > vy1) continue
+    if (height < 0.5 || width < 0.5) continue
+
+    drawRects.push({ node: item.node, x, y, width, height })
+
+    ctx.fillStyle = cellFill(item.node)
+    ctx.fillRect(x, y, width, height)
+
+    if (height >= 3) {
+      ctx.strokeStyle = strokeColor
+      ctx.lineWidth = 0.8
+      ctx.strokeRect(x, y, width, height)
+    }
+
+    if (selected === item.node) {
+      ctx.strokeStyle = '#7c3aed'
+      ctx.lineWidth = 3
+      ctx.strokeRect(
+        x + 1.5,
+        y + 1.5,
+        Math.max(0, width - 3),
+        Math.max(0, height - 3),
+      )
+    }
+
+    if (height >= 14 && width >= 40) {
+      ctx.fillStyle = cellText(item.node)
+      const fontSize = height >= 26 ? 12 : height >= 16 ? 10 : 9
+      ctx.font = `${fontSize}px "PingFang SC", "Microsoft YaHei", sans-serif`
+      ctx.textBaseline = 'middle'
+      const maxW = width - 8
+      let text = item.node.name
+      if (ctx.measureText(text).width > maxW) {
+        const approx = Math.max(1, Math.floor(maxW / (fontSize * 0.95)))
+        text = `${item.node.name.slice(0, approx)}…`
+      }
+      ctx.fillText(text, x + 5, y + Math.min(height / 2, fontSize + 4))
+    }
+  }
+
+  if (scrollPending) {
+    scrollPending = false
+    scrollSelectedIntoView()
+  }
+}
+
+function nodeAt(clientX: number, clientY: number): PreparedNode | null {
+  const canvas = canvasRef.value
+  const viewport = viewportRef.value
+  if (!canvas || !viewport || !baseRects.length) return null
+  const rect = canvas.getBoundingClientRect()
+  const scale = viewScale.value || 1
+  const contentX = (clientX - rect.left - viewX.value + viewport.scrollLeft) / scale
+  const contentY = (clientY - rect.top - viewY.value + viewport.scrollTop) / scale
+
+  let match: IcicleRect | null = null
+  for (let i = baseRects.length - 1; i >= 0; i -= 1) {
+    const item = baseRects[i]
+    if (
+      contentX >= item.x &&
+      contentX <= item.x + item.width &&
+      contentY >= item.y &&
+      contentY <= item.y + item.height
+    ) {
+      if (!match || item.node.path.length > match.node.path.length) match = item
+    }
+  }
+  return match?.node ?? null
+}
+
+function scrollSelectedIntoView() {
+  if (!selectedNode.value) return
+  const item = baseRects.find((rect) => rect.node === selectedNode.value)
+  const viewport = viewportRef.value
+  if (!item || !viewport) return
+
+  viewport.scrollTop = Math.max(0, item.y)
+  viewport.scrollLeft = Math.max(0, item.x)
+  viewX.value = 0
+  viewY.value = 0
+  scheduleDraw()
+}
+
+function hideTooltip() {
+  tooltipVisible.value = false
+  tooltipNode.value = null
+}
+
+function updateTooltip(event: PointerEvent, node: PreparedNode) {
+  const viewport = viewportRef.value
+  if (!viewport) return
+  const rect = viewport.getBoundingClientRect()
+  tooltipNode.value = node
+  tooltipVisible.value = true
+  tooltipX.value = Math.min(rect.width - 260, event.clientX - rect.left + 14)
+  tooltipY.value = Math.min(rect.height - 120, event.clientY - rect.top + 14)
+}
+
+function onPointerDown(event: PointerEvent) {
+  if (event.button !== 0) return
+  const canvas = canvasRef.value
+  if (!canvas) return
+  canvas.setPointerCapture(event.pointerId)
+  drag.active = true
+  drag.moved = false
+  drag.x = event.clientX
+  drag.y = event.clientY
+  drag.pointerId = event.pointerId
+  isDragging.value = true
+  hideTooltip()
+}
+
+function onPointerMove(event: PointerEvent) {
+  if (drag.active && drag.pointerId === event.pointerId) {
+    const dx = event.clientX - drag.x
+    const dy = event.clientY - drag.y
+    if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true
+
+    // At 1x scale, prefer native scroll (cheaper than redrawing every move).
+    if (viewScale.value === 1 && viewportRef.value) {
+      viewportRef.value.scrollLeft -= dx
+      viewportRef.value.scrollTop -= dy
+    } else {
+      viewX.value += dx
+      viewY.value += dy
+      scheduleDraw()
+    }
+    drag.x = event.clientX
+    drag.y = event.clientY
+    return
+  }
+
+  if (drag.active) return
+
+  const node = nodeAt(event.clientX, event.clientY)
+  if (!node) {
+    if (tooltipVisible.value) hideTooltip()
+    return
+  }
+
+  // Tooltip only — no canvas redraw on hover (major lag source).
+  updateTooltip(event, node)
+}
+
+function onPointerUp(event: PointerEvent) {
+  if (drag.pointerId !== event.pointerId) return
+  const wasMoved = drag.moved
+  drag.active = false
+  drag.pointerId = -1
+  isDragging.value = false
+
+  if (!wasMoved) {
+    const node = nodeAt(event.clientX, event.clientY)
+    if (node) selectNode(node, true)
+  }
+}
+
+function onPointerLeave() {
+  if (drag.active) return
+  if (!tooltipVisible.value) return
+  hideTooltip()
+}
+
+function canvasCenter(): { x: number; y: number } {
+  const canvas = canvasRef.value
+  if (!canvas) return { x: 0, y: 0 }
+  const rect = canvas.getBoundingClientRect()
+  return { x: rect.width / 2, y: rect.height / 2 }
+}
+
+function zoomIn() {
+  const { x, y } = canvasCenter()
+  zoomAt(1.35, x, y)
+}
+
+function zoomOut() {
+  const { x, y } = canvasCenter()
+  zoomAt(0.74, x, y)
+}
+
+function onWheel(event: WheelEvent) {
+  if (!(event.ctrlKey || event.metaKey)) return
+  event.preventDefault()
+  const canvas = canvasRef.value
+  if (!canvas) return
+  const rect = canvas.getBoundingClientRect()
+  zoomAt(
+    event.deltaY < 0 ? 1.15 : 0.87,
+    event.clientX - rect.left,
+    event.clientY - rect.top,
+  )
+}
+
+function onScroll() {
+  // Sticky canvas + scroll offset — redraw visible window only.
+  scheduleDraw()
+}
+
+function onResize() {
+  if (applyingExtent) return
+  scheduleDraw()
+}
+
+onMounted(() => {
+  const viewport = viewportRef.value
+  if (viewport) {
+    resizeObserver = new ResizeObserver(onResize)
+    resizeObserver.observe(viewport)
+    viewport.addEventListener('scroll', onScroll, { passive: true })
+  }
+  scheduleDraw()
+})
+
+onUnmounted(() => {
+  resizeObserver?.disconnect()
+  viewportRef.value?.removeEventListener('scroll', onScroll)
+  cancelAnimationFrame(drawRaf)
+})
+</script>
+
+<template>
+  <div class="icicle-tree">
+    <header class="toolbar">
+      <div class="title-row">
+        <h1>平台全局需求地图</h1>
+        <span v-if="bizDt" class="biz-dt">biz_dt {{ bizDt }}</span>
+      </div>
+      <p class="subtitle">完整分类树 · 横向为层级,纵向为分支规模</p>
+    </header>
+
+    <section class="map-panel">
+      <div class="dim-bar">
+        <div class="tabs" role="tablist" aria-label="热度维度">
+          <button
+            v-for="tab in treeTabs"
+            :key="tab.key"
+            type="button"
+            role="tab"
+            class="tab"
+            :class="{ active: activeTab === tab.key }"
+            :aria-selected="activeTab === tab.key"
+            @click="onTabClick(tab.key)"
+          >
+            {{ tab.label }}
+          </button>
+        </div>
+        <div v-if="!isFullTree" class="legend" aria-hidden="true">
+          <span class="legend-label">无数据</span>
+          <span class="legend-swatch missing" />
+          <span class="legend-label">冷</span>
+          <div class="legend-bar" />
+          <span class="legend-label">热</span>
+        </div>
+      </div>
+
+      <div class="map-toolbar">
+        <p class="map-context">{{ mapContext }}</p>
+        <div class="view-actions">
+          <button type="button" class="btn btn-secondary" @click="resetGlobalView">
+            回到全局
+          </button>
+        </div>
+      </div>
+
+      <div
+        class="depth-axis"
+        :style="{ gridTemplateColumns: `repeat(${maxDepth + 1}, minmax(48px, 1fr))` }"
+      >
+        <button
+          v-for="depth in maxDepth + 1"
+          :key="depth - 1"
+          type="button"
+          :class="{ active: depth - 1 === baseDepth }"
+          @click="onDepthClick(depth - 1)"
+        >
+          {{ depth - 1 === 0 ? '根' : `第 ${depth - 1} 级` }}
+        </button>
+      </div>
+
+      <nav v-if="breadcrumbNodes.length" class="breadcrumb-bar" aria-label="当前路径">
+        <template v-for="(node, index) in breadcrumbNodes" :key="node.id">
+          <span v-if="index > 0" class="sep">→</span>
+          <button
+            type="button"
+            :class="{ current: index === breadcrumbNodes.length - 1 }"
+            @click="onBreadcrumbClick(node)"
+          >
+            {{ node.name }}
+          </button>
+        </template>
+      </nav>
+      <nav v-else class="breadcrumb-bar">
+        <button type="button" class="current" @click="resetGlobalView">全局树</button>
+      </nav>
+
+      <div ref="viewportRef" class="icicle-viewport">
+        <div ref="wrapRef" class="icicle-wrap">
+          <canvas
+            ref="canvasRef"
+            class="icicle-canvas"
+            :class="{ dragging: isDragging }"
+            aria-label="全局分类树冰柱图"
+            @pointerdown="onPointerDown"
+            @pointermove="onPointerMove"
+            @pointerup="onPointerUp"
+            @pointercancel="onPointerUp"
+            @pointerleave="onPointerLeave"
+            @wheel="onWheel"
+          />
+        </div>
+        <div
+          v-if="tooltipVisible && tooltipNode"
+          class="map-tooltip"
+          :style="{ left: `${tooltipX}px`, top: `${tooltipY}px` }"
+        >
+          <strong>{{ tooltipNode.name }}</strong>
+          <span>{{ tooltipNode.path.join(' / ') }}</span>
+          <span v-if="activeDim && activeDimLabel">
+            {{ activeDimLabel }}:{{ formatNodeDimScore(tooltipNode, activeDim) }}
+          </span>
+          <span>
+            第 {{ tooltipNode.path.length - 1 }} 级
+            · {{ tooltipNode.children.length }} 个子节点
+            · {{ tooltipNode.leafCount }} 个叶节点
+          </span>
+          <span v-if="tooltipNode.hung_word_count != null">
+            挂靠词 {{ tooltipNode.hung_word_count }}
+          </span>
+          <span v-if="tooltipNode.description">{{ tooltipNode.description }}</span>
+        </div>
+        <div class="map-guide">
+          <strong>阅读方式</strong>
+          <span>滚轮浏览;拖拽平移;Ctrl/⌘+滚轮缩放;点击聚焦分支。</span>
+        </div>
+        <div class="zoom-controls">
+          <button type="button" title="缩小" @click="zoomOut">−</button>
+          <button type="button" title="自适应可读高度" @click="fitView">适配</button>
+          <button type="button" title="放大" @click="zoomIn">+</button>
+        </div>
+      </div>
+    </section>
+  </div>
+</template>
+
+<style scoped>
+.icicle-tree {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  height: 100%;
+  min-height: 0;
+}
+
+.toolbar {
+  flex-shrink: 0;
+  padding-bottom: 12px;
+  border-bottom: 1px solid #e2e8f0;
+}
+
+.title-row {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: baseline;
+  gap: 10px 14px;
+}
+
+.toolbar h1 {
+  margin: 0;
+  font-size: 22px;
+  font-weight: 700;
+  color: #0f172a;
+  letter-spacing: -0.02em;
+}
+
+.subtitle {
+  margin: 6px 0 0;
+  font-size: 13px;
+  color: #64748b;
+}
+
+.biz-dt {
+  font-size: 12px;
+  color: #64748b;
+  font-variant-numeric: tabular-nums;
+}
+
+.map-panel {
+  display: grid;
+  grid-template-rows: auto auto auto auto minmax(0, 1fr);
+  flex: 1;
+  min-height: 0;
+  overflow: hidden;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  background: #fff;
+}
+
+.dim-bar {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px 16px;
+  padding: 10px 14px;
+  border-bottom: 1px solid #e8edf2;
+  background: #f8fafc;
+}
+
+.tabs {
+  display: inline-flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  padding: 4px;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  background: #fff;
+}
+
+.tab {
+  height: 32px;
+  padding: 0 12px;
+  border: 1px solid transparent;
+  border-radius: 7px;
+  background: transparent;
+  color: #475569;
+  font-size: 13px;
+  font-weight: 500;
+  cursor: pointer;
+}
+
+.tab:hover {
+  color: #0f172a;
+  background: #f1f5f9;
+}
+
+.tab.active {
+  background: #0f172a;
+  border-color: #0f172a;
+  color: #fff;
+}
+
+.legend {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.legend-label {
+  font-size: 11px;
+  color: #94a3b8;
+}
+
+.legend-swatch {
+  width: 14px;
+  height: 10px;
+  border-radius: 2px;
+  border: 1px solid #e2e8f0;
+}
+
+.legend-swatch.missing {
+  background: #ffffff;
+}
+
+.legend-bar {
+  width: 120px;
+  height: 10px;
+  border-radius: 999px;
+  border: 1px solid rgba(15, 23, 42, 0.08);
+  background: linear-gradient(
+    90deg,
+    rgb(219, 234, 254) 0%,
+    rgb(125, 211, 252) 25%,
+    rgb(52, 211, 153) 50%,
+    rgb(251, 191, 36) 75%,
+    rgb(239, 68, 68) 100%
+  );
+}
+
+.map-toolbar {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px 16px;
+  padding: 12px 14px;
+  border-bottom: 1px solid #e8edf2;
+}
+
+.map-context {
+  margin: 0;
+  font-size: 13px;
+  color: #475569;
+}
+
+.view-actions {
+  display: flex;
+  gap: 8px;
+}
+
+.btn {
+  height: 32px;
+  padding: 0 12px;
+  border: 1px solid #334155;
+  border-radius: 6px;
+  background: #0f172a;
+  color: #fff;
+  font-size: 13px;
+  cursor: pointer;
+}
+
+.btn:hover:not(:disabled) {
+  background: #1e293b;
+}
+
+.btn-secondary {
+  background: #fff;
+  color: #0f172a;
+  border-color: #cbd5e1;
+}
+
+.btn-secondary:hover:not(:disabled) {
+  background: #f8fafc;
+  border-color: #94a3b8;
+}
+
+.depth-axis {
+  display: grid;
+  padding: 8px 12px;
+  gap: 4px;
+  border-bottom: 1px solid #edf1f5;
+  background: #fbfdff;
+}
+
+.depth-axis button {
+  padding: 4px 6px;
+  border: 0;
+  border-radius: 6px;
+  background: transparent;
+  color: #94a3b8;
+  font-size: 12px;
+  cursor: pointer;
+}
+
+.depth-axis button:hover {
+  background: #eff6ff;
+  color: #1d4ed8;
+}
+
+.depth-axis button.active {
+  background: #dbeafe;
+  color: #1d4ed8;
+  font-weight: 600;
+}
+
+.breadcrumb-bar {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  min-height: 34px;
+  overflow-x: auto;
+  padding: 6px 12px;
+  border-bottom: 1px solid #edf1f5;
+  background: #fff;
+}
+
+.breadcrumb-bar button {
+  flex: 0 0 auto;
+  padding: 4px 8px;
+  border: 0;
+  border-radius: 5px;
+  background: #f1f5f9;
+  color: #526174;
+  font-size: 12px;
+  cursor: pointer;
+}
+
+.breadcrumb-bar button.current {
+  background: #ede9fe;
+  color: #6d28d9;
+  font-weight: 600;
+}
+
+.sep {
+  color: #94a3b8;
+  font-size: 12px;
+}
+
+.icicle-viewport {
+  position: relative;
+  min-height: 0;
+  margin: 12px;
+  overflow: auto;
+  border: 1px solid #cbd5e1;
+  border-radius: 10px;
+  background: #f8fafc;
+  scrollbar-gutter: stable;
+}
+
+.icicle-wrap {
+  position: relative;
+  min-height: 0;
+  overflow: visible;
+  background: #f8fafc;
+}
+
+.icicle-canvas {
+  position: sticky;
+  top: 0;
+  left: 0;
+  z-index: 1;
+  display: block;
+  cursor: grab;
+  touch-action: none;
+}
+
+.icicle-canvas.dragging {
+  cursor: grabbing;
+}
+
+.map-tooltip {
+  position: absolute;
+  z-index: 8;
+  width: 240px;
+  padding: 10px 12px;
+  border: 1px solid #c4b5fd;
+  border-radius: 8px;
+  background: rgba(255, 255, 255, 0.97);
+  color: #334155;
+  box-shadow: 0 9px 24px rgba(15, 23, 42, 0.14);
+  font-size: 12px;
+  line-height: 1.55;
+  pointer-events: none;
+}
+
+.map-tooltip strong {
+  display: block;
+  margin-bottom: 4px;
+  color: #5b21b6;
+  font-size: 13px;
+}
+
+.map-tooltip span {
+  display: block;
+  color: #64748b;
+}
+
+.map-guide {
+  position: absolute;
+  right: 10px;
+  top: 10px;
+  z-index: 5;
+  width: 200px;
+  padding: 10px 12px;
+  border: 1px solid #bfdbfe;
+  border-radius: 8px;
+  background: rgba(255, 255, 255, 0.94);
+  color: #526174;
+  box-shadow: 0 7px 18px rgba(15, 23, 42, 0.07);
+  pointer-events: none;
+  font-size: 11px;
+  line-height: 1.5;
+}
+
+.map-guide strong {
+  display: block;
+  margin-bottom: 4px;
+  color: #1d4ed8;
+  font-size: 12px;
+}
+
+.zoom-controls {
+  position: absolute;
+  right: 10px;
+  bottom: 10px;
+  z-index: 6;
+  display: flex;
+  gap: 4px;
+}
+
+.zoom-controls button {
+  min-width: 32px;
+  height: 30px;
+  padding: 0 8px;
+  border: 1px solid #bfdbfe;
+  border-radius: 6px;
+  background: #fff;
+  color: #1d4ed8;
+  font-size: 14px;
+  cursor: pointer;
+}
+
+.zoom-controls button:hover {
+  background: #eff6ff;
+}
+</style>

+ 7 - 0
web/src/router.ts

@@ -1,6 +1,7 @@
 import { createRouter, createWebHistory } from 'vue-router'
 import CategoryTreeView from './views/CategoryTreeView.vue'
 import DemandProcessView from './views/DemandProcessView.vue'
+import GlobalDemandMapView from './views/GlobalDemandMapView.vue'
 
 export const router = createRouter({
   history: createWebHistory(),
@@ -11,6 +12,12 @@ export const router = createRouter({
       component: CategoryTreeView,
       meta: { title: '全局分类树' },
     },
+    {
+      path: '/demand-map',
+      name: 'demand-map',
+      component: GlobalDemandMapView,
+      meta: { title: '平台全局需求地图' },
+    },
     {
       path: '/demand-process',
       name: 'demand-process',

+ 278 - 0
web/src/types/heatTree.ts

@@ -0,0 +1,278 @@
+import type { CategoryNode, NodeCounts, NodeWeights, WeightDimKey } from './category'
+import { buildWeightScale, formatAvgScore, weightHeatT } from './category'
+
+export interface PreparedNode {
+  id: number
+  name: string
+  level: number | null
+  description: string | null
+  hung_word_count?: number
+  weights?: Partial<NodeWeights>
+  counts?: Partial<NodeCounts>
+  parent: PreparedNode | null
+  path: string[]
+  children: PreparedNode[]
+  leafCount: number
+}
+
+export interface PreparedTree {
+  roots: PreparedNode[]
+  flat: PreparedNode[]
+  maxDepth: number
+  byId: Map<number, PreparedNode>
+}
+
+export const FULL_TREE_TAB = 'full' as const
+export type HeatTabKey = typeof FULL_TREE_TAB | WeightDimKey
+
+export const HEAT_DIM_TABS: { key: WeightDimKey; label: string }[] = [
+  { key: 'ext_pop', label: '外部热度' },
+  { key: 'plat_sust_pop', label: '平台持续热度' },
+  { key: 'plat_ly_pop', label: '去年同期热度' },
+  { key: 'recent_pop', label: '近期热度' },
+]
+
+function toPreparedNode(node: CategoryNode): Omit<PreparedNode, 'parent' | 'path' | 'leafCount'> {
+  return {
+    id: node.id,
+    name: node.name || '(未命名)',
+    level: node.level,
+    description: node.description,
+    hung_word_count: node.hung_word_count,
+    weights: node.weights,
+    counts: node.counts,
+    children: [],
+  }
+}
+
+function computeLeafCount(node: PreparedNode): number {
+  if (!node.children.length) {
+    node.leafCount = 1
+    return 1
+  }
+  node.leafCount = node.children.reduce((sum, child) => sum + computeLeafCount(child), 0)
+  return node.leafCount
+}
+
+export function prepareTree(nodes: CategoryNode[]): PreparedTree {
+  const flat: PreparedNode[] = []
+  const byId = new Map<number, PreparedNode>()
+  let maxDepth = 0
+
+  function walk(
+    source: CategoryNode[],
+    parent: PreparedNode | null,
+    path: string[],
+  ): PreparedNode[] {
+    const out: PreparedNode[] = []
+    for (const raw of source) {
+      const base = toPreparedNode(raw)
+      const node: PreparedNode = {
+        ...base,
+        parent,
+        path: [...path, base.name],
+        leafCount: 1,
+        children: [],
+      }
+      node.children = walk(raw.children ?? [], node, node.path)
+      flat.push(node)
+      byId.set(node.id, node)
+      maxDepth = Math.max(maxDepth, node.path.length - 1)
+      out.push(node)
+    }
+    return out
+  }
+
+  const roots = walk(nodes, null, [])
+  roots.forEach(computeLeafCount)
+
+  return { roots, flat, maxDepth, byId }
+}
+
+export function descendantsAtDepth(roots: PreparedNode[], targetDepth: number): PreparedNode[] {
+  const results: PreparedNode[] = []
+  const walk = (node: PreparedNode) => {
+    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
+}
+
+export function activeBaseDepth(startDepth: number, focus: PreparedNode | null): number {
+  const focusDepth = focus ? focus.path.length - 1 : 0
+  return Math.max(focusDepth, startDepth)
+}
+
+export function viewRoots(
+  roots: PreparedNode[],
+  focus: PreparedNode | null,
+  startDepth: number,
+): PreparedNode[] {
+  const scopeRoots = focus ? [focus] : roots
+  const baseDepth = activeBaseDepth(startDepth, focus)
+  const view = descendantsAtDepth(scopeRoots, baseDepth)
+  return view.length ? view : scopeRoots
+}
+
+export function relativeMaxDepth(nodes: PreparedNode[], baseDepth: number): number {
+  let maximum = 0
+  const walk = (node: PreparedNode) => {
+    maximum = Math.max(maximum, node.path.length - 1 - baseDepth)
+    node.children.forEach(walk)
+  }
+  nodes.forEach(walk)
+  return maximum
+}
+
+export interface IcicleRect {
+  node: PreparedNode
+  x: number
+  y: number
+  width: number
+  height: number
+}
+
+/** Fixed column width per level (px). */
+export const COLUMN_WIDTH = 200
+
+export function layoutIcicle(
+  roots: PreparedNode[],
+  baseDepth: number,
+  columnWidth: number,
+  canvasHeight: number,
+): IcicleRect[] {
+  if (!roots.length || columnWidth <= 0 || canvasHeight <= 0) return []
+
+  const totalLeaves = roots.reduce((sum, node) => sum + node.leafCount, 0)
+  if (totalLeaves <= 0) return []
+
+  const rows: IcicleRect[] = []
+  let yCursor = 0
+
+  const place = (node: PreparedNode, y: number, height: number) => {
+    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 = canvasHeight * (root.leafCount / totalLeaves)
+    place(root, yCursor, height)
+    yCursor += height
+  }
+
+  return rows
+}
+
+/** Depth-based neutral palette (全局树). */
+const DEPTH_COLORS = [
+  '#dbeafe',
+  '#bfdbfe',
+  '#93c5fd',
+  '#7dd3fc',
+  '#a5f3fc',
+  '#99f6e4',
+  '#bbf7d0',
+  '#d9f99d',
+  '#fde68a',
+  '#fed7aa',
+  '#fecaca',
+  '#e9d5ff',
+]
+
+export function nodeStructureFill(node: PreparedNode): string {
+  // Absolute depth so focused/level views keep the same colors as the global tree.
+  const absoluteDepth = Math.max(0, node.path.length - 1)
+  return DEPTH_COLORS[absoluteDepth % DEPTH_COLORS.length] ?? '#f1f5f9'
+}
+
+/**
+ * Map-like heat: white = no score; cool blue → teal → amber → red.
+ * t is percentile in [0, 1].
+ */
+export function mapHeatFill(t: number | null): string {
+  if (t == null) return '#ffffff'
+  const clamp = Math.min(1, Math.max(0, t))
+  const stops: [number, [number, number, number]][] = [
+    [0, [219, 234, 254]],
+    [0.25, [125, 211, 252]],
+    [0.5, [52, 211, 153]],
+    [0.75, [251, 191, 36]],
+    [1, [239, 68, 68]],
+  ]
+  let i = 0
+  while (i < stops.length - 1 && clamp > stops[i + 1][0]) i += 1
+  const [t0, c0] = stops[i]
+  const [t1, c1] = stops[Math.min(i + 1, stops.length - 1)]
+  const u = t1 === t0 ? 0 : (clamp - t0) / (t1 - t0)
+  const r = Math.round(c0[0] + (c1[0] - c0[0]) * u)
+  const g = Math.round(c0[1] + (c1[1] - c0[1]) * u)
+  const b = Math.round(c0[2] + (c1[2] - c0[2]) * u)
+  return `rgb(${r}, ${g}, ${b})`
+}
+
+export function mapHeatText(t: number | null): string {
+  if (t == null) return '#64748b'
+  if (t >= 0.72) return '#ffffff'
+  return '#0f172a'
+}
+
+export function nodeDimScore(
+  node: PreparedNode,
+  dim: WeightDimKey,
+): { weight: number | null; count: number } {
+  const count = node.counts?.[dim] ?? 0
+  const weight = node.weights?.[dim]
+  if (count <= 0 || weight == null || Number.isNaN(weight)) {
+    return { weight: null, count }
+  }
+  return { weight, count }
+}
+
+export function collectDimScale(nodes: PreparedNode[], dim: WeightDimKey): number[] {
+  const values: number[] = []
+  for (const node of nodes) {
+    const { weight, count } = nodeDimScore(node, dim)
+    if (weight != null && count > 0) values.push(weight)
+  }
+  return buildWeightScale(values)
+}
+
+export function nodeHeatT(
+  node: PreparedNode,
+  dim: WeightDimKey,
+  scale: number[],
+): number | null {
+  const { weight, count } = nodeDimScore(node, dim)
+  if (weight == null || count <= 0) return null
+  return weightHeatT(weight, scale)
+}
+
+export function formatNodeDimScore(node: PreparedNode, dim: WeightDimKey): string {
+  const { weight, count } = nodeDimScore(node, dim)
+  return formatAvgScore(weight, count)
+}
+
+export function nodeFillColor(node: PreparedNode, _baseDepth?: number): string {
+  return nodeStructureFill(node)
+}
+
+export function nodeTextColor(): string {
+  return '#1e293b'
+}

+ 49 - 0
web/src/views/GlobalDemandMapView.vue

@@ -0,0 +1,49 @@
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+import IcicleHeatTree from '../components/IcicleHeatTree.vue'
+import { fetchCategoryTree } from '../api/category'
+import type { CategoryNode } from '../types/category'
+
+const nodes = ref<CategoryNode[]>([])
+const bizDt = ref<string | null>(null)
+const loading = ref(true)
+const error = ref<string | null>(null)
+
+onMounted(async () => {
+  try {
+    const tree = await fetchCategoryTree()
+    nodes.value = tree.nodes ?? []
+    bizDt.value = tree.biz_dt ?? null
+  } catch (e) {
+    error.value = e instanceof Error ? e.message : String(e)
+  } finally {
+    loading.value = false
+  }
+})
+</script>
+
+<template>
+  <div class="view">
+    <div v-if="loading" class="state">正在加载分类树…</div>
+    <div v-else-if="error" class="state error">{{ error }}</div>
+    <IcicleHeatTree v-else :nodes="nodes" :biz-dt="bizDt" />
+  </div>
+</template>
+
+<style scoped>
+.view {
+  height: 100%;
+  min-height: 0;
+}
+
+.state {
+  padding: 64px 24px;
+  text-align: center;
+  color: #64748b;
+  font-size: 15px;
+}
+
+.state.error {
+  color: #b91c1c;
+}
+</style>