diff --git a/scripts/build-edges.js b/scripts/build-edges.js index db84608..711535b 100644 --- a/scripts/build-edges.js +++ b/scripts/build-edges.js @@ -1,7 +1,7 @@ /** - * Pre-computes cross-reference edges for each dataset. - * Reads all concept JSON files, extracts structured and inline references, - * and writes edges.json for each dataset. + * Pre-computes cross-reference and domain edges for each dataset. + * Reads all concept JSON files, extracts structured references and + * authoritative sources (domains), and writes edges.json + domain-nodes.json. * * Usage: node scripts/build-edges.js */ @@ -13,12 +13,18 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = process.cwd(); const DATA_DIR = join(ROOT, 'public', 'data'); -function extractEdgesFromConcept(concept, registerId) { +// --- Normalization --- + +function slugify(text) { + return text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/[\s/]+/g, '-'); +} + +// --- Extractors (open/closed: add new extractors to EXTRACTORS array) --- + +function extractReferences(concept, registerId) { const edges = []; const sourceUri = concept['@id']; - - for (const [_lang, lc] of Object.entries(concept['gl:localizedConcept'] || {})) { - // Structured cross-references (gl:references array, pre-computed during data generation) + for (const [lang, lc] of Object.entries(concept['gl:localizedConcept'] || {})) { if (lc['gl:references']) { for (const ref of lc['gl:references']) { if (ref['@id'] && ref['@id'] !== sourceUri) { @@ -28,15 +34,42 @@ function extractEdgesFromConcept(concept, registerId) { type: 'references', label: ref['gl:term'] || undefined, register: registerId, + lang, }); } } } } + return edges; +} +function extractDomains(concept, registerId) { + const edges = []; + const sourceUri = concept['@id']; + for (const [lang, lc] of Object.entries(concept['gl:localizedConcept'] || {})) { + const domain = lc['gl:domain']; + if (domain) { + edges.push({ + source: sourceUri, + target: `https://glossarist.org/${registerId}/domain/${slugify(domain)}`, + type: 'domain', + label: domain, + register: registerId, + lang, + }); + } + } return edges; } +const EXTRACTORS = [extractReferences, extractDomains]; + +function extractAllEdges(concept, registerId) { + return EXTRACTORS.flatMap(fn => fn(concept, registerId)); +} + +// --- Build --- + function buildEdgesForDataset(datasetDir, registerId) { const conceptsDir = join(datasetDir, 'concepts'); if (!existsSync(conceptsDir)) { @@ -48,13 +81,20 @@ function buildEdgesForDataset(datasetDir, registerId) { console.log(` Processing ${files.length} concepts...`); const allEdges = []; + const domainConceptCount = new Map(); let processed = 0; for (const file of files) { try { const data = JSON.parse(readFileSync(join(conceptsDir, file), 'utf-8')); - const edges = extractEdgesFromConcept(data, registerId); + const edges = extractAllEdges(data, registerId); allEdges.push(...edges); + + for (const edge of edges) { + if (edge.type === 'domain') { + domainConceptCount.set(edge.target, (domainConceptCount.get(edge.target) || 0) + 1); + } + } } catch (e) { console.error(` Error processing ${file}: ${e.message}`); } @@ -64,11 +104,11 @@ function buildEdgesForDataset(datasetDir, registerId) { } } - // Deduplicate edges by source+target pair + // Deduplicate edges by source+target+type+lang const seen = new Set(); const deduped = []; for (const edge of allEdges) { - const key = `${edge.source}→${edge.target}`; + const key = `${edge.source}→${edge.target}→${edge.type}→${edge.lang || ''}`; if (!seen.has(key)) { seen.add(key); deduped.push(edge); @@ -84,6 +124,31 @@ function buildEdgesForDataset(datasetDir, registerId) { const outputPath = join(datasetDir, 'edges.json'); writeFileSync(outputPath, JSON.stringify(output, null, 2)); console.log(` Written ${deduped.length} edges to edges.json (${(JSON.stringify(output).length / 1024).toFixed(1)} KB)`); + + // Build domain-nodes.json + const domainEdgeMap = new Map(); + for (const edge of deduped) { + if (edge.type === 'domain') { + const existing = domainEdgeMap.get(edge.target); + if (existing) { + existing.labels.add(edge.label); + } else { + domainEdgeMap.set(edge.target, { uri: edge.target, labels: new Set([edge.label]), registerId }); + } + } + } + + const domainNodes = [...domainEdgeMap.values()].map(d => ({ + uri: d.uri, + label: [...d.labels][0], + registerId: d.registerId, + conceptCount: domainConceptCount.get(d.uri) || 0, + })).sort((a, b) => b.conceptCount - a.conceptCount); + + const domainOutput = { registerId, domainNodes }; + const domainPath = join(datasetDir, 'domain-nodes.json'); + writeFileSync(domainPath, JSON.stringify(domainOutput, null, 2)); + console.log(` Written ${domainNodes.length} domain nodes to domain-nodes.json`); } // Main diff --git a/scripts/generate-data.mjs b/scripts/generate-data.mjs index 06fc22e..cbf5fe9 100644 --- a/scripts/generate-data.mjs +++ b/scripts/generate-data.mjs @@ -49,17 +49,46 @@ function writeJson(filePath, data) { } function termToDesignation(term) { + const typeMap = { + expression: 'gl:Expression', + abbreviation: 'gl:Abbreviation', + symbol: 'gl:Symbol', + letter_symbol: 'gl:LetterSymbol', + 'graphical symbol': 'gl:GraphicalSymbol', + }; const doc = { - '@type': term.type === 'expression' ? 'gl:Expression' - : term.type === 'symbol' ? 'gl:Symbol' - : term.type === 'abbreviation' ? 'gl:Abbreviation' - : 'gl:Designation', + '@type': typeMap[term.type] || 'gl:Designation', 'gl:normativeStatus': term.normative_status || 'preferred', 'gl:term': term.designation, }; - if (term.gender) doc['gl:gender'] = term.gender; - if (term.plurality) doc['gl:plurality'] = term.plurality; + + if (term.grammar_info && term.grammar_info.length > 0) { + doc['gl:grammarInfo'] = term.grammar_info.map(gi => { + const g = {}; + if (gi.gender) g['gl:gender'] = gi.gender; + if (gi.number) g['gl:number'] = gi.number; + for (const pos of ['noun', 'verb', 'adj', 'adverb', 'preposition', 'participle']) { + if (gi[pos]) g[`gl:${pos}`] = gi[pos]; + } + return g; + }); + } + if (term.international !== undefined) doc['gl:international'] = term.international; + if (term.absent !== undefined) doc['gl:absent'] = term.absent; + if (term.geographical_area) doc['gl:geographicalArea'] = term.geographical_area; + if (term.term_type) doc['gl:termType'] = term.term_type; + if (term.prefix) doc['gl:prefix'] = term.prefix; + if (term.usage_info) doc['gl:usageInfo'] = term.usage_info; + if (term.field_of_application) doc['gl:fieldOfApplication'] = term.field_of_application; + + if (term.acronym !== undefined) doc['gl:acronym'] = term.acronym; + if (term.initialism !== undefined) doc['gl:initialism'] = term.initialism; + if (term.truncation !== undefined) doc['gl:truncation'] = term.truncation; + + if (term.text) doc['gl:text'] = term.text; + if (term.image) doc['gl:image'] = term.image; + return doc; } @@ -210,6 +239,10 @@ function yamlToJsonLd(conceptYaml, register, refMaps) { }; if (lc.entry_status) lDoc['gl:entryStatus'] = lc.entry_status; + if (lc.classification) lDoc['gl:classification'] = lc.classification; + if (lc.review_type) lDoc['gl:reviewType'] = lc.review_type; + if (lc.script) lDoc['gl:script'] = lc.script; + if (lc.system) lDoc['gl:system'] = lc.system; if (lc.terms && lc.terms.length > 0) lDoc['gl:designation'] = lc.terms.map(termToDesignation); if (lc.definition) lDoc['gl:definition'] = defsToJsonLd(lc.definition); if (lc.notes && lc.notes.length > 0) lDoc['gl:notes'] = defsToJsonLd(lc.notes); @@ -223,6 +256,7 @@ function yamlToJsonLd(conceptYaml, register, refMaps) { if (lc.review_status) lDoc['gl:reviewStatus'] = lc.review_status; if (lc.review_decision) lDoc['gl:reviewDecision'] = lc.review_decision; if (lc.review_decision_notes) lDoc['gl:reviewDecisionNotes'] = lc.review_decision_notes; + if (lc.domain) lDoc['gl:domain'] = lc.domain; if (lc.dates && lc.dates.length > 0) { lDoc['gl:dates'] = lc.dates.map(d => ({ 'gl:dateType': d.type, @@ -378,15 +412,22 @@ function conceptJsonToTbx(concept) { const status = d['gl:normativeStatus'] || ''; const type = d['@type'] || ''; let gramGrp = ''; - if (d['gl:gender']) gramGrp = `\n ${escapeXml(d['gl:gender'])}`; - let partOfSpeech = ''; - if (type.includes('Abbreviation')) partOfSpeech = '\n abbreviation'; - if (type.includes('Symbol')) partOfSpeech = '\n symbol'; + if (d['gl:grammarInfo'] && d['gl:grammarInfo'].length > 0) { + const gi = d['gl:grammarInfo'][0]; + if (gi['gl:gender']) gramGrp = `\n ${escapeXml(gi['gl:gender'])}`; + if (gi['gl:number']) gramGrp += `\n ${escapeXml(gi['gl:number'])}`; + for (const pos of ['noun', 'verb', 'adj', 'adverb', 'preposition', 'participle']) { + if (gi[`gl:${pos}`]) gramGrp += `\n ${pos}`; + } + } + let posBlock = ''; + if (type.includes('Abbreviation')) posBlock = '\n abbreviation'; + if (type.includes('Symbol')) posBlock = '\n symbol'; termEntries.push(` - ${escapeXml(term)}${gramGrp}${partOfSpeech} + ${escapeXml(term)}${gramGrp}${posBlock} `); diff --git a/src/__tests__/concept-timeline.test.ts b/src/__tests__/concept-timeline.test.ts index 00de9ae..1456f1b 100644 --- a/src/__tests__/concept-timeline.test.ts +++ b/src/__tests__/concept-timeline.test.ts @@ -120,7 +120,7 @@ describe('ConceptTimeline', () => { 'gl:reviewStatus': 'final', 'gl:reviewDecision': 'accepted', 'gl:entryStatus': 'valid', - 'gl:release': 3, + 'gl:release': '3', }); const wrapper = mountTimeline({ eng: lc }); expect(wrapper.text()).toContain('Review Details'); diff --git a/src/__tests__/dataset-adapter.test.ts b/src/__tests__/dataset-adapter.test.ts index a0b8323..3e7e8c7 100644 --- a/src/__tests__/dataset-adapter.test.ts +++ b/src/__tests__/dataset-adapter.test.ts @@ -218,6 +218,24 @@ describe('DatasetAdapter', () => { expect(edges[0].label).toBe('functional'); }); + it('tags reference edges with language', () => { + const concept = { + '@id': 'https://glossarist.org/test/concept/1', + 'gl:localizedConcept': { + eng: { 'gl:references': [ + { '@id': 'https://glossarist.org/test/concept/2', 'gl:term': 'other' }, + ]}, + fra: { 'gl:references': [ + { '@id': 'https://glossarist.org/test/concept/3', 'gl:term': 'autre' }, + ]}, + }, + }; + const edges = adapter.extractEdges(concept as any); + expect(edges.length).toBe(2); + expect(edges.find(e => e.lang === 'eng')?.target).toContain('/concept/2'); + expect(edges.find(e => e.lang === 'fra')?.target).toContain('/concept/3'); + }); + it('skips self-references', () => { const concept = { '@id': 'https://glossarist.org/test/concept/102-01-01', @@ -318,6 +336,81 @@ describe('DatasetAdapter', () => { }); }); + describe('extractDomainEdges', () => { + it('extracts domain edges from gl:domain field per language', () => { + const concept = { + '@id': 'https://glossarist.org/test/concept/3', + 'gl:localizedConcept': { + eng: { 'gl:domain': 'geometry' }, + fra: { 'gl:domain': 'géométrie' }, + }, + }; + const edges = adapter.extractDomainEdges(concept as any); + expect(edges.length).toBe(2); + expect(edges.every(e => e.type === 'domain')).toBe(true); + expect(edges.find(e => e.lang === 'eng')?.target).toContain('/domain/geometry'); + expect(edges.find(e => e.lang === 'fra')?.target).toContain('/domain/gomtrie'); + expect(edges.find(e => e.lang === 'eng')?.label).toBe('geometry'); + expect(edges.find(e => e.lang === 'fra')?.label).toBe('géométrie'); + }); + + it('handles same domain across languages', () => { + const concept = { + '@id': 'https://glossarist.org/test/concept/1', + 'gl:localizedConcept': { + eng: { 'gl:domain': 'metadata' }, + fra: { 'gl:domain': 'metadata' }, + }, + }; + const edges = adapter.extractDomainEdges(concept as any); + expect(edges.length).toBe(2); + expect(edges[0].target).toBe(edges[1].target); + expect(edges[0].target).toContain('/domain/metadata'); + }); + + it('skips concepts without gl:domain', () => { + const concept = { + '@id': 'https://glossarist.org/test/concept/1', + 'gl:localizedConcept': { eng: {} }, + }; + const edges = adapter.extractDomainEdges(concept as any); + expect(edges.length).toBe(0); + }); + + it('handles empty localizedConcept', () => { + const concept = { + '@id': 'https://glossarist.org/test/concept/1', + 'gl:localizedConcept': {}, + }; + const edges = adapter.extractDomainEdges(concept as any); + expect(edges.length).toBe(0); + }); + }); + + describe('loadDomainNodes', () => { + it('loads domain nodes from domain-nodes.json', async () => { + mockFetch.mockReturnValue(mockJsonResponse({ + registerId: 'test', + domainNodes: [ + { uri: 'https://glossarist.org/test/domain/iso-19107', label: 'ISO 19107', registerId: 'test', conceptCount: 147 }, + ], + })); + const nodes = await adapter.loadDomainNodes(); + expect(nodes.length).toBe(1); + expect(nodes[0].nodeType).toBe('domain'); + expect(nodes[0].status).toBe('domain'); + expect(nodes[0].loaded).toBe(true); + expect(nodes[0].designations.eng).toBe('ISO 19107'); + expect(mockFetch).toHaveBeenCalledWith('/data/test/domain-nodes.json'); + }); + + it('returns empty array on fetch failure', async () => { + mockFetch.mockReturnValue(Promise.resolve({ ok: false, status: 404 } as Response)); + const nodes = await adapter.loadDomainNodes(); + expect(nodes).toEqual([]); + }); + }); + describe('getLanguages', () => { it('returns languages from manifest', async () => { const manifest = { diff --git a/src/__tests__/graph.test.ts b/src/__tests__/graph.test.ts index fd9f832..59bb19f 100644 --- a/src/__tests__/graph.test.ts +++ b/src/__tests__/graph.test.ts @@ -91,6 +91,37 @@ describe('GraphEngine', () => { expect(g.edgeCount).toBe(1); }); + it('keeps separate edges for different languages', () => { + const g = new GraphEngine(); + g.addEdge({ source: 'uri:a', target: 'uri:b', type: 'references', register: 'test', lang: 'eng' }); + g.addEdge({ source: 'uri:a', target: 'uri:b', type: 'references', register: 'test', lang: 'fra' }); + expect(g.edgeCount).toBe(2); + }); + + it('deduplicates edges with same source+target+type+lang', () => { + const g = new GraphEngine(); + g.addEdge({ source: 'uri:a', target: 'uri:b', type: 'references', register: 'test', lang: 'eng' }); + g.addEdge({ source: 'uri:a', target: 'uri:b', type: 'references', register: 'test', lang: 'eng' }); + expect(g.edgeCount).toBe(1); + }); + + it('creates domain stub with correct fields', () => { + const g = new GraphEngine(); + g.addEdge({ + source: 'https://glossarist.org/isotc211/concept/3', + target: 'https://glossarist.org/isotc211/domain/iso-19105', + type: 'domain', + label: 'ISO 19105', + register: 'isotc211', + lang: 'eng', + }); + const domainNode = g.getNode('https://glossarist.org/isotc211/domain/iso-19105'); + expect(domainNode?.register).toBe('isotc211'); + expect(domainNode?.nodeType).toBe('domain'); + expect(domainNode?.status).toBe('domain'); + expect(domainNode?.loaded).toBe(false); + }); + it('extracts register from URI for stub nodes', () => { const g = new GraphEngine(); g.addEdge({ @@ -172,6 +203,37 @@ describe('GraphEngine', () => { const sub = g.getSubgraph('uri:a', 5); expect(sub.nodes.length).toBe(2); }); + + it('does not traverse past domain nodes in getSubgraph', () => { + const g = new GraphEngine(); + g.addNode(makeNode('https://glossarist.org/test/concept/a', 'a')); + g.addNode(makeNode('https://glossarist.org/test/concept/b', 'b')); + g.addNode(makeNode('https://glossarist.org/test/concept/c', 'c')); + g.addNode(makeNode('https://glossarist.org/test/concept/d', 'd')); + + g.addEdge({ + source: 'https://glossarist.org/test/concept/a', + target: 'https://glossarist.org/test/domain/iso-12345', + type: 'domain', register: 'test', label: 'ISO 12345', lang: 'eng', + }); + g.addEdge({ + source: 'https://glossarist.org/test/concept/b', + target: 'https://glossarist.org/test/domain/iso-12345', + type: 'domain', register: 'test', label: 'ISO 12345', lang: 'eng', + }); + g.addEdge({ + source: 'https://glossarist.org/test/concept/c', + target: 'https://glossarist.org/test/domain/iso-12345', + type: 'domain', register: 'test', label: 'ISO 12345', lang: 'eng', + }); + + const sub = g.getSubgraph('https://glossarist.org/test/concept/a', 3); + const nodeUris = sub.nodes.map(n => n.uri); + expect(nodeUris).toContain('https://glossarist.org/test/concept/a'); + expect(nodeUris).toContain('https://glossarist.org/test/domain/iso-12345'); + expect(nodeUris).not.toContain('https://glossarist.org/test/concept/b'); + expect(nodeUris).not.toContain('https://glossarist.org/test/concept/c'); + }); }); describe('getAllNodes', () => { diff --git a/src/__tests__/language-detail.test.ts b/src/__tests__/language-detail.test.ts index 2b628b7..5777fb3 100644 --- a/src/__tests__/language-detail.test.ts +++ b/src/__tests__/language-detail.test.ts @@ -133,10 +133,10 @@ describe('LanguageDetail', () => { expect(wrapper.text()).toContain('Abbreviation'); }); - it('shows gender and plurality when present', () => { + it('shows grammar info when present', () => { const eng = makeLocalizedConcept({ 'gl:designation': [ - { '@type': 'gl:Expression', 'gl:term': 'route', 'gl:normativeStatus': 'preferred', 'gl:gender': 'f', 'gl:plurality': 'singular' }, + { '@type': 'gl:Expression', 'gl:term': 'route', 'gl:normativeStatus': 'preferred', 'gl:grammarInfo': [{ 'gl:gender': 'f', 'gl:number': 'singular' }] }, ], }); const wrapper = mountDetail({ eng }); diff --git a/src/__tests__/test-helpers.ts b/src/__tests__/test-helpers.ts index 3b5341e..1553f9f 100644 --- a/src/__tests__/test-helpers.ts +++ b/src/__tests__/test-helpers.ts @@ -43,6 +43,7 @@ export interface AdapterStubOptions { ensureChunksForRange?: () => Promise; ensureAllChunksLoaded?: () => Promise; extractEdges?: () => any[]; + extractDomainEdges?: () => any[]; getIndexEntry?: () => any; } @@ -61,6 +62,7 @@ export function makeAdapterStub(options: AdapterStubOptions = {}): any { ensureChunksForRange: options.ensureChunksForRange ?? (() => Promise.resolve()), ensureAllChunksLoaded: options.ensureAllChunksLoaded ?? (() => Promise.resolve()), extractEdges: options.extractEdges ?? (() => []), + extractDomainEdges: options.extractDomainEdges ?? (() => []), getIndexEntry: options.getIndexEntry ?? (() => null), }; } diff --git a/src/adapters/DatasetAdapter.ts b/src/adapters/DatasetAdapter.ts index 352b2fd..d4eedd1 100644 --- a/src/adapters/DatasetAdapter.ts +++ b/src/adapters/DatasetAdapter.ts @@ -6,9 +6,14 @@ import type { ConceptDocument, SearchHit, GraphEdge, + GraphNode, } from './types'; import { UriRouter } from './UriRouter'; +function slugify(text: string): string { + return text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/[\s/]+/g, '-'); +} + export class DatasetAdapter { private positionIndex = new Map(); readonly registerId: string; @@ -241,7 +246,7 @@ export class DatasetAdapter { const edges: GraphEdge[] = []; const sourceUri = concept['@id']; - for (const [_lang, lc] of Object.entries(concept['gl:localizedConcept'] || {})) { + for (const [lang, lc] of Object.entries(concept['gl:localizedConcept'] || {})) { if (lc['gl:references']) { for (const ref of lc['gl:references']) { if (ref['@id'] && ref['@id'] !== sourceUri) { @@ -252,6 +257,7 @@ export class DatasetAdapter { type: 'references', label: ref['gl:term'], register: parsed?.registerId ?? this.registerId, + lang, }); } } @@ -261,6 +267,40 @@ export class DatasetAdapter { return edges; } + extractDomainEdges(concept: ConceptDocument): GraphEdge[] { + const edges: GraphEdge[] = []; + const sourceUri = concept['@id']; + for (const [lang, lc] of Object.entries(concept['gl:localizedConcept'] || {})) { + const domain = lc['gl:domain']; + if (domain) { + edges.push({ + source: sourceUri, + target: `https://glossarist.org/${this.registerId}/domain/${slugify(domain)}`, + type: 'domain', + label: domain, + register: this.registerId, + lang, + }); + } + } + return edges; + } + + async loadDomainNodes(): Promise { + const resp = await fetch(`${this.baseUrl}/domain-nodes.json`); + if (!resp.ok) return []; + const data = await resp.json(); + return (data.domainNodes || []).map((dn: any) => ({ + uri: dn.uri, + register: dn.registerId, + conceptId: dn.uri.split('/domain/')[1] || '', + designations: { eng: dn.label }, + status: 'domain', + loaded: true, + nodeType: 'domain' as const, + })); + } + async loadEdgeIndex(): Promise { const resp = await fetch(`${this.baseUrl}/edges.json`); if (!resp.ok) return []; diff --git a/src/adapters/types.ts b/src/adapters/types.ts index 53bcafa..89f05fb 100644 --- a/src/adapters/types.ts +++ b/src/adapters/types.ts @@ -63,12 +63,17 @@ export interface LocalizedConcept { '@type': string; 'gl:languageCode': string; 'gl:entryStatus'?: string; + 'gl:classification'?: string; + 'gl:reviewType'?: string; + 'gl:script'?: string; + 'gl:system'?: string; 'gl:designation'?: Designation[]; 'gl:definition'?: DetailedDefinition[]; 'gl:notes'?: DetailedDefinition[]; 'gl:examples'?: DetailedDefinition[]; 'gl:source'?: ConceptSource[]; - 'gl:release'?: number; + 'gl:release'?: string; + 'gl:lineageSourceSimilarity'?: number; 'gl:reviewDate'?: string; 'gl:reviewDecisionDate'?: string; 'gl:reviewDecisionEvent'?: string; @@ -77,15 +82,37 @@ export interface LocalizedConcept { 'gl:reviewDecisionNotes'?: string; 'gl:dates'?: ConceptDate[]; 'gl:references'?: CrossReference[]; + 'gl:domain'?: string; +} + +export interface GrammarInfo { + 'gl:gender'?: string; + 'gl:number'?: string; + 'gl:noun'?: boolean; + 'gl:verb'?: boolean; + 'gl:adj'?: boolean; + 'gl:adverb'?: boolean; + 'gl:preposition'?: boolean; + 'gl:participle'?: boolean; } export interface Designation { '@type': string; 'gl:normativeStatus': string; 'gl:term': string; - 'gl:gender'?: string; - 'gl:plurality'?: string; + 'gl:grammarInfo'?: GrammarInfo[]; 'gl:international'?: boolean; + 'gl:termType'?: string; + 'gl:absent'?: boolean; + 'gl:geographicalArea'?: string; + 'gl:prefix'?: string; + 'gl:usageInfo'?: string; + 'gl:fieldOfApplication'?: string; + 'gl:acronym'?: boolean; + 'gl:initialism'?: boolean; + 'gl:truncation'?: boolean; + 'gl:text'?: string; + 'gl:image'?: string; } export interface DetailedDefinition { @@ -121,12 +148,18 @@ export interface DatasetRegistry { manifestUrl: string; } +export const EDGE_TYPE = { + REFERENCES: 'references', + DOMAIN: 'domain', +} as const; + export interface GraphEdge { - source: string; // concept URI - target: string; // concept URI + source: string; + target: string; type: string; label?: string; register: string; + lang?: string; } export interface GraphNode { @@ -136,6 +169,7 @@ export interface GraphNode { designations: Record; status: string; loaded: boolean; + nodeType?: 'concept' | 'domain'; } export interface SearchHit { diff --git a/src/components/ConceptDetail.vue b/src/components/ConceptDetail.vue index c9bd242..20bac54 100644 --- a/src/components/ConceptDetail.vue +++ b/src/components/ConceptDetail.vue @@ -277,6 +277,27 @@ function plainTruncate(html: string, max: number = 120): string { const text = cleanContent(html).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); return text.length <= max ? text : text.slice(0, max).trimEnd() + '\u2026'; } + +function slugify(text: string): string { + return text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/[\s/]+/g, '-'); +} + +const conceptDomains = computed(() => { + const domainMap = new Map(); + for (const [lang, lc] of Object.entries(props.concept['gl:localizedConcept'] || {})) { + const domain = lc['gl:domain']; + if (domain) { + const slug = slugify(domain); + const existing = domainMap.get(slug); + if (existing) { + if (!existing.langs.includes(lang)) existing.langs.push(lang); + } else { + domainMap.set(slug, { slug, label: domain, langs: [lang] }); + } + } + } + return [...domainMap.values()].sort((a, b) => b.langs.length - a.langs.length); +});