mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
31c98e923d
commit
4804954ad2
@ -31,9 +31,11 @@ import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Extracts a named-entity knowledge graph from source chunks: it pulls
|
||||
@ -90,7 +92,15 @@ public class WikiEntityExtractionService {
|
||||
* chunks already processed
|
||||
*/
|
||||
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) {
|
||||
@ -119,7 +129,8 @@ public class WikiEntityExtractionService {
|
||||
if (chunk.getContent() == null || chunk.getContent().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
if (!force && hasMentions(chunk.getId())) {
|
||||
boolean alreadyProcessed = hasMentions(chunk.getId());
|
||||
if (alreadyProcessed && !force) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@ -127,6 +138,12 @@ public class WikiEntityExtractionService {
|
||||
if (result == null) {
|
||||
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);
|
||||
} catch (Exception e) {
|
||||
log.warn("[WikiEntity] Extraction failed for chunkId={} kbId={}: {}",
|
||||
@ -335,6 +352,71 @@ public class WikiEntityExtractionService {
|
||||
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) {
|
||||
List<Long> pages = citationMapper.listPageIdsByChunkId(chunkId);
|
||||
return (pages == null || pages.isEmpty()) ? null : pages.get(0);
|
||||
|
||||
@ -148,6 +148,30 @@ class WikiEntityExtractionServiceTest {
|
||||
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
|
||||
@DisplayName("extractForRaw: skips chunks that already have mentions")
|
||||
void extractForRaw_skipsProcessedChunks() {
|
||||
|
||||
@ -9,27 +9,30 @@ import { useWikiStore } from '@/stores/useWikiStore'
|
||||
* 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> = {
|
||||
concept: '#D96E46',
|
||||
person: '#5B8DEF',
|
||||
place: '#4CAF82',
|
||||
event: '#F59E0B',
|
||||
technology: '#8B5CF6',
|
||||
organization: '#EC4899',
|
||||
product: '#14B8A6',
|
||||
term: '#6B7280',
|
||||
process: '#F97316',
|
||||
other: '#9CA3AF',
|
||||
concept: '#6B9A55', // sage
|
||||
person: '#D97757', // terracotta (theme primary)
|
||||
place: '#E0A030', // goldenrod
|
||||
event: '#9B5E8E', // plum
|
||||
technology: '#4FA39B', // aqua
|
||||
organization: '#2F8F83', // teal (theme accent)
|
||||
product: '#5B7DB1', // denim blue
|
||||
term: '#A07B5C', // taupe
|
||||
process: '#C08A4E', // ochre
|
||||
other: '#9C8576', // warm grey
|
||||
}
|
||||
|
||||
// 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
|
||||
// and across sessions, without needing a colour field on the profile schema.
|
||||
// Same earthy family so generated types stay on-theme.
|
||||
const HASH_PALETTE = [
|
||||
'#0EA5E9', '#A855F7', '#22C55E', '#EAB308', '#EF4444',
|
||||
'#06B6D4', '#D946EF', '#84CC16', '#F43F5E', '#3B82F6',
|
||||
'#10B981', '#FB923C',
|
||||
'#C0533F', '#B06E7C', '#8B934A', '#A07B5C', '#5B7DB1', '#9B5E8E',
|
||||
'#4FA39B', '#C08A4E', '#6B9A55', '#2F8F83', '#D97757', '#E0A030',
|
||||
]
|
||||
|
||||
function hashIndex(s: string, mod: number): number {
|
||||
|
||||
@ -2497,6 +2497,9 @@ export default {
|
||||
entityExtractionEnable: 'Enable entity extraction',
|
||||
entityExtractionRun: 'Extract now',
|
||||
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',
|
||||
globalDefault: 'Global default',
|
||||
selectModel: 'Select a model…',
|
||||
@ -2559,6 +2562,18 @@ export default {
|
||||
synthesis: 'Synthesis',
|
||||
other: 'Other',
|
||||
},
|
||||
entityTypes: {
|
||||
person: 'Person',
|
||||
organization: 'Organization',
|
||||
location: 'Location',
|
||||
place: 'Place',
|
||||
event: 'Event',
|
||||
product: 'Product',
|
||||
concept: 'Concept',
|
||||
technology: 'Technology',
|
||||
term: 'Term',
|
||||
other: 'Other',
|
||||
},
|
||||
graph: {
|
||||
tab: 'Knowledge Graph',
|
||||
nodes: 'nodes',
|
||||
|
||||
@ -2509,6 +2509,9 @@ export default {
|
||||
entityExtractionEnable: '开启实体抽取',
|
||||
entityExtractionRun: '立即抽取',
|
||||
entityExtractionRunning: '抽取中…',
|
||||
entityTypesLabel: '抽取的实体类型',
|
||||
entityTypesPlaceholder: '选择或输入实体类型,回车添加',
|
||||
entityTypesHint: '限定要抽取的实体类型;留空则使用内置默认类型(人物、组织、地点、事件、产品、概念)。',
|
||||
modelStrategy: '模型策略',
|
||||
globalDefault: '跟随全局默认',
|
||||
selectModel: '选择可用模型…',
|
||||
@ -2571,6 +2574,18 @@ export default {
|
||||
synthesis: '综合',
|
||||
other: '其他',
|
||||
},
|
||||
entityTypes: {
|
||||
person: '人物',
|
||||
organization: '组织',
|
||||
location: '地点',
|
||||
place: '地点',
|
||||
event: '事件',
|
||||
product: '产品',
|
||||
concept: '概念',
|
||||
technology: '技术',
|
||||
term: '术语',
|
||||
other: '其他',
|
||||
},
|
||||
graph: {
|
||||
tab: '知识图谱',
|
||||
nodes: '节点',
|
||||
|
||||
@ -71,6 +71,25 @@
|
||||
{{ extracting ? t('wiki.configPanel.entityExtractionRunning') : t('wiki.configPanel.entityExtractionRun') }}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<!-- ② Model strategy -->
|
||||
@ -186,9 +205,22 @@ import WikiModelPicker, { type ModelOption } from './WikiModelPicker.vue'
|
||||
import WikiConfigRules from './WikiConfigRules.vue'
|
||||
import WikiConfigModels from './WikiConfigModels.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t, te } = useI18n()
|
||||
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 ──
|
||||
const configContent = ref('')
|
||||
const rulesOpen = ref(false)
|
||||
@ -261,6 +293,7 @@ async function saveIngestMode() {
|
||||
// Opt-in named-entity knowledge graph extraction. Off by default because it
|
||||
// adds an LLM call per chunk on top of the page pipeline.
|
||||
const entityExtractionEnabled = ref(false)
|
||||
const entityTypes = ref<string[]>([])
|
||||
const savingEntityExtraction = ref(false)
|
||||
const extracting = ref(false)
|
||||
|
||||
@ -273,6 +306,12 @@ async function saveEntityExtraction() {
|
||||
if (store.currentKB.configContent) existingConfig = JSON.parse(store.currentKB.configContent)
|
||||
} catch { /* config may be plain text rules */ }
|
||||
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))
|
||||
} catch (e) {
|
||||
console.error('[WikiConfig] Failed to save entity extraction toggle', e)
|
||||
@ -285,7 +324,9 @@ async function runExtraction() {
|
||||
if (!store.currentKB) return
|
||||
extracting.value = true
|
||||
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) {
|
||||
console.error('[WikiConfig] Failed to start entity extraction', e)
|
||||
} finally {
|
||||
@ -310,6 +351,7 @@ function loadStepModels() {
|
||||
wikiGlobalModelId.value = ''
|
||||
ingestMode.value = 'eager'
|
||||
entityExtractionEnabled.value = false
|
||||
entityTypes.value = []
|
||||
if (!store.currentKB) return
|
||||
try {
|
||||
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?.ingestMode === 'lazy') ingestMode.value = 'lazy'
|
||||
if (cfg?.entityExtractionEnabled) entityExtractionEnabled.value = true
|
||||
if (Array.isArray(cfg?.entityTypes)) entityTypes.value = cfg.entityTypes.map(String)
|
||||
} 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__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-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.ingest-mode-option {
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
<button class="entity-panel__close" @click="selected = null">×</button>
|
||||
<h3 class="entity-panel__title">{{ selected.canonicalName }}</h3>
|
||||
<span class="entity-panel__type" :style="{ background: typeColor(selected.type) }">
|
||||
{{ selected.type }}
|
||||
{{ formatType(selected.type) }}
|
||||
</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])
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t, te } = useI18n()
|
||||
|
||||
interface EntityNode {
|
||||
id: number | string
|
||||
@ -95,12 +95,47 @@ const selected = ref<EntityNode | null>(null)
|
||||
const egoEdges = ref<{ predicate: string; label: string }[]>([])
|
||||
const egoPages = ref<{ pageId: number | string; slug: string; title: string }[]>([])
|
||||
|
||||
// Stable color per entity type via a small hash → palette.
|
||||
const PALETTE = ['#5b8ff9', '#5ad8a6', '#f6bd16', '#e8684a', '#6dc8ec', '#9270ca', '#ff9d4d', '#269a99']
|
||||
// Earthy categorical palette tuned to the warm terracotta + teal app theme
|
||||
// (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 {
|
||||
const key = (type || 'other').toLowerCase()
|
||||
const fixed = ENTITY_TYPE_COLORS[key]
|
||||
if (fixed) return fixed
|
||||
let h = 0
|
||||
for (let i = 0; i < (type || '').length; i++) h = (h * 31 + type.charCodeAt(i)) >>> 0
|
||||
return PALETTE[h % PALETTE.length]
|
||||
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0
|
||||
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() {
|
||||
@ -123,14 +158,34 @@ function buildOption() {
|
||||
// Keep IDs as strings throughout — backend issues Snowflake IDs that lose
|
||||
// precision if coerced to Number.
|
||||
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 size = Math.max(12, Math.min(46, 12 + (n.mentionCount || 0) * 3))
|
||||
return {
|
||||
id: String(n.id),
|
||||
name: n.canonicalName,
|
||||
symbolSize: size,
|
||||
itemStyle: { color: typeColor(n.type) },
|
||||
label: { show: size > 22, position: 'right' as const, fontSize: 10, color: 'var(--mc-text-secondary)', distance: 4 },
|
||||
category: typeIndex.get((n.type || 'other').toLowerCase()) ?? 0,
|
||||
// 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
|
||||
@ -144,6 +199,17 @@ function buildOption() {
|
||||
|
||||
return {
|
||||
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: {
|
||||
trigger: 'item',
|
||||
formatter: (params: any) => {
|
||||
@ -152,13 +218,14 @@ function buildOption() {
|
||||
if (!node) return ''
|
||||
const desc = (node.description || '').substring(0, 80)
|
||||
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>'
|
||||
},
|
||||
},
|
||||
series: [{
|
||||
type: 'graph',
|
||||
layout: 'force',
|
||||
categories,
|
||||
data: nodeList,
|
||||
links: edgeList,
|
||||
roam: true,
|
||||
|
||||
@ -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[]
|
||||
function parseLinks(outgoingLinks: string | null | undefined): string[] {
|
||||
if (!outgoingLinks) return []
|
||||
@ -209,6 +217,7 @@ const selectedNodeLinks = computed(() => {
|
||||
})
|
||||
|
||||
function buildOption() {
|
||||
const labelColor = cssVar('--mc-text-secondary', '#665245')
|
||||
const nodeSet = new Set(nodes.value.map(p => p.slug))
|
||||
const nodeList = nodes.value.map(p => {
|
||||
const outDeg = parseLinks(p.outgoingLinks).filter(l => {
|
||||
@ -227,7 +236,7 @@ function buildOption() {
|
||||
show: size > 22,
|
||||
position: 'right' as const,
|
||||
fontSize: 10,
|
||||
color: 'var(--mc-text-secondary)',
|
||||
color: labelColor,
|
||||
distance: 4,
|
||||
},
|
||||
// Do NOT embed Vue reactive proxies here — ECharts normalizes data and strips them.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user