|
|
@@ -7,6 +7,7 @@ import {
|
|
|
shallowRef,
|
|
|
watch,
|
|
|
} from 'vue'
|
|
|
+import DemandPathPanel from './DemandPathPanel.vue'
|
|
|
import type { CategoryNode, WeightDimKey } from '../types/category'
|
|
|
import {
|
|
|
COLUMN_WIDTH,
|
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
type IcicleRect,
|
|
|
type PreparedNode,
|
|
|
} from '../types/heatTree'
|
|
|
+import type { DemandBelongItem, DemandsByCategory } from '../types/demand'
|
|
|
|
|
|
/** Min cell height in leftmost two columns so labels stay readable. */
|
|
|
const MIN_READABLE_HEIGHT = 18
|
|
|
@@ -60,6 +62,7 @@ function minLeafInLeftTwoColumns(roots: PreparedNode[]): number {
|
|
|
const props = defineProps<{
|
|
|
nodes: CategoryNode[]
|
|
|
bizDt?: string | null
|
|
|
+ demandsByCategory?: DemandsByCategory
|
|
|
}>()
|
|
|
|
|
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
|
|
@@ -82,6 +85,18 @@ const tooltipX = ref(0)
|
|
|
const tooltipY = ref(0)
|
|
|
const tooltipNode = ref<PreparedNode | null>(null)
|
|
|
|
|
|
+const inspectOpen = ref(false)
|
|
|
+const inspectNode = ref<PreparedNode | null>(null)
|
|
|
+
|
|
|
+const inspectItems = computed<DemandBelongItem[]>(() => {
|
|
|
+ if (!inspectNode.value) return []
|
|
|
+ return props.demandsByCategory?.[inspectNode.value.id] ?? []
|
|
|
+})
|
|
|
+
|
|
|
+function nodeHasDemands(node: PreparedNode): boolean {
|
|
|
+ return (props.demandsByCategory?.[node.id]?.length ?? 0) > 0
|
|
|
+}
|
|
|
+
|
|
|
/** Plain arrays — avoid Vue reactivity on thousands of rects. */
|
|
|
let baseRects: IcicleRect[] = []
|
|
|
let drawRects: IcicleRect[] = []
|
|
|
@@ -217,6 +232,7 @@ function resetGlobalView() {
|
|
|
startDepth.value = 0
|
|
|
focus.value = null
|
|
|
selectedNode.value = null
|
|
|
+ closeInspect()
|
|
|
hideTooltip()
|
|
|
scrollPending = false
|
|
|
needResetTransform = false
|
|
|
@@ -245,9 +261,22 @@ function selectNode(node: PreparedNode, withFocus = false) {
|
|
|
viewportRef.value.scrollTop = 0
|
|
|
viewportRef.value.scrollLeft = 0
|
|
|
}
|
|
|
+ // Auto-open path panel when this category has demand words (same as 🔍 nodes).
|
|
|
+ const items = props.demandsByCategory?.[node.id] ?? []
|
|
|
+ if (items.length) {
|
|
|
+ inspectNode.value = node
|
|
|
+ inspectOpen.value = true
|
|
|
+ } else {
|
|
|
+ closeInspect()
|
|
|
+ }
|
|
|
scheduleDraw()
|
|
|
}
|
|
|
|
|
|
+function closeInspect() {
|
|
|
+ inspectOpen.value = false
|
|
|
+ inspectNode.value = null
|
|
|
+}
|
|
|
+
|
|
|
function onDepthClick(depth: number) {
|
|
|
if (depth === 0) {
|
|
|
resetGlobalView()
|
|
|
@@ -255,6 +284,7 @@ function onDepthClick(depth: number) {
|
|
|
}
|
|
|
focus.value = null
|
|
|
selectedNode.value = null
|
|
|
+ closeInspect()
|
|
|
startDepth.value = depth
|
|
|
hideTooltip()
|
|
|
scrollPending = false
|
|
|
@@ -468,10 +498,12 @@ function draw() {
|
|
|
ctx.font = `${fontSize}px "PingFang SC", "Microsoft YaHei", sans-serif`
|
|
|
ctx.textBaseline = 'middle'
|
|
|
const maxW = width - 8
|
|
|
- let text = item.node.name
|
|
|
+ const hasDemand = nodeHasDemands(item.node)
|
|
|
+ const suffix = hasDemand ? ' 🔍' : ''
|
|
|
+ let text = `${item.node.name}${suffix}`
|
|
|
if (ctx.measureText(text).width > maxW) {
|
|
|
- const approx = Math.max(1, Math.floor(maxW / (fontSize * 0.95)))
|
|
|
- text = `${item.node.name.slice(0, approx)}…`
|
|
|
+ const approx = Math.max(1, Math.floor(maxW / (fontSize * 0.95)) - (hasDemand ? 2 : 0))
|
|
|
+ text = `${item.node.name.slice(0, Math.max(1, approx))}…${suffix}`
|
|
|
}
|
|
|
ctx.fillText(text, x + 5, y + Math.min(height / 2, fontSize + 4))
|
|
|
}
|
|
|
@@ -752,8 +784,12 @@ onUnmounted(() => {
|
|
|
class="map-tooltip"
|
|
|
:style="{ left: `${tooltipX}px`, top: `${tooltipY}px` }"
|
|
|
>
|
|
|
- <strong>{{ tooltipNode.name }}</strong>
|
|
|
- <span>{{ tooltipNode.path.join(' / ') }}</span>
|
|
|
+ <strong>
|
|
|
+ {{ tooltipNode.name }}
|
|
|
+ <template v-if="nodeHasDemands(tooltipNode)"> 🔍</template>
|
|
|
+ </strong>
|
|
|
+ <span>{{ tooltipNode.path.join(' / ') }}</span>
|
|
|
+ <span v-if="nodeHasDemands(tooltipNode)">点击可在下方展开需求路径</span>
|
|
|
<span v-if="activeDim && activeDimLabel">
|
|
|
{{ activeDimLabel }}:{{ formatNodeDimScore(tooltipNode, activeDim) }}
|
|
|
</span>
|
|
|
@@ -778,6 +814,17 @@ onUnmounted(() => {
|
|
|
</div>
|
|
|
</div>
|
|
|
</section>
|
|
|
+
|
|
|
+ <div v-if="inspectOpen && inspectNode" class="path-dock">
|
|
|
+ <DemandPathPanel
|
|
|
+ :open="true"
|
|
|
+ :category-name="inspectNode.name"
|
|
|
+ :node-name="inspectNode.name"
|
|
|
+ :node-path="inspectNode.path.join(' / ')"
|
|
|
+ :items="inspectItems"
|
|
|
+ @close="closeInspect"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
@@ -962,6 +1009,16 @@ onUnmounted(() => {
|
|
|
border-color: #94a3b8;
|
|
|
}
|
|
|
|
|
|
+.path-dock {
|
|
|
+ flex-shrink: 0;
|
|
|
+ max-height: 340px;
|
|
|
+ overflow: auto;
|
|
|
+ border: 1px solid #e2e8f0;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: #fff;
|
|
|
+ padding: 12px 14px;
|
|
|
+}
|
|
|
+
|
|
|
.depth-axis {
|
|
|
display: grid;
|
|
|
padding: 8px 12px;
|