import type { CategoryNode, NodeCounts, NodeWeights, WeightDimKey } from './category' import { buildWeightScale, formatWeightScore, scoreHeatT } from './category' export interface PreparedNode { id: number name: string level: number | null description: string | null hung_word_count?: number weights?: Partial counts?: Partial parent: PreparedNode | null path: string[] children: PreparedNode[] leafCount: number } export interface PreparedTree { roots: PreparedNode[] flat: PreparedNode[] maxDepth: number byId: Map } 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: 'total_score', label: '全局热度' }, { key: 'ext_pop', label: '外部热度' }, { key: 'plat_sust_pop', label: '平台持续热度' }, { key: 'plat_ly_pop', label: '去年同期热度' }, { key: 'recent_pop', label: '近期热度' }, ] function toPreparedNode(node: CategoryNode): Omit { 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() 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] (全局热度 tab uses min-max linear instead). */ 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 scoreHeatT(dim, weight, scale) } export function formatNodeDimScore(node: PreparedNode, dim: WeightDimKey): string { const { weight, count } = nodeDimScore(node, dim) return formatWeightScore(dim, weight, count) } export function nodeFillColor(node: PreparedNode, _baseDepth?: number): string { return nodeStructureFill(node) } export function nodeTextColor(): string { return '#1e293b' }