heatTree.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. import type { CategoryNode, NodeCounts, NodeWeights, WeightDimKey } from './category'
  2. import { buildWeightScale, formatWeightScore, scoreHeatT } from './category'
  3. export interface PreparedNode {
  4. id: number
  5. name: string
  6. level: number | null
  7. description: string | null
  8. hung_word_count?: number
  9. weights?: Partial<NodeWeights>
  10. counts?: Partial<NodeCounts>
  11. parent: PreparedNode | null
  12. path: string[]
  13. children: PreparedNode[]
  14. leafCount: number
  15. }
  16. export interface PreparedTree {
  17. roots: PreparedNode[]
  18. flat: PreparedNode[]
  19. maxDepth: number
  20. byId: Map<number, PreparedNode>
  21. }
  22. export const FULL_TREE_TAB = 'full' as const
  23. export type HeatTabKey = typeof FULL_TREE_TAB | WeightDimKey
  24. export const HEAT_DIM_TABS: { key: WeightDimKey; label: string }[] = [
  25. { key: 'total_score', label: '全局热度' },
  26. { key: 'ext_pop', label: '外部热度' },
  27. { key: 'plat_sust_pop', label: '平台持续热度' },
  28. { key: 'plat_ly_pop', label: '去年同期热度' },
  29. { key: 'recent_pop', label: '近期热度' },
  30. ]
  31. function toPreparedNode(node: CategoryNode): Omit<PreparedNode, 'parent' | 'path' | 'leafCount'> {
  32. return {
  33. id: node.id,
  34. name: node.name || '(未命名)',
  35. level: node.level,
  36. description: node.description,
  37. hung_word_count: node.hung_word_count,
  38. weights: node.weights,
  39. counts: node.counts,
  40. children: [],
  41. }
  42. }
  43. function computeLeafCount(node: PreparedNode): number {
  44. if (!node.children.length) {
  45. node.leafCount = 1
  46. return 1
  47. }
  48. node.leafCount = node.children.reduce((sum, child) => sum + computeLeafCount(child), 0)
  49. return node.leafCount
  50. }
  51. export function prepareTree(nodes: CategoryNode[]): PreparedTree {
  52. const flat: PreparedNode[] = []
  53. const byId = new Map<number, PreparedNode>()
  54. let maxDepth = 0
  55. function walk(
  56. source: CategoryNode[],
  57. parent: PreparedNode | null,
  58. path: string[],
  59. ): PreparedNode[] {
  60. const out: PreparedNode[] = []
  61. for (const raw of source) {
  62. const base = toPreparedNode(raw)
  63. const node: PreparedNode = {
  64. ...base,
  65. parent,
  66. path: [...path, base.name],
  67. leafCount: 1,
  68. children: [],
  69. }
  70. node.children = walk(raw.children ?? [], node, node.path)
  71. flat.push(node)
  72. byId.set(node.id, node)
  73. maxDepth = Math.max(maxDepth, node.path.length - 1)
  74. out.push(node)
  75. }
  76. return out
  77. }
  78. const roots = walk(nodes, null, [])
  79. roots.forEach(computeLeafCount)
  80. return { roots, flat, maxDepth, byId }
  81. }
  82. export function descendantsAtDepth(roots: PreparedNode[], targetDepth: number): PreparedNode[] {
  83. const results: PreparedNode[] = []
  84. const walk = (node: PreparedNode) => {
  85. const depth = node.path.length - 1
  86. if (depth === targetDepth) {
  87. results.push(node)
  88. return
  89. }
  90. if (depth < targetDepth) node.children.forEach(walk)
  91. }
  92. roots.forEach(walk)
  93. return results
  94. }
  95. export function activeBaseDepth(startDepth: number, focus: PreparedNode | null): number {
  96. const focusDepth = focus ? focus.path.length - 1 : 0
  97. return Math.max(focusDepth, startDepth)
  98. }
  99. export function viewRoots(
  100. roots: PreparedNode[],
  101. focus: PreparedNode | null,
  102. startDepth: number,
  103. ): PreparedNode[] {
  104. const scopeRoots = focus ? [focus] : roots
  105. const baseDepth = activeBaseDepth(startDepth, focus)
  106. const view = descendantsAtDepth(scopeRoots, baseDepth)
  107. return view.length ? view : scopeRoots
  108. }
  109. export function relativeMaxDepth(nodes: PreparedNode[], baseDepth: number): number {
  110. let maximum = 0
  111. const walk = (node: PreparedNode) => {
  112. maximum = Math.max(maximum, node.path.length - 1 - baseDepth)
  113. node.children.forEach(walk)
  114. }
  115. nodes.forEach(walk)
  116. return maximum
  117. }
  118. export interface IcicleRect {
  119. node: PreparedNode
  120. x: number
  121. y: number
  122. width: number
  123. height: number
  124. }
  125. /** Fixed column width per level (px). */
  126. export const COLUMN_WIDTH = 200
  127. export function layoutIcicle(
  128. roots: PreparedNode[],
  129. baseDepth: number,
  130. columnWidth: number,
  131. canvasHeight: number,
  132. ): IcicleRect[] {
  133. if (!roots.length || columnWidth <= 0 || canvasHeight <= 0) return []
  134. const totalLeaves = roots.reduce((sum, node) => sum + node.leafCount, 0)
  135. if (totalLeaves <= 0) return []
  136. const rows: IcicleRect[] = []
  137. let yCursor = 0
  138. const place = (node: PreparedNode, y: number, height: number) => {
  139. const relativeDepth = node.path.length - 1 - baseDepth
  140. rows.push({
  141. node,
  142. x: relativeDepth * columnWidth,
  143. y,
  144. width: columnWidth,
  145. height,
  146. })
  147. let childY = y
  148. for (const child of node.children) {
  149. const childHeight = height * (child.leafCount / node.leafCount)
  150. place(child, childY, childHeight)
  151. childY += childHeight
  152. }
  153. }
  154. for (const root of roots) {
  155. const height = canvasHeight * (root.leafCount / totalLeaves)
  156. place(root, yCursor, height)
  157. yCursor += height
  158. }
  159. return rows
  160. }
  161. /** Depth-based neutral palette (全局树). */
  162. const DEPTH_COLORS = [
  163. '#dbeafe',
  164. '#bfdbfe',
  165. '#93c5fd',
  166. '#7dd3fc',
  167. '#a5f3fc',
  168. '#99f6e4',
  169. '#bbf7d0',
  170. '#d9f99d',
  171. '#fde68a',
  172. '#fed7aa',
  173. '#fecaca',
  174. '#e9d5ff',
  175. ]
  176. export function nodeStructureFill(node: PreparedNode): string {
  177. // Absolute depth so focused/level views keep the same colors as the global tree.
  178. const absoluteDepth = Math.max(0, node.path.length - 1)
  179. return DEPTH_COLORS[absoluteDepth % DEPTH_COLORS.length] ?? '#f1f5f9'
  180. }
  181. /**
  182. * Map-like heat: white = no score; cool blue → teal → amber → red.
  183. * t is percentile in [0, 1] (全局热度 tab uses min-max linear instead).
  184. */
  185. export function mapHeatFill(t: number | null): string {
  186. if (t == null) return '#ffffff'
  187. const clamp = Math.min(1, Math.max(0, t))
  188. const stops: [number, [number, number, number]][] = [
  189. [0, [219, 234, 254]],
  190. [0.25, [125, 211, 252]],
  191. [0.5, [52, 211, 153]],
  192. [0.75, [251, 191, 36]],
  193. [1, [239, 68, 68]],
  194. ]
  195. let i = 0
  196. while (i < stops.length - 1 && clamp > stops[i + 1][0]) i += 1
  197. const [t0, c0] = stops[i]
  198. const [t1, c1] = stops[Math.min(i + 1, stops.length - 1)]
  199. const u = t1 === t0 ? 0 : (clamp - t0) / (t1 - t0)
  200. const r = Math.round(c0[0] + (c1[0] - c0[0]) * u)
  201. const g = Math.round(c0[1] + (c1[1] - c0[1]) * u)
  202. const b = Math.round(c0[2] + (c1[2] - c0[2]) * u)
  203. return `rgb(${r}, ${g}, ${b})`
  204. }
  205. export function mapHeatText(t: number | null): string {
  206. if (t == null) return '#64748b'
  207. if (t >= 0.72) return '#ffffff'
  208. return '#0f172a'
  209. }
  210. export function nodeDimScore(
  211. node: PreparedNode,
  212. dim: WeightDimKey,
  213. ): { weight: number | null; count: number } {
  214. const count = node.counts?.[dim] ?? 0
  215. const weight = node.weights?.[dim]
  216. if (count <= 0 || weight == null || Number.isNaN(weight)) {
  217. return { weight: null, count }
  218. }
  219. return { weight, count }
  220. }
  221. export function collectDimScale(nodes: PreparedNode[], dim: WeightDimKey): number[] {
  222. const values: number[] = []
  223. for (const node of nodes) {
  224. const { weight, count } = nodeDimScore(node, dim)
  225. if (weight != null && count > 0) values.push(weight)
  226. }
  227. return buildWeightScale(values)
  228. }
  229. export function nodeHeatT(
  230. node: PreparedNode,
  231. dim: WeightDimKey,
  232. scale: number[],
  233. ): number | null {
  234. const { weight, count } = nodeDimScore(node, dim)
  235. if (weight == null || count <= 0) return null
  236. return scoreHeatT(dim, weight, scale)
  237. }
  238. export function formatNodeDimScore(node: PreparedNode, dim: WeightDimKey): string {
  239. const { weight, count } = nodeDimScore(node, dim)
  240. return formatWeightScore(dim, weight, count)
  241. }
  242. export function nodeFillColor(node: PreparedNode, _baseDepth?: number): string {
  243. return nodeStructureFill(node)
  244. }
  245. export function nodeTextColor(): string {
  246. return '#1e293b'
  247. }