IcicleHeatTree.vue 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559
  1. <script setup lang="ts">
  2. import {
  3. computed,
  4. onMounted,
  5. onUnmounted,
  6. ref,
  7. shallowRef,
  8. watch,
  9. } from 'vue'
  10. import DemandPathPanel from './DemandPathPanel.vue'
  11. import DemandGradeListPanel from './DemandGradeListPanel.vue'
  12. import type { DemandGradeListCard } from './DemandGradeListPanel.vue'
  13. import type { CategoryNode, WeightDimKey } from '../types/category'
  14. import {
  15. COLUMN_WIDTH,
  16. FULL_TREE_TAB,
  17. HEAT_DIM_TABS,
  18. activeBaseDepth,
  19. collectDimScale,
  20. formatNodeDimScore,
  21. layoutIcicle,
  22. mapHeatFill,
  23. mapHeatText,
  24. nodeHeatT,
  25. nodeStructureFill,
  26. nodeTextColor,
  27. prepareTree,
  28. relativeMaxDepth,
  29. viewRoots,
  30. type HeatTabKey,
  31. type IcicleRect,
  32. type PreparedNode,
  33. } from '../types/heatTree'
  34. import type { DemandGradeItem, DemandsByCategory } from '../types/demand'
  35. /** Min cell height in leftmost two columns so labels stay readable. */
  36. const MIN_READABLE_HEIGHT = 18
  37. /** Cap when focused/view roots have no children. */
  38. const MAX_LEAF_BLOCK_HEIGHT = 64
  39. /**
  40. * Max content height when drilling down.
  41. * Prefer growing height (not viewScale) so column width stays 200px.
  42. * Canvas itself stays viewport-sized; this only sizes the scroll area.
  43. */
  44. const MAX_CANVAS_HEIGHT = 24000
  45. /** Padding around content wrap. */
  46. const CONTENT_PAD = 8
  47. /**
  48. * Smallest leafCount among nodes in the leftmost two columns
  49. * (view roots + their direct children).
  50. */
  51. function minLeafInLeftTwoColumns(roots: PreparedNode[]): number {
  52. let minLeaf = Infinity
  53. for (const root of roots) {
  54. minLeaf = Math.min(minLeaf, root.leafCount)
  55. for (const child of root.children) {
  56. minLeaf = Math.min(minLeaf, child.leafCount)
  57. }
  58. }
  59. return Number.isFinite(minLeaf) && minLeaf > 0 ? minLeaf : 1
  60. }
  61. const props = defineProps<{
  62. nodes: CategoryNode[]
  63. bizDt?: string | null
  64. demandsByCategory?: DemandsByCategory
  65. }>()
  66. const canvasRef = ref<HTMLCanvasElement | null>(null)
  67. const viewportRef = ref<HTMLElement | null>(null)
  68. const wrapRef = ref<HTMLElement | null>(null)
  69. const prepared = shallowRef(prepareTree([]))
  70. const activeTab = ref<HeatTabKey>('total_score')
  71. const startDepth = ref(0)
  72. const focus = ref<PreparedNode | null>(null)
  73. const selectedNode = ref<PreparedNode | null>(null)
  74. const viewScale = ref(1)
  75. const viewX = ref(0)
  76. const viewY = ref(0)
  77. const isDragging = ref(false)
  78. const tooltipVisible = ref(false)
  79. const tooltipX = ref(0)
  80. const tooltipY = ref(0)
  81. const tooltipNode = ref<PreparedNode | null>(null)
  82. const inspectOpen = ref(false)
  83. const inspectNode = ref<PreparedNode | null>(null)
  84. const PATH_DOCK_HEIGHT_KEY = 'icicle-path-dock-height'
  85. const PATH_DOCK_MIN = 120
  86. const PATH_DOCK_MAX_RATIO = 0.75
  87. function loadPathDockHeight(): number {
  88. const raw = localStorage.getItem(PATH_DOCK_HEIGHT_KEY)
  89. const n = raw ? Number(raw) : NaN
  90. return Number.isFinite(n) && n >= PATH_DOCK_MIN ? n : 240
  91. }
  92. const pathDockHeight = ref(loadPathDockHeight())
  93. const pathDockResizing = ref(false)
  94. function pathDockMaxHeight(): number {
  95. return Math.floor(window.innerHeight * PATH_DOCK_MAX_RATIO)
  96. }
  97. function onPathDockResizeStart(event: PointerEvent) {
  98. event.preventDefault()
  99. pathDockResizing.value = true
  100. const startY = event.clientY
  101. const startHeight = pathDockHeight.value
  102. const onMove = (e: PointerEvent) => {
  103. const delta = startY - e.clientY
  104. pathDockHeight.value = Math.max(
  105. PATH_DOCK_MIN,
  106. Math.min(pathDockMaxHeight(), startHeight + delta),
  107. )
  108. }
  109. const onUp = () => {
  110. pathDockResizing.value = false
  111. localStorage.setItem(PATH_DOCK_HEIGHT_KEY, String(pathDockHeight.value))
  112. window.removeEventListener('pointermove', onMove)
  113. window.removeEventListener('pointerup', onUp)
  114. window.removeEventListener('pointercancel', onUp)
  115. }
  116. window.addEventListener('pointermove', onMove)
  117. window.addEventListener('pointerup', onUp)
  118. window.addEventListener('pointercancel', onUp)
  119. }
  120. const inspectItems = computed<DemandGradeItem[]>(() => {
  121. if (!inspectNode.value) return []
  122. const categoryId = inspectNode.value.id
  123. const all = props.demandsByCategory?.[categoryId] ?? []
  124. if (
  125. listSelectedDemandId.value != null &&
  126. listSelectedCategoryId.value === categoryId
  127. ) {
  128. const picked = all.find((item) => item.id === listSelectedDemandId.value)
  129. if (picked) return [picked]
  130. }
  131. return all
  132. })
  133. const inspectDemandName = computed(() => {
  134. if (listSelectedDemandId.value == null) return null
  135. const card = demandCards.value.find((c) => c.id === listSelectedDemandId.value)
  136. return card?.demand_name ?? null
  137. })
  138. const listSelectedDemandId = ref<number | null>(null)
  139. const listSelectedCategoryId = ref<number | null>(null)
  140. const preparedNodeById = computed(() => {
  141. const m = new Map<number, PreparedNode>()
  142. for (const n of prepared.value.flat) m.set(n.id, n)
  143. return m
  144. })
  145. const demandCards = computed<DemandGradeListCard[]>(() => {
  146. // props.demandsByCategory: category_id -> DemandGradeItem[]
  147. const metaById = new Map<
  148. number,
  149. Omit<DemandGradeListCard, 'categoryOptions'> & { _categoryIds: Set<number> }
  150. >()
  151. for (const [categoryIdRaw, items] of Object.entries(props.demandsByCategory ?? {})) {
  152. const categoryId = Number(categoryIdRaw)
  153. for (const item of items) {
  154. if (!item || item.id == null) continue
  155. const entry = metaById.get(item.id)
  156. if (!entry) {
  157. metaById.set(item.id, {
  158. id: item.id,
  159. demand_name: item.demand_name,
  160. grade: item.grade,
  161. score: item.score,
  162. reason: item.reason,
  163. strategies: item.strategies,
  164. biz_dt: item.biz_dt,
  165. _categoryIds: new Set<number>(),
  166. })
  167. }
  168. metaById.get(item.id)!._categoryIds.add(categoryId)
  169. }
  170. }
  171. const result: DemandGradeListCard[] = []
  172. for (const entry of metaById.values()) {
  173. const categoryIds = Array.from(entry._categoryIds).sort((a, b) => a - b)
  174. const categoryOptions = categoryIds.map((cid) => ({
  175. categoryId: cid,
  176. categoryName: preparedNodeById.value.get(cid)?.name ?? '(未命名)',
  177. }))
  178. result.push({
  179. id: entry.id,
  180. demand_name: entry.demand_name,
  181. grade: entry.grade,
  182. score: entry.score,
  183. reason: entry.reason,
  184. strategies: entry.strategies,
  185. biz_dt: entry.biz_dt,
  186. categoryOptions,
  187. })
  188. }
  189. return result
  190. })
  191. function clearDemandListSelection() {
  192. listSelectedDemandId.value = null
  193. listSelectedCategoryId.value = null
  194. }
  195. const inspectHighlightDemandId = computed<number | null>(() => {
  196. if (!inspectNode.value) return null
  197. if (listSelectedCategoryId.value !== inspectNode.value.id) return null
  198. return listSelectedDemandId.value
  199. })
  200. function selectCategoryNodeById(categoryId: number) {
  201. const node = preparedNodeById.value.get(categoryId)
  202. if (!node) return
  203. // When selection originates from demand list, keep list selection state.
  204. selectNode(node, true, 'demandList')
  205. }
  206. function onSelectDemandFromList(payload: { demandId: number; defaultCategoryId: number | null }) {
  207. listSelectedDemandId.value = payload.demandId
  208. listSelectedCategoryId.value = payload.defaultCategoryId
  209. if (payload.defaultCategoryId != null) {
  210. selectCategoryNodeById(payload.defaultCategoryId)
  211. }
  212. }
  213. function onSelectDemandCategoryFromList(payload: { demandId: number; categoryId: number }) {
  214. listSelectedDemandId.value = payload.demandId
  215. listSelectedCategoryId.value = payload.categoryId
  216. selectCategoryNodeById(payload.categoryId)
  217. }
  218. function nodeHasDemands(node: PreparedNode): boolean {
  219. return (props.demandsByCategory?.[node.id]?.length ?? 0) > 0
  220. }
  221. /** Plain arrays — avoid Vue reactivity on thousands of rects. */
  222. let baseRects: IcicleRect[] = []
  223. let drawRects: IcicleRect[] = []
  224. let layoutCacheKey = ''
  225. let fillCache = new Map<number, string>()
  226. let textCache = new Map<number, string>()
  227. let resizeObserver: ResizeObserver | null = null
  228. let dpr = 1
  229. let drawRaf = 0
  230. let scrollPending = false
  231. /** After navigation: reset transform so width stays 200px/col. */
  232. let needResetTransform = false
  233. let applyingExtent = false
  234. let lastExtentKey = ''
  235. const drag = {
  236. active: false,
  237. moved: false,
  238. x: 0,
  239. y: 0,
  240. pointerId: -1,
  241. }
  242. const maxDepth = computed(() => prepared.value.maxDepth)
  243. const baseDepth = computed(() => activeBaseDepth(startDepth.value, focus.value))
  244. const currentViewRoots = computed(() =>
  245. viewRoots(prepared.value.roots, focus.value, startDepth.value),
  246. )
  247. const isFullTree = computed(() => activeTab.value === FULL_TREE_TAB)
  248. const activeDim = computed<WeightDimKey | null>(() =>
  249. isFullTree.value ? null : (activeTab.value as WeightDimKey),
  250. )
  251. const weightScale = computed(() => {
  252. if (!activeDim.value) return [] as number[]
  253. return collectDimScale(prepared.value.flat, activeDim.value)
  254. })
  255. const treeTabs = computed(() => [
  256. { key: FULL_TREE_TAB as HeatTabKey, label: '全局树' },
  257. ...HEAT_DIM_TABS.map((d) => ({ key: d.key as HeatTabKey, label: d.label })),
  258. ])
  259. const activeDimLabel = computed(() => {
  260. if (!activeDim.value) return null
  261. return HEAT_DIM_TABS.find((d) => d.key === activeDim.value)?.label ?? activeDim.value
  262. })
  263. const mapContext = computed(() => {
  264. const total = prepared.value.flat.length
  265. const heatHint = activeDim.value
  266. ? ` · ${activeDimLabel.value}色阶 · 无数据为白`
  267. : ''
  268. if (focus.value) {
  269. return `${focus.value.path.join(' / ')} · 当前分支 · 共 ${total} 个节点${heatHint}`
  270. }
  271. if (startDepth.value > 0) {
  272. return `从第 ${startDepth.value} 级开始 · ${currentViewRoots.value.length} 个可视根 · 共 ${total} 个节点${heatHint}`
  273. }
  274. return `全局视图 · 横向为层级,纵向为分支规模 · ${total} 个节点${heatHint}`
  275. })
  276. const breadcrumbNodes = computed(() => {
  277. const pathNodes: PreparedNode[] = []
  278. let current = selectedNode.value || focus.value
  279. while (current) {
  280. pathNodes.unshift(current)
  281. current = current.parent
  282. }
  283. return pathNodes
  284. })
  285. function scheduleDraw() {
  286. cancelAnimationFrame(drawRaf)
  287. drawRaf = requestAnimationFrame(() => draw())
  288. }
  289. watch(
  290. () => props.nodes,
  291. (nodes) => {
  292. prepared.value = prepareTree(nodes)
  293. invalidatePaintCache()
  294. resetGlobalView()
  295. },
  296. { immediate: true },
  297. )
  298. watch(activeTab, () => {
  299. invalidatePaintCache()
  300. scheduleDraw()
  301. })
  302. function invalidatePaintCache() {
  303. layoutCacheKey = ''
  304. fillCache = new Map()
  305. textCache = new Map()
  306. }
  307. function cellFill(node: PreparedNode): string {
  308. const cached = fillCache.get(node.id)
  309. if (cached) return cached
  310. const color = !activeDim.value
  311. ? nodeStructureFill(node)
  312. : mapHeatFill(nodeHeatT(node, activeDim.value, weightScale.value))
  313. fillCache.set(node.id, color)
  314. return color
  315. }
  316. function cellText(node: PreparedNode): string {
  317. const cached = textCache.get(node.id)
  318. if (cached) return cached
  319. const color = !activeDim.value
  320. ? nodeTextColor()
  321. : mapHeatText(nodeHeatT(node, activeDim.value, weightScale.value))
  322. textCache.set(node.id, color)
  323. return color
  324. }
  325. function onTabClick(key: HeatTabKey) {
  326. if (activeTab.value === key) return
  327. activeTab.value = key
  328. }
  329. function resetViewTransform() {
  330. viewScale.value = 1
  331. viewX.value = 0
  332. viewY.value = 0
  333. }
  334. function resetGlobalView() {
  335. startDepth.value = 0
  336. focus.value = null
  337. selectedNode.value = null
  338. clearDemandListSelection()
  339. closeInspect()
  340. hideTooltip()
  341. scrollPending = false
  342. needResetTransform = false
  343. lastExtentKey = ''
  344. resetViewTransform()
  345. if (viewportRef.value) {
  346. viewportRef.value.scrollTop = 0
  347. viewportRef.value.scrollLeft = 0
  348. }
  349. scheduleDraw()
  350. }
  351. function selectNode(
  352. node: PreparedNode,
  353. withFocus = false,
  354. source: 'canvas' | 'demandList' = 'canvas',
  355. ) {
  356. if (source === 'canvas') clearDemandListSelection()
  357. selectedNode.value = node
  358. if (withFocus) {
  359. focus.value = node
  360. startDepth.value = node.path.length - 1
  361. }
  362. hideTooltip()
  363. scrollPending = false
  364. needResetTransform = true
  365. lastExtentKey = ''
  366. layoutCacheKey = ''
  367. resetViewTransform()
  368. if (viewportRef.value) {
  369. viewportRef.value.scrollTop = 0
  370. viewportRef.value.scrollLeft = 0
  371. }
  372. // Auto-open path panel when this category has demand words (same as 🔍 nodes).
  373. const items = props.demandsByCategory?.[node.id] ?? []
  374. if (items.length) {
  375. inspectNode.value = node
  376. inspectOpen.value = true
  377. } else {
  378. closeInspect()
  379. }
  380. scheduleDraw()
  381. }
  382. function closeInspect() {
  383. inspectOpen.value = false
  384. inspectNode.value = null
  385. }
  386. function onDepthClick(depth: number) {
  387. if (depth === 0) {
  388. resetGlobalView()
  389. return
  390. }
  391. focus.value = null
  392. selectedNode.value = null
  393. clearDemandListSelection()
  394. closeInspect()
  395. startDepth.value = depth
  396. hideTooltip()
  397. scrollPending = false
  398. needResetTransform = true
  399. lastExtentKey = ''
  400. layoutCacheKey = ''
  401. resetViewTransform()
  402. if (viewportRef.value) {
  403. viewportRef.value.scrollTop = 0
  404. viewportRef.value.scrollLeft = 0
  405. }
  406. scheduleDraw()
  407. }
  408. function onBreadcrumbClick(node: PreparedNode) {
  409. selectNode(node, true)
  410. }
  411. function computeExtent(viewportHeight: number): { width: number; height: number } {
  412. const roots = currentViewRoots.value
  413. const depth = baseDepth.value
  414. const columns = Math.max(1, relativeMaxDepth(roots, depth) + 1)
  415. // Width is always N × 200px — never scaled by auto-fit.
  416. const width = columns * COLUMN_WIDTH + CONTENT_PAD
  417. if (!roots.length) {
  418. return { width, height: Math.max(320, viewportHeight) }
  419. }
  420. const totalLeaves = roots.reduce((sum, node) => sum + node.leafCount, 0)
  421. const scoped = startDepth.value > 0 || Boolean(focus.value)
  422. const hasChildren = roots.some((node) => node.children.length > 0)
  423. // Overview (root): fill viewport height, fixed column widths.
  424. if (!scoped) {
  425. return {
  426. width,
  427. height: Math.max(320, viewportHeight),
  428. }
  429. }
  430. // Leaf-only drill-down: capped block height (already readable).
  431. if (!hasChildren || totalLeaves <= 0) {
  432. return {
  433. width,
  434. height: Math.min(
  435. MAX_CANVAS_HEIGHT,
  436. Math.max(MAX_LEAF_BLOCK_HEIGHT, roots.length * MAX_LEAF_BLOCK_HEIGHT),
  437. ),
  438. }
  439. }
  440. // Grow height only so leftmost-two-column thinnest cell >= MIN_READABLE_HEIGHT.
  441. // Do not use viewScale here — that would also widen the 200px columns.
  442. const minLeaf = minLeafInLeftTwoColumns(roots)
  443. const idealHeight = (MIN_READABLE_HEIGHT * totalLeaves) / minLeaf
  444. const height = Math.max(
  445. MIN_READABLE_HEIGHT,
  446. Math.min(MAX_CANVAS_HEIGHT, idealHeight),
  447. )
  448. return { width, height }
  449. }
  450. function applyWrapSize(width: number, height: number) {
  451. const wrap = wrapRef.value
  452. if (!wrap) return
  453. const key = `${startDepth.value}:${focus.value?.id ?? 'none'}:${Math.round(width)}x${Math.round(height)}`
  454. if (
  455. key === lastExtentKey &&
  456. wrap.style.height === `${height}px` &&
  457. wrap.style.width === `${width}px`
  458. ) {
  459. return
  460. }
  461. lastExtentKey = key
  462. applyingExtent = true
  463. wrap.style.width = `${width}px`
  464. wrap.style.height = `${height}px`
  465. queueMicrotask(() => {
  466. applyingExtent = false
  467. })
  468. }
  469. function fitView() {
  470. needResetTransform = true
  471. lastExtentKey = ''
  472. layoutCacheKey = ''
  473. resetViewTransform()
  474. if (viewportRef.value) {
  475. viewportRef.value.scrollTop = 0
  476. viewportRef.value.scrollLeft = 0
  477. }
  478. scheduleDraw()
  479. }
  480. function zoomAt(factor: number, screenX: number, screenY: number) {
  481. const oldScale = viewScale.value
  482. const nextScale = Math.max(0.5, Math.min(18, oldScale * factor))
  483. const worldX = (screenX - viewX.value) / oldScale
  484. const worldY = (screenY - viewY.value) / oldScale
  485. viewScale.value = nextScale
  486. viewX.value = screenX - worldX * nextScale
  487. viewY.value = screenY - worldY * nextScale
  488. scheduleDraw()
  489. }
  490. function ensureLayout(extentHeight: number, depth: number) {
  491. const roots = currentViewRoots.value
  492. const key = [
  493. focus.value?.id ?? 'g',
  494. startDepth.value,
  495. depth,
  496. Math.round(extentHeight),
  497. roots.length,
  498. ].join(':')
  499. if (key === layoutCacheKey && baseRects.length) return
  500. layoutCacheKey = key
  501. baseRects = layoutIcicle(roots, depth, COLUMN_WIDTH, extentHeight)
  502. }
  503. function draw() {
  504. const canvas = canvasRef.value
  505. const viewport = viewportRef.value
  506. if (!canvas || !viewport || !prepared.value.flat.length) return
  507. const vw = viewport.clientWidth
  508. const vh = viewport.clientHeight
  509. if (vw <= 0 || vh <= 0) return
  510. const extent = computeExtent(vh)
  511. applyWrapSize(extent.width, extent.height)
  512. if (needResetTransform) {
  513. needResetTransform = false
  514. viewScale.value = 1
  515. viewX.value = 0
  516. viewY.value = 0
  517. }
  518. // Canvas stays viewport-sized (sticky). Never allocate a multi-thousand-px bitmap.
  519. canvas.style.width = `${vw}px`
  520. canvas.style.height = `${vh}px`
  521. dpr = Math.min(window.devicePixelRatio || 1, 2)
  522. const bw = Math.round(vw * dpr)
  523. const bh = Math.round(vh * dpr)
  524. if (canvas.width !== bw || canvas.height !== bh) {
  525. canvas.width = bw
  526. canvas.height = bh
  527. }
  528. const ctx = canvas.getContext('2d', { alpha: false })
  529. if (!ctx) return
  530. ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
  531. ctx.fillStyle = '#f8fafc'
  532. ctx.fillRect(0, 0, vw, vh)
  533. const depth = baseDepth.value
  534. ensureLayout(extent.height, depth)
  535. const scale = viewScale.value
  536. const scrollLeft = viewport.scrollLeft
  537. const scrollTop = viewport.scrollTop
  538. const ox = viewX.value - scrollLeft
  539. const oy = viewY.value - scrollTop
  540. const pad = 2
  541. const vx0 = -pad
  542. const vy0 = -pad
  543. const vx1 = vw + pad
  544. const vy1 = vh + pad
  545. const selected = selectedNode.value
  546. const strokeColor = activeDim.value ? 'rgba(15,23,42,.08)' : 'rgba(255,255,255,.88)'
  547. drawRects = []
  548. for (const item of baseRects) {
  549. const x = item.x * scale + ox
  550. const y = item.y * scale + oy
  551. const width = item.width * scale
  552. const height = item.height * scale
  553. if (x + width < vx0 || y + height < vy0 || x > vx1 || y > vy1) continue
  554. if (height < 0.5 || width < 0.5) continue
  555. drawRects.push({ node: item.node, x, y, width, height })
  556. ctx.fillStyle = cellFill(item.node)
  557. ctx.fillRect(x, y, width, height)
  558. if (height >= 3) {
  559. ctx.strokeStyle = strokeColor
  560. ctx.lineWidth = 0.8
  561. ctx.strokeRect(x, y, width, height)
  562. }
  563. if (selected === item.node) {
  564. ctx.strokeStyle = '#7c3aed'
  565. ctx.lineWidth = 3
  566. ctx.strokeRect(
  567. x + 1.5,
  568. y + 1.5,
  569. Math.max(0, width - 3),
  570. Math.max(0, height - 3),
  571. )
  572. }
  573. if (height >= 14 && width >= 40) {
  574. ctx.fillStyle = cellText(item.node)
  575. const fontSize = height >= 26 ? 12 : height >= 16 ? 10 : 9
  576. ctx.font = `${fontSize}px "PingFang SC", "Microsoft YaHei", sans-serif`
  577. ctx.textBaseline = 'middle'
  578. const maxW = width - 8
  579. const hasDemand = nodeHasDemands(item.node)
  580. const suffix = hasDemand ? ' 🔍' : ''
  581. let text = `${item.node.name}${suffix}`
  582. if (ctx.measureText(text).width > maxW) {
  583. const approx = Math.max(1, Math.floor(maxW / (fontSize * 0.95)) - (hasDemand ? 2 : 0))
  584. text = `${item.node.name.slice(0, Math.max(1, approx))}…${suffix}`
  585. }
  586. ctx.fillText(text, x + 5, y + Math.min(height / 2, fontSize + 4))
  587. }
  588. }
  589. if (scrollPending) {
  590. scrollPending = false
  591. scrollSelectedIntoView()
  592. }
  593. }
  594. function nodeAt(clientX: number, clientY: number): PreparedNode | null {
  595. const canvas = canvasRef.value
  596. const viewport = viewportRef.value
  597. if (!canvas || !viewport || !baseRects.length) return null
  598. const rect = canvas.getBoundingClientRect()
  599. const scale = viewScale.value || 1
  600. const contentX = (clientX - rect.left - viewX.value + viewport.scrollLeft) / scale
  601. const contentY = (clientY - rect.top - viewY.value + viewport.scrollTop) / scale
  602. let match: IcicleRect | null = null
  603. for (let i = baseRects.length - 1; i >= 0; i -= 1) {
  604. const item = baseRects[i]
  605. if (
  606. contentX >= item.x &&
  607. contentX <= item.x + item.width &&
  608. contentY >= item.y &&
  609. contentY <= item.y + item.height
  610. ) {
  611. if (!match || item.node.path.length > match.node.path.length) match = item
  612. }
  613. }
  614. return match?.node ?? null
  615. }
  616. function scrollSelectedIntoView() {
  617. if (!selectedNode.value) return
  618. const item = baseRects.find((rect) => rect.node === selectedNode.value)
  619. const viewport = viewportRef.value
  620. if (!item || !viewport) return
  621. viewport.scrollTop = Math.max(0, item.y)
  622. viewport.scrollLeft = Math.max(0, item.x)
  623. viewX.value = 0
  624. viewY.value = 0
  625. scheduleDraw()
  626. }
  627. function hideTooltip() {
  628. tooltipVisible.value = false
  629. tooltipNode.value = null
  630. }
  631. function updateTooltip(event: PointerEvent, node: PreparedNode) {
  632. const viewport = viewportRef.value
  633. if (!viewport) return
  634. const rect = viewport.getBoundingClientRect()
  635. tooltipNode.value = node
  636. tooltipVisible.value = true
  637. tooltipX.value = Math.min(rect.width - 260, event.clientX - rect.left + 14)
  638. tooltipY.value = Math.min(rect.height - 120, event.clientY - rect.top + 14)
  639. }
  640. function onPointerDown(event: PointerEvent) {
  641. if (event.button !== 0) return
  642. const canvas = canvasRef.value
  643. if (!canvas) return
  644. canvas.setPointerCapture(event.pointerId)
  645. drag.active = true
  646. drag.moved = false
  647. drag.x = event.clientX
  648. drag.y = event.clientY
  649. drag.pointerId = event.pointerId
  650. isDragging.value = true
  651. hideTooltip()
  652. }
  653. function onPointerMove(event: PointerEvent) {
  654. if (drag.active && drag.pointerId === event.pointerId) {
  655. const dx = event.clientX - drag.x
  656. const dy = event.clientY - drag.y
  657. if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true
  658. // At 1x scale, prefer native scroll (cheaper than redrawing every move).
  659. if (viewScale.value === 1 && viewportRef.value) {
  660. viewportRef.value.scrollLeft -= dx
  661. viewportRef.value.scrollTop -= dy
  662. } else {
  663. viewX.value += dx
  664. viewY.value += dy
  665. scheduleDraw()
  666. }
  667. drag.x = event.clientX
  668. drag.y = event.clientY
  669. return
  670. }
  671. if (drag.active) return
  672. const node = nodeAt(event.clientX, event.clientY)
  673. if (!node) {
  674. if (tooltipVisible.value) hideTooltip()
  675. return
  676. }
  677. // Tooltip only — no canvas redraw on hover (major lag source).
  678. updateTooltip(event, node)
  679. }
  680. function onPointerUp(event: PointerEvent) {
  681. if (drag.pointerId !== event.pointerId) return
  682. const wasMoved = drag.moved
  683. drag.active = false
  684. drag.pointerId = -1
  685. isDragging.value = false
  686. if (!wasMoved) {
  687. const node = nodeAt(event.clientX, event.clientY)
  688. if (node) selectNode(node, true)
  689. }
  690. }
  691. function onPointerLeave() {
  692. if (drag.active) return
  693. if (!tooltipVisible.value) return
  694. hideTooltip()
  695. }
  696. function canvasCenter(): { x: number; y: number } {
  697. const canvas = canvasRef.value
  698. if (!canvas) return { x: 0, y: 0 }
  699. const rect = canvas.getBoundingClientRect()
  700. return { x: rect.width / 2, y: rect.height / 2 }
  701. }
  702. function zoomIn() {
  703. const { x, y } = canvasCenter()
  704. zoomAt(1.35, x, y)
  705. }
  706. function zoomOut() {
  707. const { x, y } = canvasCenter()
  708. zoomAt(0.74, x, y)
  709. }
  710. function onWheel(event: WheelEvent) {
  711. if (!(event.ctrlKey || event.metaKey)) return
  712. event.preventDefault()
  713. const canvas = canvasRef.value
  714. if (!canvas) return
  715. const rect = canvas.getBoundingClientRect()
  716. zoomAt(
  717. event.deltaY < 0 ? 1.15 : 0.87,
  718. event.clientX - rect.left,
  719. event.clientY - rect.top,
  720. )
  721. }
  722. function onScroll() {
  723. // Sticky canvas + scroll offset — redraw visible window only.
  724. scheduleDraw()
  725. }
  726. function onResize() {
  727. if (applyingExtent) return
  728. scheduleDraw()
  729. }
  730. onMounted(() => {
  731. const viewport = viewportRef.value
  732. if (viewport) {
  733. resizeObserver = new ResizeObserver(onResize)
  734. resizeObserver.observe(viewport)
  735. viewport.addEventListener('scroll', onScroll, { passive: true })
  736. }
  737. scheduleDraw()
  738. })
  739. onUnmounted(() => {
  740. resizeObserver?.disconnect()
  741. viewportRef.value?.removeEventListener('scroll', onScroll)
  742. cancelAnimationFrame(drawRaf)
  743. })
  744. </script>
  745. <template>
  746. <div class="icicle-tree">
  747. <header class="toolbar">
  748. <div class="title-row">
  749. <h1>平台全局需求地图</h1>
  750. <span v-if="bizDt" class="biz-dt">biz_dt {{ bizDt }}</span>
  751. </div>
  752. <p class="subtitle">完整分类树 · 横向为层级,纵向为分支规模</p>
  753. </header>
  754. <div class="content-grid">
  755. <section class="map-panel">
  756. <div class="dim-bar">
  757. <div class="tabs" role="tablist" aria-label="热度维度">
  758. <button
  759. v-for="tab in treeTabs"
  760. :key="tab.key"
  761. type="button"
  762. role="tab"
  763. class="tab"
  764. :class="{ active: activeTab === tab.key }"
  765. :aria-selected="activeTab === tab.key"
  766. @click="onTabClick(tab.key)"
  767. >
  768. {{ tab.label }}
  769. </button>
  770. </div>
  771. <div v-if="!isFullTree" class="legend" aria-hidden="true">
  772. <span class="legend-label">无数据</span>
  773. <span class="legend-swatch missing" />
  774. <span class="legend-label">冷</span>
  775. <div class="legend-bar" />
  776. <span class="legend-label">热</span>
  777. </div>
  778. </div>
  779. <div class="map-toolbar">
  780. <p class="map-context">{{ mapContext }}</p>
  781. <div class="view-actions">
  782. <button type="button" class="btn btn-secondary" @click="resetGlobalView">
  783. 回到全局
  784. </button>
  785. </div>
  786. </div>
  787. <div
  788. class="depth-axis"
  789. :style="{ gridTemplateColumns: `repeat(${maxDepth + 1}, minmax(48px, 1fr))` }"
  790. >
  791. <button
  792. v-for="depth in maxDepth + 1"
  793. :key="depth - 1"
  794. type="button"
  795. :class="{ active: depth - 1 === baseDepth }"
  796. @click="onDepthClick(depth - 1)"
  797. >
  798. {{ depth - 1 === 0 ? '根' : `第 ${depth - 1} 级` }}
  799. </button>
  800. </div>
  801. <nav v-if="breadcrumbNodes.length" class="breadcrumb-bar" aria-label="当前路径">
  802. <template v-for="(node, index) in breadcrumbNodes" :key="node.id">
  803. <span v-if="index > 0" class="sep">→</span>
  804. <button
  805. type="button"
  806. :class="{ current: index === breadcrumbNodes.length - 1 }"
  807. @click="onBreadcrumbClick(node)"
  808. >
  809. {{ node.name }}
  810. </button>
  811. </template>
  812. </nav>
  813. <nav v-else class="breadcrumb-bar">
  814. <button type="button" class="current" @click="resetGlobalView">全局树</button>
  815. </nav>
  816. <div class="icicle-stage">
  817. <div ref="viewportRef" class="icicle-viewport">
  818. <div ref="wrapRef" class="icicle-wrap">
  819. <canvas
  820. ref="canvasRef"
  821. class="icicle-canvas"
  822. :class="{ dragging: isDragging }"
  823. aria-label="全局分类树冰柱图"
  824. @pointerdown="onPointerDown"
  825. @pointermove="onPointerMove"
  826. @pointerup="onPointerUp"
  827. @pointercancel="onPointerUp"
  828. @pointerleave="onPointerLeave"
  829. @wheel="onWheel"
  830. />
  831. </div>
  832. </div>
  833. <div class="icicle-overlay">
  834. <div
  835. v-if="tooltipVisible && tooltipNode"
  836. class="map-tooltip"
  837. :style="{ left: `${tooltipX}px`, top: `${tooltipY}px` }"
  838. >
  839. <strong>
  840. {{ tooltipNode.name }}
  841. <template v-if="nodeHasDemands(tooltipNode)"> 🔍</template>
  842. </strong>
  843. <span>{{ tooltipNode.path.join(' / ') }}</span>
  844. <span v-if="nodeHasDemands(tooltipNode)">点击可在下方展开需求路径</span>
  845. <span v-if="activeDim && activeDimLabel">
  846. {{ activeDimLabel }}:{{ formatNodeDimScore(tooltipNode, activeDim) }}
  847. </span>
  848. <span>
  849. 第 {{ tooltipNode.path.length - 1 }} 级
  850. · {{ tooltipNode.children.length }} 个子节点
  851. · {{ tooltipNode.leafCount }} 个叶节点
  852. </span>
  853. <span v-if="tooltipNode.hung_word_count != null">
  854. 挂靠词 {{ tooltipNode.hung_word_count }}
  855. </span>
  856. <span v-if="tooltipNode.description">{{ tooltipNode.description }}</span>
  857. </div>
  858. <div class="map-guide">
  859. <strong>阅读方式</strong>
  860. <span>滚轮浏览;拖拽平移;Ctrl/⌘+滚轮缩放;点击聚焦分支。</span>
  861. </div>
  862. <div class="zoom-controls">
  863. <button type="button" title="缩小" @click="zoomOut">−</button>
  864. <button type="button" title="自适应可读高度" @click="fitView">适配</button>
  865. <button type="button" title="放大" @click="zoomIn">+</button>
  866. </div>
  867. </div>
  868. </div>
  869. </section>
  870. <aside class="demand-list-aside">
  871. <DemandGradeListPanel
  872. v-if="demandCards.length"
  873. :demand-cards="demandCards"
  874. :selected-demand-id="listSelectedDemandId"
  875. :selected-category-id="listSelectedCategoryId"
  876. @select-demand="onSelectDemandFromList"
  877. @select-demand-category="onSelectDemandCategoryFromList"
  878. />
  879. <div v-else class="demand-empty">暂无需求数据</div>
  880. </aside>
  881. </div>
  882. <div
  883. v-if="inspectOpen && inspectNode"
  884. class="path-dock"
  885. :class="{ resizing: pathDockResizing }"
  886. :style="{ height: `${pathDockHeight}px` }"
  887. >
  888. <div
  889. class="path-dock-resize"
  890. role="separator"
  891. aria-orientation="horizontal"
  892. aria-label="调整详情区域高度"
  893. title="上下拖拽调整高度"
  894. @pointerdown="onPathDockResizeStart"
  895. />
  896. <div class="path-dock-head">
  897. <div class="path-dock-meta">
  898. <span class="path-dock-label">节点详情</span>
  899. <span v-if="inspectDemandName" class="path-dock-demand">{{ inspectDemandName }}</span>
  900. <span class="path-dock-name">{{ inspectNode.name }}</span>
  901. </div>
  902. <button
  903. type="button"
  904. class="path-dock-close"
  905. aria-label="关闭节点详情"
  906. @click="closeInspect"
  907. >
  908. 关闭
  909. </button>
  910. </div>
  911. <div class="path-dock-body">
  912. <DemandPathPanel
  913. :open="true"
  914. :category-name="inspectNode.name"
  915. :node-name="inspectNode.name"
  916. :node-path="inspectNode.path.join(' / ')"
  917. :items="inspectItems"
  918. :highlight-demand-id="inspectHighlightDemandId"
  919. hide-inline-close
  920. @close="closeInspect"
  921. />
  922. </div>
  923. </div>
  924. </div>
  925. </template>
  926. <style scoped>
  927. .icicle-tree {
  928. display: flex;
  929. flex-direction: column;
  930. gap: 12px;
  931. height: 100%;
  932. min-height: 0;
  933. }
  934. .toolbar {
  935. flex-shrink: 0;
  936. padding-bottom: 12px;
  937. border-bottom: 1px solid #e2e8f0;
  938. }
  939. .title-row {
  940. display: flex;
  941. flex-wrap: wrap;
  942. align-items: baseline;
  943. gap: 10px 14px;
  944. }
  945. .toolbar h1 {
  946. margin: 0;
  947. font-size: 22px;
  948. font-weight: 700;
  949. color: #0f172a;
  950. letter-spacing: -0.02em;
  951. }
  952. .subtitle {
  953. margin: 6px 0 0;
  954. font-size: 13px;
  955. color: #64748b;
  956. }
  957. .biz-dt {
  958. font-size: 12px;
  959. color: #64748b;
  960. font-variant-numeric: tabular-nums;
  961. }
  962. .map-panel {
  963. display: grid;
  964. grid-template-rows: auto auto auto auto minmax(0, 1fr);
  965. flex: 1;
  966. min-height: 0;
  967. overflow: hidden;
  968. border: 1px solid #e2e8f0;
  969. border-radius: 10px;
  970. background: #fff;
  971. }
  972. .content-grid {
  973. display: grid;
  974. grid-template-columns: minmax(520px, 1fr) 360px;
  975. gap: 12px;
  976. flex: 1;
  977. min-height: 0;
  978. align-items: stretch;
  979. }
  980. .demand-list-aside {
  981. min-height: 0;
  982. height: 100%;
  983. }
  984. .demand-empty {
  985. height: 100%;
  986. min-height: 0;
  987. border: 1px solid #e2e8f0;
  988. border-radius: 10px;
  989. background: #fff;
  990. color: #94a3b8;
  991. font-size: 13px;
  992. display: flex;
  993. align-items: center;
  994. justify-content: center;
  995. }
  996. @media (max-width: 980px) {
  997. .content-grid {
  998. grid-template-columns: 1fr;
  999. }
  1000. }
  1001. .dim-bar {
  1002. display: flex;
  1003. flex-wrap: wrap;
  1004. align-items: center;
  1005. justify-content: space-between;
  1006. gap: 10px 16px;
  1007. padding: 10px 14px;
  1008. border-bottom: 1px solid #e8edf2;
  1009. background: #f8fafc;
  1010. }
  1011. .tabs {
  1012. display: inline-flex;
  1013. flex-wrap: wrap;
  1014. gap: 6px;
  1015. padding: 4px;
  1016. border: 1px solid #e2e8f0;
  1017. border-radius: 10px;
  1018. background: #fff;
  1019. }
  1020. .tab {
  1021. height: 32px;
  1022. padding: 0 12px;
  1023. border: 1px solid transparent;
  1024. border-radius: 7px;
  1025. background: transparent;
  1026. color: #475569;
  1027. font-size: 13px;
  1028. font-weight: 500;
  1029. cursor: pointer;
  1030. }
  1031. .tab:hover {
  1032. color: #0f172a;
  1033. background: #f1f5f9;
  1034. }
  1035. .tab.active {
  1036. background: #0f172a;
  1037. border-color: #0f172a;
  1038. color: #fff;
  1039. }
  1040. .legend {
  1041. display: inline-flex;
  1042. align-items: center;
  1043. gap: 8px;
  1044. }
  1045. .legend-label {
  1046. font-size: 11px;
  1047. color: #94a3b8;
  1048. }
  1049. .legend-swatch {
  1050. width: 14px;
  1051. height: 10px;
  1052. border-radius: 2px;
  1053. border: 1px solid #e2e8f0;
  1054. }
  1055. .legend-swatch.missing {
  1056. background: #ffffff;
  1057. }
  1058. .legend-bar {
  1059. width: 120px;
  1060. height: 10px;
  1061. border-radius: 999px;
  1062. border: 1px solid rgba(15, 23, 42, 0.08);
  1063. background: linear-gradient(
  1064. 90deg,
  1065. rgb(219, 234, 254) 0%,
  1066. rgb(125, 211, 252) 25%,
  1067. rgb(52, 211, 153) 50%,
  1068. rgb(251, 191, 36) 75%,
  1069. rgb(239, 68, 68) 100%
  1070. );
  1071. }
  1072. .map-toolbar {
  1073. display: flex;
  1074. flex-wrap: wrap;
  1075. align-items: center;
  1076. justify-content: space-between;
  1077. gap: 10px 16px;
  1078. padding: 12px 14px;
  1079. border-bottom: 1px solid #e8edf2;
  1080. }
  1081. .map-context {
  1082. margin: 0;
  1083. font-size: 13px;
  1084. color: #475569;
  1085. }
  1086. .view-actions {
  1087. display: flex;
  1088. gap: 8px;
  1089. }
  1090. .btn {
  1091. height: 32px;
  1092. padding: 0 12px;
  1093. border: 1px solid #334155;
  1094. border-radius: 6px;
  1095. background: #0f172a;
  1096. color: #fff;
  1097. font-size: 13px;
  1098. cursor: pointer;
  1099. }
  1100. .btn:hover:not(:disabled) {
  1101. background: #1e293b;
  1102. }
  1103. .btn-secondary {
  1104. background: #fff;
  1105. color: #0f172a;
  1106. border-color: #cbd5e1;
  1107. }
  1108. .btn-secondary:hover:not(:disabled) {
  1109. background: #f8fafc;
  1110. border-color: #94a3b8;
  1111. }
  1112. .path-dock {
  1113. display: flex;
  1114. flex-direction: column;
  1115. flex: 0 0 auto;
  1116. min-height: 0;
  1117. overflow: hidden;
  1118. overscroll-behavior: contain;
  1119. border: 1px solid #e2e8f0;
  1120. border-radius: 10px;
  1121. background: #fff;
  1122. isolation: isolate;
  1123. }
  1124. .path-dock-resize {
  1125. flex-shrink: 0;
  1126. height: 10px;
  1127. cursor: ns-resize;
  1128. touch-action: none;
  1129. position: relative;
  1130. background: #f8fafc;
  1131. border-bottom: 1px solid #e2e8f0;
  1132. }
  1133. .path-dock-resize::before {
  1134. content: '';
  1135. position: absolute;
  1136. left: 50%;
  1137. top: 50%;
  1138. transform: translate(-50%, -50%);
  1139. width: 40px;
  1140. height: 4px;
  1141. border-radius: 999px;
  1142. background: #cbd5e1;
  1143. transition: background 0.15s ease;
  1144. }
  1145. .path-dock-resize:hover::before,
  1146. .path-dock.resizing .path-dock-resize::before {
  1147. background: #64748b;
  1148. }
  1149. .path-dock.resizing {
  1150. user-select: none;
  1151. }
  1152. .path-dock-head {
  1153. display: flex;
  1154. align-items: center;
  1155. justify-content: space-between;
  1156. gap: 12px;
  1157. flex-shrink: 0;
  1158. padding: 10px 14px;
  1159. border-bottom: 1px solid #e2e8f0;
  1160. background: #f8fafc;
  1161. }
  1162. .path-dock-meta {
  1163. display: flex;
  1164. align-items: baseline;
  1165. gap: 8px;
  1166. min-width: 0;
  1167. }
  1168. .path-dock-label {
  1169. flex-shrink: 0;
  1170. font-size: 12px;
  1171. font-weight: 700;
  1172. color: #64748b;
  1173. letter-spacing: 0.03em;
  1174. }
  1175. .path-dock-name {
  1176. font-size: 14px;
  1177. font-weight: 600;
  1178. color: #0f172a;
  1179. overflow: hidden;
  1180. text-overflow: ellipsis;
  1181. white-space: nowrap;
  1182. }
  1183. .path-dock-demand {
  1184. font-size: 14px;
  1185. font-weight: 700;
  1186. color: #1d4ed8;
  1187. overflow: hidden;
  1188. text-overflow: ellipsis;
  1189. white-space: nowrap;
  1190. }
  1191. .path-dock-close {
  1192. flex-shrink: 0;
  1193. height: 30px;
  1194. padding: 0 12px;
  1195. border: 1px solid #cbd5e1;
  1196. border-radius: 6px;
  1197. background: #fff;
  1198. color: #475569;
  1199. font-size: 13px;
  1200. font-weight: 500;
  1201. cursor: pointer;
  1202. }
  1203. .path-dock-close:hover {
  1204. background: #f1f5f9;
  1205. border-color: #94a3b8;
  1206. color: #0f172a;
  1207. }
  1208. .path-dock-body {
  1209. flex: 1;
  1210. min-height: 0;
  1211. overflow: auto;
  1212. overscroll-behavior: contain;
  1213. padding: 12px 14px;
  1214. display: flex;
  1215. flex-direction: column;
  1216. }
  1217. .depth-axis {
  1218. display: grid;
  1219. padding: 8px 12px;
  1220. gap: 4px;
  1221. border-bottom: 1px solid #edf1f5;
  1222. background: #fbfdff;
  1223. }
  1224. .depth-axis button {
  1225. padding: 4px 6px;
  1226. border: 0;
  1227. border-radius: 6px;
  1228. background: transparent;
  1229. color: #94a3b8;
  1230. font-size: 12px;
  1231. cursor: pointer;
  1232. }
  1233. .depth-axis button:hover {
  1234. background: #eff6ff;
  1235. color: #1d4ed8;
  1236. }
  1237. .depth-axis button.active {
  1238. background: #dbeafe;
  1239. color: #1d4ed8;
  1240. font-weight: 600;
  1241. }
  1242. .breadcrumb-bar {
  1243. display: flex;
  1244. align-items: center;
  1245. gap: 4px;
  1246. min-height: 34px;
  1247. overflow-x: auto;
  1248. padding: 6px 12px;
  1249. border-bottom: 1px solid #edf1f5;
  1250. background: #fff;
  1251. }
  1252. .breadcrumb-bar button {
  1253. flex: 0 0 auto;
  1254. padding: 4px 8px;
  1255. border: 0;
  1256. border-radius: 5px;
  1257. background: #f1f5f9;
  1258. color: #526174;
  1259. font-size: 12px;
  1260. cursor: pointer;
  1261. }
  1262. .breadcrumb-bar button.current {
  1263. background: #ede9fe;
  1264. color: #6d28d9;
  1265. font-weight: 600;
  1266. }
  1267. .sep {
  1268. color: #94a3b8;
  1269. font-size: 12px;
  1270. }
  1271. .icicle-stage {
  1272. position: relative;
  1273. display: flex;
  1274. flex-direction: column;
  1275. min-height: 0;
  1276. margin: 12px;
  1277. overflow: hidden;
  1278. border: 1px solid #cbd5e1;
  1279. border-radius: 10px;
  1280. background: #f8fafc;
  1281. }
  1282. .icicle-viewport {
  1283. position: relative;
  1284. flex: 1;
  1285. min-height: 0;
  1286. overflow: auto;
  1287. background: #f8fafc;
  1288. scrollbar-gutter: stable;
  1289. }
  1290. .icicle-overlay {
  1291. position: absolute;
  1292. inset: 0;
  1293. z-index: 5;
  1294. pointer-events: none;
  1295. }
  1296. .icicle-wrap {
  1297. position: relative;
  1298. min-height: 0;
  1299. overflow: visible;
  1300. background: #f8fafc;
  1301. }
  1302. .icicle-canvas {
  1303. position: sticky;
  1304. top: 0;
  1305. left: 0;
  1306. z-index: 1;
  1307. display: block;
  1308. cursor: grab;
  1309. touch-action: none;
  1310. }
  1311. .icicle-canvas.dragging {
  1312. cursor: grabbing;
  1313. }
  1314. .map-tooltip {
  1315. position: absolute;
  1316. z-index: 8;
  1317. width: 240px;
  1318. padding: 10px 12px;
  1319. border: 1px solid #c4b5fd;
  1320. border-radius: 8px;
  1321. background: rgba(255, 255, 255, 0.97);
  1322. color: #334155;
  1323. box-shadow: 0 9px 24px rgba(15, 23, 42, 0.14);
  1324. font-size: 12px;
  1325. line-height: 1.55;
  1326. pointer-events: none;
  1327. }
  1328. .map-tooltip strong {
  1329. display: block;
  1330. margin-bottom: 4px;
  1331. color: #5b21b6;
  1332. font-size: 13px;
  1333. }
  1334. .map-tooltip span {
  1335. display: block;
  1336. color: #64748b;
  1337. }
  1338. .map-guide {
  1339. position: absolute;
  1340. right: 10px;
  1341. top: 10px;
  1342. z-index: 5;
  1343. width: 200px;
  1344. padding: 10px 12px;
  1345. border: 1px solid #bfdbfe;
  1346. border-radius: 8px;
  1347. background: rgba(255, 255, 255, 0.94);
  1348. color: #526174;
  1349. box-shadow: 0 7px 18px rgba(15, 23, 42, 0.07);
  1350. pointer-events: none;
  1351. font-size: 11px;
  1352. line-height: 1.5;
  1353. }
  1354. .map-guide strong {
  1355. display: block;
  1356. margin-bottom: 4px;
  1357. color: #1d4ed8;
  1358. font-size: 12px;
  1359. }
  1360. .zoom-controls {
  1361. position: absolute;
  1362. right: 10px;
  1363. bottom: 10px;
  1364. z-index: 6;
  1365. display: flex;
  1366. gap: 4px;
  1367. pointer-events: auto;
  1368. }
  1369. .zoom-controls button {
  1370. min-width: 32px;
  1371. height: 30px;
  1372. padding: 0 8px;
  1373. border: 1px solid #bfdbfe;
  1374. border-radius: 6px;
  1375. background: #fff;
  1376. color: #1d4ed8;
  1377. font-size: 14px;
  1378. cursor: pointer;
  1379. }
  1380. .zoom-controls button:hover {
  1381. background: #eff6ff;
  1382. }
  1383. </style>