|
|
@@ -101,6 +101,9 @@ const activeTab = ref<HeatTabKey>(props.initialTab)
|
|
|
const startDepth = ref(0)
|
|
|
const focus = ref<PreparedNode | null>(null)
|
|
|
const selectedNode = ref<PreparedNode | null>(null)
|
|
|
+const searchQuery = ref('')
|
|
|
+const searchOpen = ref(false)
|
|
|
+const searchHighlightedNodeId = ref<number | null>(null)
|
|
|
|
|
|
/** Vertical zoom only — column width stays fixed at COLUMN_WIDTH. */
|
|
|
const viewScaleY = ref(1)
|
|
|
@@ -233,6 +236,33 @@ const preparedNodeById = computed(() => {
|
|
|
return m
|
|
|
})
|
|
|
|
|
|
+const searchResults = computed(() => {
|
|
|
+ const keyword = searchQuery.value.trim().toLocaleLowerCase('zh-CN')
|
|
|
+ if (!keyword) return []
|
|
|
+
|
|
|
+ return prepared.value.flat
|
|
|
+ .map((node) => {
|
|
|
+ const name = node.name.toLocaleLowerCase('zh-CN')
|
|
|
+ const path = node.path.join(' / ').toLocaleLowerCase('zh-CN')
|
|
|
+ let score = 4
|
|
|
+ if (name === keyword) score = 0
|
|
|
+ else if (name.startsWith(keyword)) score = 1
|
|
|
+ else if (name.includes(keyword)) score = 2
|
|
|
+ else if (path.includes(keyword)) score = 3
|
|
|
+ else return null
|
|
|
+ return { node, score }
|
|
|
+ })
|
|
|
+ .filter((item): item is { node: PreparedNode; score: number } => item != null)
|
|
|
+ .sort(
|
|
|
+ (a, b) =>
|
|
|
+ a.score - b.score ||
|
|
|
+ a.node.path.length - b.node.path.length ||
|
|
|
+ a.node.name.localeCompare(b.node.name, 'zh-CN'),
|
|
|
+ )
|
|
|
+ .slice(0, 20)
|
|
|
+ .map((item) => item.node)
|
|
|
+})
|
|
|
+
|
|
|
const demandCards = computed<DemandGradeListCard[]>(() => {
|
|
|
// props.demandsByCategory: category_id -> DemandGradeItem[]
|
|
|
const metaById = new Map<
|
|
|
@@ -405,7 +435,7 @@ const mapContext = computed(() => {
|
|
|
return `${focus.value.path.join(' / ')} · 当前分支 · 共 ${total} 个节点${heatHint}`
|
|
|
}
|
|
|
if (startDepth.value > 0) {
|
|
|
- return `从第 ${startDepth.value} 级开始 · ${currentViewRoots.value.length} 个可视根 · 共 ${total} 个节点${heatHint}`
|
|
|
+ return `从第 ${startDepth.value + 1} 级开始 · ${currentViewRoots.value.length} 个可视根 · 共 ${total} 个节点${heatHint}`
|
|
|
}
|
|
|
return `全局视图 · 横向为层级,纵向为分支规模 · ${total} 个节点${heatHint}`
|
|
|
})
|
|
|
@@ -478,6 +508,14 @@ function onTabClick(key: HeatTabKey) {
|
|
|
emit('tabChange', key)
|
|
|
}
|
|
|
|
|
|
+function formatAggregateNumber(value: number | null | undefined): string {
|
|
|
+ if (value == null || Number.isNaN(value)) return '0'
|
|
|
+ return value.toLocaleString('zh-CN', {
|
|
|
+ minimumFractionDigits: 0,
|
|
|
+ maximumFractionDigits: 2,
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
function resetViewTransform() {
|
|
|
viewScaleY.value = 1
|
|
|
viewY.value = 0
|
|
|
@@ -487,6 +525,7 @@ function resetGlobalView() {
|
|
|
startDepth.value = 0
|
|
|
focus.value = null
|
|
|
selectedNode.value = null
|
|
|
+ searchHighlightedNodeId.value = null
|
|
|
clearDemandListSelection()
|
|
|
closeInspect()
|
|
|
hideTooltip()
|
|
|
@@ -504,9 +543,10 @@ function resetGlobalView() {
|
|
|
function selectNode(
|
|
|
node: PreparedNode,
|
|
|
withFocus = false,
|
|
|
- source: 'canvas' | 'demandList' = 'canvas',
|
|
|
+ source: 'canvas' | 'demandList' | 'search' = 'canvas',
|
|
|
) {
|
|
|
- if (source === 'canvas') clearDemandListSelection()
|
|
|
+ if (source !== 'demandList') clearDemandListSelection()
|
|
|
+ searchHighlightedNodeId.value = source === 'search' ? node.id : null
|
|
|
selectedNode.value = node
|
|
|
emit('nodeSelected', { id: node.id, name: node.name })
|
|
|
if (withFocus) {
|
|
|
@@ -539,6 +579,30 @@ function selectNode(
|
|
|
scheduleDraw()
|
|
|
}
|
|
|
|
|
|
+function selectSearchResult(node: PreparedNode) {
|
|
|
+ searchQuery.value = node.name
|
|
|
+ searchOpen.value = false
|
|
|
+ selectNode(node, true, 'search')
|
|
|
+}
|
|
|
+
|
|
|
+function onSearchEnter() {
|
|
|
+ const first = searchResults.value[0]
|
|
|
+ if (first) selectSearchResult(first)
|
|
|
+}
|
|
|
+
|
|
|
+function onSearchInput() {
|
|
|
+ searchOpen.value = true
|
|
|
+ searchHighlightedNodeId.value = null
|
|
|
+ scheduleDraw()
|
|
|
+}
|
|
|
+
|
|
|
+function clearSearch() {
|
|
|
+ searchQuery.value = ''
|
|
|
+ searchOpen.value = false
|
|
|
+ searchHighlightedNodeId.value = null
|
|
|
+ scheduleDraw()
|
|
|
+}
|
|
|
+
|
|
|
function closeInspect() {
|
|
|
inspectOpen.value = false
|
|
|
inspectNode.value = null
|
|
|
@@ -551,6 +615,7 @@ function onDepthClick(depth: number) {
|
|
|
}
|
|
|
focus.value = null
|
|
|
selectedNode.value = null
|
|
|
+ searchHighlightedNodeId.value = null
|
|
|
clearDemandListSelection()
|
|
|
closeInspect()
|
|
|
startDepth.value = depth
|
|
|
@@ -724,6 +789,7 @@ function draw() {
|
|
|
const vx1 = vw + pad
|
|
|
const vy1 = vh + pad
|
|
|
const selected = selectedNode.value
|
|
|
+ const searchHighlightedId = searchHighlightedNodeId.value
|
|
|
const strokeColor = activeDim.value ? 'rgba(15,23,42,.08)' : 'rgba(255,255,255,.88)'
|
|
|
|
|
|
drawRects = []
|
|
|
@@ -748,9 +814,20 @@ function draw() {
|
|
|
ctx.strokeRect(x, y, width, height)
|
|
|
}
|
|
|
|
|
|
+ if (searchHighlightedId === item.node.id) {
|
|
|
+ ctx.strokeStyle = '#f59e0b'
|
|
|
+ ctx.lineWidth = 6
|
|
|
+ ctx.strokeRect(
|
|
|
+ x + 3,
|
|
|
+ y + 3,
|
|
|
+ Math.max(0, width - 6),
|
|
|
+ Math.max(0, height - 6),
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
if (selected === item.node) {
|
|
|
ctx.strokeStyle = '#7c3aed'
|
|
|
- ctx.lineWidth = 3
|
|
|
+ ctx.lineWidth = 2
|
|
|
ctx.strokeRect(
|
|
|
x + 1.5,
|
|
|
y + 1.5,
|
|
|
@@ -1005,10 +1082,53 @@ onUnmounted(() => {
|
|
|
|
|
|
<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 class="map-toolbar-actions">
|
|
|
+ <div class="node-search" role="search">
|
|
|
+ <span class="node-search-icon" aria-hidden="true">⌕</span>
|
|
|
+ <input
|
|
|
+ v-model="searchQuery"
|
|
|
+ type="search"
|
|
|
+ autocomplete="off"
|
|
|
+ placeholder="搜索节点名称"
|
|
|
+ aria-label="搜索分类节点"
|
|
|
+ @focus="searchOpen = true"
|
|
|
+ @input="onSearchInput"
|
|
|
+ @blur="searchOpen = false"
|
|
|
+ @keydown.enter.prevent="onSearchEnter"
|
|
|
+ @keydown.esc.prevent="searchOpen = false"
|
|
|
+ >
|
|
|
+ <button
|
|
|
+ v-if="searchQuery"
|
|
|
+ type="button"
|
|
|
+ class="node-search-clear"
|
|
|
+ aria-label="清空搜索"
|
|
|
+ @click="clearSearch"
|
|
|
+ >
|
|
|
+ ×
|
|
|
+ </button>
|
|
|
+ <div v-if="searchOpen && searchQuery.trim()" class="node-search-results">
|
|
|
+ <button
|
|
|
+ v-for="node in searchResults"
|
|
|
+ :key="node.id"
|
|
|
+ type="button"
|
|
|
+ class="node-search-result"
|
|
|
+ @mousedown.prevent="selectSearchResult(node)"
|
|
|
+ >
|
|
|
+ <span class="node-search-name">{{ node.name }}</span>
|
|
|
+ <span class="node-search-path">
|
|
|
+ 第 {{ node.path.length }} 级 · {{ node.path.join(' / ') }}
|
|
|
+ </span>
|
|
|
+ </button>
|
|
|
+ <div v-if="!searchResults.length" class="node-search-empty">
|
|
|
+ 未找到匹配节点
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="view-actions">
|
|
|
+ <button type="button" class="btn btn-secondary" @click="resetGlobalView">
|
|
|
+ 回到全局
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
@@ -1023,7 +1143,7 @@ onUnmounted(() => {
|
|
|
:class="{ active: depth - 1 === baseDepth }"
|
|
|
@click="onDepthClick(depth - 1)"
|
|
|
>
|
|
|
- {{ depth - 1 === 0 ? '根' : `第 ${depth - 1} 级` }}
|
|
|
+ 第 {{ depth }} 级
|
|
|
</button>
|
|
|
</div>
|
|
|
|
|
|
@@ -1076,10 +1196,15 @@ onUnmounted(() => {
|
|
|
{{ activeDimLabel }}:{{ formatNodeDimScore(tooltipNode, activeDim) }}
|
|
|
</span>
|
|
|
<span>
|
|
|
- 第 {{ tooltipNode.path.length - 1 }} 级
|
|
|
+ 第 {{ tooltipNode.path.length }} 级
|
|
|
· {{ tooltipNode.children.length }} 个子节点
|
|
|
· {{ tooltipNode.leafCount }} 个叶节点
|
|
|
</span>
|
|
|
+ <span>去重账号数:{{ formatAggregateNumber(tooltipNode.account_uid_count) }}</span>
|
|
|
+ <span>
|
|
|
+ 去重文章数:{{ formatAggregateNumber(tooltipNode.channel_content_id_count) }}
|
|
|
+ </span>
|
|
|
+ <span>粉丝数合计:{{ formatAggregateNumber(tooltipNode.cal_fans_num_sum) }}</span>
|
|
|
<span v-if="tooltipNode.hung_word_count != null">
|
|
|
挂靠词 {{ tooltipNode.hung_word_count }}
|
|
|
</span>
|
|
|
@@ -1432,6 +1557,130 @@ onUnmounted(() => {
|
|
|
gap: 8px;
|
|
|
}
|
|
|
|
|
|
+.map-toolbar-actions {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.node-search {
|
|
|
+ position: relative;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ width: min(360px, 38vw);
|
|
|
+ min-width: 220px;
|
|
|
+}
|
|
|
+
|
|
|
+.node-search-icon {
|
|
|
+ position: absolute;
|
|
|
+ left: 10px;
|
|
|
+ z-index: 1;
|
|
|
+ color: #64748b;
|
|
|
+ font-size: 18px;
|
|
|
+ line-height: 1;
|
|
|
+ pointer-events: none;
|
|
|
+}
|
|
|
+
|
|
|
+.node-search input {
|
|
|
+ width: 100%;
|
|
|
+ height: 34px;
|
|
|
+ padding: 0 32px 0 34px;
|
|
|
+ border: 1px solid #cbd5e1;
|
|
|
+ border-radius: 7px;
|
|
|
+ outline: none;
|
|
|
+ background: #fff;
|
|
|
+ color: #0f172a;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+
|
|
|
+.node-search input:focus {
|
|
|
+ border-color: #7c3aed;
|
|
|
+ box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.12);
|
|
|
+}
|
|
|
+
|
|
|
+.node-search-clear {
|
|
|
+ position: absolute;
|
|
|
+ right: 5px;
|
|
|
+ width: 25px;
|
|
|
+ height: 25px;
|
|
|
+ padding: 0;
|
|
|
+ border: 0;
|
|
|
+ border-radius: 5px;
|
|
|
+ background: transparent;
|
|
|
+ color: #64748b;
|
|
|
+ font-size: 18px;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.node-search-clear:hover {
|
|
|
+ background: #f1f5f9;
|
|
|
+ color: #0f172a;
|
|
|
+}
|
|
|
+
|
|
|
+.node-search-results {
|
|
|
+ position: absolute;
|
|
|
+ top: calc(100% + 6px);
|
|
|
+ right: 0;
|
|
|
+ z-index: 20;
|
|
|
+ width: max(100%, 360px);
|
|
|
+ max-height: 360px;
|
|
|
+ overflow-y: auto;
|
|
|
+ padding: 5px;
|
|
|
+ border: 1px solid #cbd5e1;
|
|
|
+ border-radius: 8px;
|
|
|
+ background: #fff;
|
|
|
+ box-shadow: 0 14px 32px rgba(15, 23, 42, 0.18);
|
|
|
+}
|
|
|
+
|
|
|
+.node-search-result {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 3px;
|
|
|
+ width: 100%;
|
|
|
+ padding: 8px 10px;
|
|
|
+ border: 0;
|
|
|
+ border-radius: 6px;
|
|
|
+ background: transparent;
|
|
|
+ text-align: left;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.node-search-result:hover {
|
|
|
+ background: #f5f3ff;
|
|
|
+}
|
|
|
+
|
|
|
+.node-search-name {
|
|
|
+ color: #0f172a;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+.node-search-path {
|
|
|
+ overflow: hidden;
|
|
|
+ color: #64748b;
|
|
|
+ font-size: 11px;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.node-search-empty {
|
|
|
+ padding: 20px 12px;
|
|
|
+ color: #94a3b8;
|
|
|
+ font-size: 13px;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+@media (max-width: 720px) {
|
|
|
+ .map-toolbar-actions,
|
|
|
+ .node-search {
|
|
|
+ width: 100%;
|
|
|
+ }
|
|
|
+
|
|
|
+ .node-search-results {
|
|
|
+ width: 100%;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
.btn {
|
|
|
height: 32px;
|
|
|
padding: 0 12px;
|