|
|
@@ -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>
|