| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279 |
- 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<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: '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<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] (全局热度 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'
- }
|