feat(wiki): configurable entity types, type legend filter & theme-aligned graph colors (#336)

- per-KB entity-type whitelist (config UI + persistence; empty = built-in defaults)
- entity graph: legend grouped by type with click-to-filter; nodes colored by type
- always show entity names on graph nodes (not only on hover)
- earthy categorical palette aligned to the app theme, shared by entity & page graphs
- theme-aware graph label color (resolve CSS var for canvas, light/dark correct)
- manual extract = full rebuild: idempotent force re-extraction + orphan pruning,
  guarded against data loss on a fully-failed run
- regression test for force re-extraction; zh/en i18n
This commit is contained in:
matevip 2026-06-19 07:18:09 +08:00
parent 31c98e923d
commit 4804954ad2
8 changed files with 292 additions and 28 deletions

View File

@ -31,9 +31,11 @@ import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set;
/** /**
* Extracts a named-entity knowledge graph from source chunks: it pulls * Extracts a named-entity knowledge graph from source chunks: it pulls
@ -90,7 +92,15 @@ public class WikiEntityExtractionService {
* chunks already processed * chunks already processed
*/ */
public int extractForKb(Long kbId, boolean force) { public int extractForKb(Long kbId, boolean force) {
return extract(kbId, chunkService.listByKbId(kbId), force); int touched = extract(kbId, chunkService.listByKbId(kbId), force);
if (force && touched > 0) {
// A forced full rebuild may have dropped entity types from the KB
// config; entities that no longer earn a mention become orphans.
// Skip pruning when the run resolved nothing (e.g. the model was
// unavailable) so a total failure can't wipe the existing graph.
pruneOrphanEntities(kbId);
}
return touched;
} }
private int extract(Long kbId, List<WikiChunkEntity> chunks, boolean force) { private int extract(Long kbId, List<WikiChunkEntity> chunks, boolean force) {
@ -119,7 +129,8 @@ public class WikiEntityExtractionService {
if (chunk.getContent() == null || chunk.getContent().isBlank()) { if (chunk.getContent() == null || chunk.getContent().isBlank()) {
continue; continue;
} }
if (!force && hasMentions(chunk.getId())) { boolean alreadyProcessed = hasMentions(chunk.getId());
if (alreadyProcessed && !force) {
continue; continue;
} }
try { try {
@ -127,6 +138,12 @@ public class WikiEntityExtractionService {
if (result == null) { if (result == null) {
continue; continue;
} }
// Forced re-extraction: only now that we have a fresh result do
// we wipe the chunk's prior mentions/relations, so a failed LLM
// call leaves the existing graph intact instead of destroying it.
if (alreadyProcessed) {
clearChunkArtifacts(chunk.getId());
}
persistChunk(kbId, chunk, result, resolved, index); persistChunk(kbId, chunk, result, resolved, index);
} catch (Exception e) { } catch (Exception e) {
log.warn("[WikiEntity] Extraction failed for chunkId={} kbId={}: {}", log.warn("[WikiEntity] Extraction failed for chunkId={} kbId={}: {}",
@ -335,6 +352,71 @@ public class WikiEntityExtractionService {
return count != null && count > 0; return count != null && count > 0;
} }
/**
* Remove a chunk's previously-extracted mentions and relations and roll
* back the affected entities' mention counts so a forced re-extraction is
* idempotent. Entities themselves are kept here; orphans (those left with
* zero mentions after a full rebuild) are swept by {@link #pruneOrphanEntities}.
*/
private void clearChunkArtifacts(Long chunkId) {
List<WikiEntityMentionEntity> existing = mentionMapper.selectList(
new LambdaQueryWrapper<WikiEntityMentionEntity>()
.eq(WikiEntityMentionEntity::getChunkId, chunkId));
Set<Long> affected = new HashSet<>();
for (WikiEntityMentionEntity m : existing) {
if (m.getEntityId() != null) {
affected.add(m.getEntityId());
}
}
mentionMapper.delete(new LambdaQueryWrapper<WikiEntityMentionEntity>()
.eq(WikiEntityMentionEntity::getChunkId, chunkId));
relationMapper.delete(new LambdaQueryWrapper<WikiEntityRelationEntity>()
.eq(WikiEntityRelationEntity::getEvidenceChunkId, chunkId));
// Recompute each affected entity's mention count from the surviving
// (non-deleted) rows rather than decrementing, so counts stay exact.
for (Long entityId : affected) {
WikiEntityEntity e = entityMapper.selectById(entityId);
if (e == null) {
continue;
}
Long remaining = mentionMapper.selectCount(new LambdaQueryWrapper<WikiEntityMentionEntity>()
.eq(WikiEntityMentionEntity::getEntityId, entityId));
int count = remaining == null ? 0 : remaining.intValue();
e.setMentionCount(count);
e.setSalience(BigDecimal.valueOf((double) count / (count + 5.0))
.setScale(4, RoundingMode.HALF_UP));
entityMapper.updateById(e);
}
}
/**
* Delete entities in a KB that have no mentions left (and the relations that
* referenced them). Runs after a forced full rebuild so entity types removed
* from the KB config stop appearing in the graph.
*/
private void pruneOrphanEntities(Long kbId) {
List<WikiEntityEntity> orphans = entityMapper.selectList(
new LambdaQueryWrapper<WikiEntityEntity>()
.eq(WikiEntityEntity::getKbId, kbId)
.and(w -> w.isNull(WikiEntityEntity::getMentionCount)
.or().le(WikiEntityEntity::getMentionCount, 0)));
if (orphans == null || orphans.isEmpty()) {
return;
}
Set<Long> ids = new HashSet<>();
for (WikiEntityEntity e : orphans) {
ids.add(e.getId());
}
relationMapper.delete(new LambdaQueryWrapper<WikiEntityRelationEntity>()
.eq(WikiEntityRelationEntity::getKbId, kbId)
.and(w -> w.in(WikiEntityRelationEntity::getSubjectEntityId, ids)
.or().in(WikiEntityRelationEntity::getObjectEntityId, ids)));
entityMapper.delete(new LambdaQueryWrapper<WikiEntityEntity>()
.eq(WikiEntityEntity::getKbId, kbId)
.in(WikiEntityEntity::getId, ids));
log.info("[WikiEntity] Pruned {} orphan entities for kbId={}", ids.size(), kbId);
}
private Long firstCitingPage(Long chunkId) { private Long firstCitingPage(Long chunkId) {
List<Long> pages = citationMapper.listPageIdsByChunkId(chunkId); List<Long> pages = citationMapper.listPageIdsByChunkId(chunkId);
return (pages == null || pages.isEmpty()) ? null : pages.get(0); return (pages == null || pages.isEmpty()) ? null : pages.get(0);

View File

@ -148,6 +148,30 @@ class WikiEntityExtractionServiceTest {
verify(relationMapper, times(2)).insert(any(WikiEntityRelationEntity.class)); verify(relationMapper, times(2)).insert(any(WikiEntityRelationEntity.class));
} }
@Test
@DisplayName("extractForKb(force): clears a chunk's prior mentions/relations before re-extracting")
void extractForKb_forceClearsBeforeReextract() {
when(chunkService.listByKbId(KB_ID)).thenReturn(List.of(chunk(1L, "Alice works at Acme.")));
// Chunk already processed hasMentions() is true, so the force path runs
// its clear step instead of skipping.
when(mentionMapper.selectCount(any(Wrapper.class))).thenReturn(1L);
// One stale mention exists for this chunk, pointing at an entity not in
// the store (so the count-recompute loop simply skips it).
WikiEntityMentionEntity stale = new WikiEntityMentionEntity();
stale.setEntityId(999L);
stale.setChunkId(1L);
when(mentionMapper.selectList(any())).thenReturn(List.of(stale));
int touched = service.extractForKb(KB_ID, true);
assertEquals(2, touched, "should re-resolve both entities on a forced rebuild");
// Stale artifacts wiped exactly once before re-extraction.
verify(mentionMapper, times(1)).delete(any());
verify(relationMapper, times(1)).delete(any());
// Fresh mentions re-inserted (2 entities), not duplicated on top of the old ones.
verify(mentionMapper, times(2)).insert(any(WikiEntityMentionEntity.class));
}
@Test @Test
@DisplayName("extractForRaw: skips chunks that already have mentions") @DisplayName("extractForRaw: skips chunks that already have mentions")
void extractForRaw_skipsProcessedChunks() { void extractForRaw_skipsProcessedChunks() {

View File

@ -9,27 +9,30 @@ import { useWikiStore } from '@/stores/useWikiStore'
* built-in ten types. * built-in ten types.
*/ */
// Fixed colours for the built-in ten types so existing KBs look unchanged. // Earthy categorical palette tuned to the warm terracotta + teal app theme
// (see main.css design tokens). Hues are mutually contrasting yet share an
// earthy register so the page graph, sidebar and toolbar feel of-a-piece with
// the rest of the UI. Shared with the entity graph for one visual language.
const BUILTIN_COLORS: Record<string, string> = { const BUILTIN_COLORS: Record<string, string> = {
concept: '#D96E46', concept: '#6B9A55', // sage
person: '#5B8DEF', person: '#D97757', // terracotta (theme primary)
place: '#4CAF82', place: '#E0A030', // goldenrod
event: '#F59E0B', event: '#9B5E8E', // plum
technology: '#8B5CF6', technology: '#4FA39B', // aqua
organization: '#EC4899', organization: '#2F8F83', // teal (theme accent)
product: '#14B8A6', product: '#5B7DB1', // denim blue
term: '#6B7280', term: '#A07B5C', // taupe
process: '#F97316', process: '#C08A4E', // ochre
other: '#9CA3AF', other: '#9C8576', // warm grey
} }
// Palette for custom / synthesis types not in the built-in map. A type name is // Palette for custom / synthesis types not in the built-in map. A type name is
// hashed to a stable index so the same type always gets the same colour within // hashed to a stable index so the same type always gets the same colour within
// and across sessions, without needing a colour field on the profile schema. // and across sessions, without needing a colour field on the profile schema.
// Same earthy family so generated types stay on-theme.
const HASH_PALETTE = [ const HASH_PALETTE = [
'#0EA5E9', '#A855F7', '#22C55E', '#EAB308', '#EF4444', '#C0533F', '#B06E7C', '#8B934A', '#A07B5C', '#5B7DB1', '#9B5E8E',
'#06B6D4', '#D946EF', '#84CC16', '#F43F5E', '#3B82F6', '#4FA39B', '#C08A4E', '#6B9A55', '#2F8F83', '#D97757', '#E0A030',
'#10B981', '#FB923C',
] ]
function hashIndex(s: string, mod: number): number { function hashIndex(s: string, mod: number): number {

View File

@ -2497,6 +2497,9 @@ export default {
entityExtractionEnable: 'Enable entity extraction', entityExtractionEnable: 'Enable entity extraction',
entityExtractionRun: 'Extract now', entityExtractionRun: 'Extract now',
entityExtractionRunning: 'Extracting…', entityExtractionRunning: 'Extracting…',
entityTypesLabel: 'Entity types to extract',
entityTypesPlaceholder: 'Select or type an entity type, Enter to add',
entityTypesHint: 'Restrict which entity types are extracted; leave empty to use the built-in defaults (person, organization, location, event, product, concept).',
modelStrategy: 'Model Strategy', modelStrategy: 'Model Strategy',
globalDefault: 'Global default', globalDefault: 'Global default',
selectModel: 'Select a model…', selectModel: 'Select a model…',
@ -2559,6 +2562,18 @@ export default {
synthesis: 'Synthesis', synthesis: 'Synthesis',
other: 'Other', other: 'Other',
}, },
entityTypes: {
person: 'Person',
organization: 'Organization',
location: 'Location',
place: 'Place',
event: 'Event',
product: 'Product',
concept: 'Concept',
technology: 'Technology',
term: 'Term',
other: 'Other',
},
graph: { graph: {
tab: 'Knowledge Graph', tab: 'Knowledge Graph',
nodes: 'nodes', nodes: 'nodes',

View File

@ -2509,6 +2509,9 @@ export default {
entityExtractionEnable: '开启实体抽取', entityExtractionEnable: '开启实体抽取',
entityExtractionRun: '立即抽取', entityExtractionRun: '立即抽取',
entityExtractionRunning: '抽取中…', entityExtractionRunning: '抽取中…',
entityTypesLabel: '抽取的实体类型',
entityTypesPlaceholder: '选择或输入实体类型,回车添加',
entityTypesHint: '限定要抽取的实体类型;留空则使用内置默认类型(人物、组织、地点、事件、产品、概念)。',
modelStrategy: '模型策略', modelStrategy: '模型策略',
globalDefault: '跟随全局默认', globalDefault: '跟随全局默认',
selectModel: '选择可用模型…', selectModel: '选择可用模型…',
@ -2571,6 +2574,18 @@ export default {
synthesis: '综合', synthesis: '综合',
other: '其他', other: '其他',
}, },
entityTypes: {
person: '人物',
organization: '组织',
location: '地点',
place: '地点',
event: '事件',
product: '产品',
concept: '概念',
technology: '技术',
term: '术语',
other: '其他',
},
graph: { graph: {
tab: '知识图谱', tab: '知识图谱',
nodes: '节点', nodes: '节点',

View File

@ -71,6 +71,25 @@
{{ extracting ? t('wiki.configPanel.entityExtractionRunning') : t('wiki.configPanel.entityExtractionRun') }} {{ extracting ? t('wiki.configPanel.entityExtractionRunning') : t('wiki.configPanel.entityExtractionRun') }}
</button> </button>
</div> </div>
<!-- Entity types to extract (whitelist). Empty = use built-in defaults. -->
<div v-if="entityExtractionEnabled" class="entity-types">
<div class="entity-types__label">{{ t('wiki.configPanel.entityTypesLabel') }}</div>
<el-select
v-model="entityTypes"
multiple
filterable
allow-create
default-first-option
:reserve-keyword="false"
size="small"
class="entity-types__select"
:placeholder="t('wiki.configPanel.entityTypesPlaceholder')"
>
<el-option v-for="opt in DEFAULT_ENTITY_TYPES" :key="opt" :label="formatEntityType(opt)" :value="opt" />
</el-select>
<div class="entity-types__hint">{{ t('wiki.configPanel.entityTypesHint') }}</div>
</div>
</div> </div>
<!-- Model strategy --> <!-- Model strategy -->
@ -186,9 +205,22 @@ import WikiModelPicker, { type ModelOption } from './WikiModelPicker.vue'
import WikiConfigRules from './WikiConfigRules.vue' import WikiConfigRules from './WikiConfigRules.vue'
import WikiConfigModels from './WikiConfigModels.vue' import WikiConfigModels from './WikiConfigModels.vue'
const { t } = useI18n() const { t, te } = useI18n()
const store = useWikiStore() const store = useWikiStore()
// Built-in entity types offered as suggestions; users may add custom ones.
// Empty config falls back to the backend's default set.
const DEFAULT_ENTITY_TYPES = ['person', 'organization', 'location', 'event', 'product', 'concept']
// Localized label for an entity type in the suggestion dropdown.
function formatEntityType(type: string): string {
const key = (type || '').toLowerCase()
if (!key) return ''
const i18nKey = `wiki.entityTypes.${key}`
if (te(i18nKey)) return `${t(i18nKey)} (${key})`
return key
}
// Rules state // Rules state
const configContent = ref('') const configContent = ref('')
const rulesOpen = ref(false) const rulesOpen = ref(false)
@ -261,6 +293,7 @@ async function saveIngestMode() {
// Opt-in named-entity knowledge graph extraction. Off by default because it // Opt-in named-entity knowledge graph extraction. Off by default because it
// adds an LLM call per chunk on top of the page pipeline. // adds an LLM call per chunk on top of the page pipeline.
const entityExtractionEnabled = ref(false) const entityExtractionEnabled = ref(false)
const entityTypes = ref<string[]>([])
const savingEntityExtraction = ref(false) const savingEntityExtraction = ref(false)
const extracting = ref(false) const extracting = ref(false)
@ -273,6 +306,12 @@ async function saveEntityExtraction() {
if (store.currentKB.configContent) existingConfig = JSON.parse(store.currentKB.configContent) if (store.currentKB.configContent) existingConfig = JSON.parse(store.currentKB.configContent)
} catch { /* config may be plain text rules */ } } catch { /* config may be plain text rules */ }
existingConfig.entityExtractionEnabled = entityExtractionEnabled.value ? true : undefined existingConfig.entityExtractionEnabled = entityExtractionEnabled.value ? true : undefined
// Normalize to trimmed lowercase keys so they match the extractor's type
// normalization; empty list drops the field and uses backend defaults.
const cleanedTypes = entityExtractionEnabled.value
? [...new Set(entityTypes.value.map(s => s.trim().toLowerCase()).filter(Boolean))]
: []
existingConfig.entityTypes = cleanedTypes.length > 0 ? cleanedTypes : undefined
await wikiApi.updateConfig(store.currentKB.id, JSON.stringify(existingConfig, null, 2)) await wikiApi.updateConfig(store.currentKB.id, JSON.stringify(existingConfig, null, 2))
} catch (e) { } catch (e) {
console.error('[WikiConfig] Failed to save entity extraction toggle', e) console.error('[WikiConfig] Failed to save entity extraction toggle', e)
@ -285,7 +324,9 @@ async function runExtraction() {
if (!store.currentKB) return if (!store.currentKB) return
extracting.value = true extracting.value = true
try { try {
await wikiApi.extractEntities(store.currentKB.id) // Manual trigger is a full rebuild (force): re-process every chunk so the
// current entity-type config takes effect even on already-extracted KBs.
await wikiApi.extractEntities(store.currentKB.id, true)
} catch (e) { } catch (e) {
console.error('[WikiConfig] Failed to start entity extraction', e) console.error('[WikiConfig] Failed to start entity extraction', e)
} finally { } finally {
@ -310,6 +351,7 @@ function loadStepModels() {
wikiGlobalModelId.value = '' wikiGlobalModelId.value = ''
ingestMode.value = 'eager' ingestMode.value = 'eager'
entityExtractionEnabled.value = false entityExtractionEnabled.value = false
entityTypes.value = []
if (!store.currentKB) return if (!store.currentKB) return
try { try {
const cfg = store.currentKB.configContent ? JSON.parse(store.currentKB.configContent) : null const cfg = store.currentKB.configContent ? JSON.parse(store.currentKB.configContent) : null
@ -323,6 +365,7 @@ function loadStepModels() {
if (cfg?.wikiDefaultModelId) wikiGlobalModelId.value = String(cfg.wikiDefaultModelId) if (cfg?.wikiDefaultModelId) wikiGlobalModelId.value = String(cfg.wikiDefaultModelId)
if (cfg?.ingestMode === 'lazy') ingestMode.value = 'lazy' if (cfg?.ingestMode === 'lazy') ingestMode.value = 'lazy'
if (cfg?.entityExtractionEnabled) entityExtractionEnabled.value = true if (cfg?.entityExtractionEnabled) entityExtractionEnabled.value = true
if (Array.isArray(cfg?.entityTypes)) entityTypes.value = cfg.entityTypes.map(String)
} catch { /* not JSON */ } } catch { /* not JSON */ }
} }
@ -547,6 +590,12 @@ loadProviderNames().then(() => {
.entity-toggle { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--mc-text-primary); cursor: pointer; } .entity-toggle { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--mc-text-primary); cursor: pointer; }
.entity-toggle__label { user-select: none; } .entity-toggle__label { user-select: none; }
/* Entity types editor */
.entity-types { display: flex; flex-direction: column; gap: 6px; }
.entity-types__label { font-size: 12px; font-weight: 600; color: var(--mc-text-secondary); }
.entity-types__select { width: 100%; }
.entity-types__hint { font-size: 11px; color: var(--mc-text-tertiary); line-height: 1.4; }
/* Ingest mode radio group */ /* Ingest mode radio group */
.ingest-mode-row { display: flex; gap: 8px; flex-wrap: wrap; } .ingest-mode-row { display: flex; gap: 8px; flex-wrap: wrap; }
.ingest-mode-option { .ingest-mode-option {

View File

@ -7,7 +7,7 @@
<button class="entity-panel__close" @click="selected = null">×</button> <button class="entity-panel__close" @click="selected = null">×</button>
<h3 class="entity-panel__title">{{ selected.canonicalName }}</h3> <h3 class="entity-panel__title">{{ selected.canonicalName }}</h3>
<span class="entity-panel__type" :style="{ background: typeColor(selected.type) }"> <span class="entity-panel__type" :style="{ background: typeColor(selected.type) }">
{{ selected.type }} {{ formatType(selected.type) }}
</span> </span>
<span class="entity-panel__count">{{ selected.mentionCount || 0 }} {{ t('wiki.graph.mentions') }}</span> <span class="entity-panel__count">{{ selected.mentionCount || 0 }} {{ t('wiki.graph.mentions') }}</span>
@ -61,7 +61,7 @@ import { wikiApi } from '@/api'
echarts.use([GraphChart, TooltipComponent, LegendComponent, CanvasRenderer]) echarts.use([GraphChart, TooltipComponent, LegendComponent, CanvasRenderer])
const { t } = useI18n() const { t, te } = useI18n()
interface EntityNode { interface EntityNode {
id: number | string id: number | string
@ -95,12 +95,47 @@ const selected = ref<EntityNode | null>(null)
const egoEdges = ref<{ predicate: string; label: string }[]>([]) const egoEdges = ref<{ predicate: string; label: string }[]>([])
const egoPages = ref<{ pageId: number | string; slug: string; title: string }[]>([]) const egoPages = ref<{ pageId: number | string; slug: string; title: string }[]>([])
// Stable color per entity type via a small hash palette. // Earthy categorical palette tuned to the warm terracotta + teal app theme
const PALETTE = ['#5b8ff9', '#5ad8a6', '#f6bd16', '#e8684a', '#6dc8ec', '#9270ca', '#ff9d4d', '#269a99'] // (see main.css design tokens). Each common entity type gets a fixed, mutually
// contrasting hue person (terracotta) vs product (denim) reads as a clear
// warm/cool split rather than the near-identical blues used before. All hues
// sit at mid lightness so they stay legible on both the light and dark canvas.
const ENTITY_TYPE_COLORS: Record<string, string> = {
person: '#D97757', // terracotta (theme primary)
organization: '#2F8F83', // teal (theme accent)
location: '#E0A030', // goldenrod
place: '#E0A030',
event: '#9B5E8E', // plum
product: '#5B7DB1', // denim blue
concept: '#6B9A55', // sage
technology: '#4FA39B', // aqua
term: '#C0533F', // brick
other: '#9C8576', // warm taupe
}
// Stable fallback palette (same earthy family) for user-defined types.
const FALLBACK_PALETTE = ['#C08A4E', '#B06E7C', '#8B934A', '#A07B5C', '#5B7DB1', '#9B5E8E', '#4FA39B', '#C0533F']
function typeColor(type: string): string { function typeColor(type: string): string {
const key = (type || 'other').toLowerCase()
const fixed = ENTITY_TYPE_COLORS[key]
if (fixed) return fixed
let h = 0 let h = 0
for (let i = 0; i < (type || '').length; i++) h = (h * 31 + type.charCodeAt(i)) >>> 0 for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0
return PALETTE[h % PALETTE.length] return FALLBACK_PALETTE[h % FALLBACK_PALETTE.length]
}
// Resolve a CSS custom property to its computed value so ECharts (canvas, which
// can't read CSS vars) picks up the active light/dark theme color.
function cssVar(name: string, fallback: string): string {
if (typeof document === 'undefined') return fallback
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
return v || fallback
}
// Localized label for an entity type, fallback to capitalized raw key.
function formatType(type: string): string {
const key = (type || '').toLowerCase()
if (!key) return ''
const i18nKey = `wiki.entityTypes.${key}`
if (te(i18nKey)) return t(i18nKey)
return key.charAt(0).toUpperCase() + key.slice(1)
} }
async function load() { async function load() {
@ -123,14 +158,34 @@ function buildOption() {
// Keep IDs as strings throughout backend issues Snowflake IDs that lose // Keep IDs as strings throughout backend issues Snowflake IDs that lose
// precision if coerced to Number. // precision if coerced to Number.
const idSet = new Set(nodes.value.map(n => String(n.id))) const idSet = new Set(nodes.value.map(n => String(n.id)))
// Distinct entity types present ECharts categories. The category index
// drives each node's color and powers the legend: clicking a type in the
// legend filters its nodes (and their edges) in or out of the graph.
const types: string[] = []
for (const n of nodes.value) {
const tp = (n.type || 'other').toLowerCase()
if (!types.includes(tp)) types.push(tp)
}
types.sort()
const typeIndex = new Map(types.map((tp, i) => [tp, i]))
const categories = types.map(tp => ({
name: formatType(tp),
itemStyle: { color: typeColor(tp) },
}))
const labelColor = cssVar('--mc-text-secondary', '#665245')
const legendColor = cssVar('--mc-text-tertiary', '#9b7d6c')
const nodeList = nodes.value.map(n => { const nodeList = nodes.value.map(n => {
const size = Math.max(12, Math.min(46, 12 + (n.mentionCount || 0) * 3)) const size = Math.max(12, Math.min(46, 12 + (n.mentionCount || 0) * 3))
return { return {
id: String(n.id), id: String(n.id),
name: n.canonicalName, name: n.canonicalName,
symbolSize: size, symbolSize: size,
itemStyle: { color: typeColor(n.type) }, category: typeIndex.get((n.type || 'other').toLowerCase()) ?? 0,
label: { show: size > 22, position: 'right' as const, fontSize: 10, color: 'var(--mc-text-secondary)', distance: 4 }, // Always show the entity name (not just on hover); larger/more-mentioned
// nodes get a slightly bigger label so hubs stand out.
label: { show: true, position: 'right' as const, fontSize: size > 26 ? 12 : 10, color: labelColor, distance: 5 },
} }
}) })
const edgeList = edges.value const edgeList = edges.value
@ -144,6 +199,17 @@ function buildOption() {
return { return {
backgroundColor: 'transparent', backgroundColor: 'transparent',
legend: [{
top: 8,
left: 'center',
type: 'scroll',
itemWidth: 10,
itemHeight: 10,
itemGap: 12,
icon: 'circle',
textStyle: { fontSize: 11, color: legendColor },
data: categories.map(c => c.name),
}],
tooltip: { tooltip: {
trigger: 'item', trigger: 'item',
formatter: (params: any) => { formatter: (params: any) => {
@ -152,13 +218,14 @@ function buildOption() {
if (!node) return '' if (!node) return ''
const desc = (node.description || '').substring(0, 80) const desc = (node.description || '').substring(0, 80)
return `<div style="max-width:220px;white-space:normal"><strong>${node.canonicalName}</strong>` return `<div style="max-width:220px;white-space:normal"><strong>${node.canonicalName}</strong>`
+ `<small style="color:#999;display:block">${node.type}</small>` + `<small style="color:#999;display:block">${formatType(node.type)}</small>`
+ (desc ? `<span style="font-size:11px">${desc}</span>` : '') + '</div>' + (desc ? `<span style="font-size:11px">${desc}</span>` : '') + '</div>'
}, },
}, },
series: [{ series: [{
type: 'graph', type: 'graph',
layout: 'force', layout: 'force',
categories,
data: nodeList, data: nodeList,
links: edgeList, links: edgeList,
roam: true, roam: true,

View File

@ -87,6 +87,14 @@ const kbId = computed<string | number | null>(() => {
}) })
// Resolve a CSS custom property to its computed value so ECharts (canvas, which
// can't read CSS vars) renders labels in the active light/dark theme color.
function cssVar(name: string, fallback: string): string {
if (typeof document === 'undefined') return fallback
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
return v || fallback
}
// Parse outgoing links JSON string slug[] // Parse outgoing links JSON string slug[]
function parseLinks(outgoingLinks: string | null | undefined): string[] { function parseLinks(outgoingLinks: string | null | undefined): string[] {
if (!outgoingLinks) return [] if (!outgoingLinks) return []
@ -209,6 +217,7 @@ const selectedNodeLinks = computed(() => {
}) })
function buildOption() { function buildOption() {
const labelColor = cssVar('--mc-text-secondary', '#665245')
const nodeSet = new Set(nodes.value.map(p => p.slug)) const nodeSet = new Set(nodes.value.map(p => p.slug))
const nodeList = nodes.value.map(p => { const nodeList = nodes.value.map(p => {
const outDeg = parseLinks(p.outgoingLinks).filter(l => { const outDeg = parseLinks(p.outgoingLinks).filter(l => {
@ -227,7 +236,7 @@ function buildOption() {
show: size > 22, show: size > 22,
position: 'right' as const, position: 'right' as const,
fontSize: 10, fontSize: 10,
color: 'var(--mc-text-secondary)', color: labelColor,
distance: 4, distance: 4,
}, },
// Do NOT embed Vue reactive proxies here ECharts normalizes data and strips them. // Do NOT embed Vue reactive proxies here ECharts normalizes data and strips them.