@glossarist/concept-browser 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +319 -0
  2. package/cli/index.mjs +119 -0
  3. package/env.d.ts +7 -0
  4. package/index.html +16 -0
  5. package/package.json +78 -0
  6. package/postcss.config.js +6 -0
  7. package/scripts/build-edges.js +112 -0
  8. package/scripts/fetch-datasets.mjs +195 -0
  9. package/scripts/generate-404.js +15 -0
  10. package/scripts/generate-data.mjs +606 -0
  11. package/scripts/load-site-config.mjs +56 -0
  12. package/src/App.vue +98 -0
  13. package/src/__tests__/data-integration.test.ts +135 -0
  14. package/src/__tests__/data-integrity.test.ts +101 -0
  15. package/src/__tests__/dataset-adapter.test.ts +336 -0
  16. package/src/__tests__/dataset-style.test.ts +37 -0
  17. package/src/__tests__/graph.test.ts +187 -0
  18. package/src/__tests__/lang.test.ts +29 -0
  19. package/src/__tests__/math.test.ts +113 -0
  20. package/src/__tests__/reference-resolver.test.ts +122 -0
  21. package/src/__tests__/site-config.test.ts +52 -0
  22. package/src/__tests__/uri-router.test.ts +76 -0
  23. package/src/adapters/DatasetAdapter.ts +270 -0
  24. package/src/adapters/ReferenceResolver.ts +95 -0
  25. package/src/adapters/UriRouter.ts +41 -0
  26. package/src/adapters/factory.ts +78 -0
  27. package/src/adapters/types.ts +162 -0
  28. package/src/components/AppHeader.vue +99 -0
  29. package/src/components/AppSidebar.vue +133 -0
  30. package/src/components/ConceptCard.vue +65 -0
  31. package/src/components/ConceptDetail.vue +540 -0
  32. package/src/components/ConceptTimeline.vue +410 -0
  33. package/src/components/FormatDownloads.vue +46 -0
  34. package/src/components/GraphPanel.vue +499 -0
  35. package/src/components/LanguageDetail.vue +211 -0
  36. package/src/components/NavIcon.vue +20 -0
  37. package/src/components/SearchBar.vue +241 -0
  38. package/src/composables/use-dataset-loader.ts +27 -0
  39. package/src/config/types.ts +130 -0
  40. package/src/config/use-site-config.ts +144 -0
  41. package/src/graph/GraphEngine.ts +137 -0
  42. package/src/graph/index.ts +1 -0
  43. package/src/main.ts +11 -0
  44. package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  45. package/src/router/index.ts +43 -0
  46. package/src/router/page-routes.ts +35 -0
  47. package/src/stores/ui.ts +59 -0
  48. package/src/stores/vocabulary.ts +309 -0
  49. package/src/style.css +314 -0
  50. package/src/utils/asciidoc-lite.ts +123 -0
  51. package/src/utils/concept-formats.ts +157 -0
  52. package/src/utils/dataset-style.ts +54 -0
  53. package/src/utils/index.ts +1 -0
  54. package/src/utils/lang.ts +32 -0
  55. package/src/utils/math.ts +100 -0
  56. package/src/views/AboutView.vue +122 -0
  57. package/src/views/ConceptView.vue +119 -0
  58. package/src/views/ContributorsView.vue +110 -0
  59. package/src/views/DatasetView.vue +249 -0
  60. package/src/views/GraphView.vue +65 -0
  61. package/src/views/HomeView.vue +168 -0
  62. package/src/views/NewsView.vue +146 -0
  63. package/src/views/ResolveView.vue +63 -0
  64. package/src/views/SearchView.vue +33 -0
  65. package/src/views/StatsView.vue +121 -0
  66. package/tailwind.config.js +43 -0
  67. package/tsconfig.json +24 -0
  68. package/vite.config.ts +27 -0
@@ -0,0 +1,499 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch, onMounted, onUnmounted, nextTick, reactive } from 'vue';
3
+ import type { GraphNode, GraphEdge } from '../adapters/types';
4
+ import type { SimulationNodeDatum, SimulationLinkDatum } from 'd3';
5
+ import { useDsStyle } from '../utils/dataset-style';
6
+ import {
7
+ forceSimulation,
8
+ forceLink,
9
+ forceManyBody,
10
+ forceCenter,
11
+ forceCollide,
12
+ select,
13
+ zoom,
14
+ zoomIdentity,
15
+ drag,
16
+ type D3DragEvent,
17
+ type Selection,
18
+ } from 'd3';
19
+
20
+ const props = defineProps<{
21
+ nodes: GraphNode[];
22
+ edges: GraphEdge[];
23
+ registers: { id: string; title: string }[];
24
+ }>();
25
+
26
+ const svgRef = ref<SVGSVGElement | null>(null);
27
+ const containerRef = ref<HTMLDivElement | null>(null);
28
+ const selectedNode = ref<GraphNode | null>(null);
29
+ const detailCloseRef = ref<HTMLButtonElement | null>(null);
30
+
31
+ // Dataset enable/disable state
32
+ const registerEnabled = reactive<Record<string, boolean>>({});
33
+ const panelOpen = ref(true);
34
+
35
+ // Default to first register only enabled
36
+ for (let i = 0; i < props.registers.length; i++) {
37
+ const reg = props.registers[i];
38
+ if (registerEnabled[reg.id] === undefined) {
39
+ registerEnabled[reg.id] = i === 0;
40
+ }
41
+ }
42
+
43
+ // Watch for new registers — keep them off by default
44
+ watch(() => props.registers, (regs) => {
45
+ for (const reg of regs) {
46
+ if (registerEnabled[reg.id] === undefined) {
47
+ registerEnabled[reg.id] = false;
48
+ }
49
+ }
50
+ });
51
+
52
+ const { getColor } = useDsStyle();
53
+
54
+ const STUB_COLOR = '#b8b9cc'; // ink-200
55
+ const HIGHLIGHT_COLOR = '#1a1b2e'; // ink-800
56
+
57
+ function registerColor(register: string): string {
58
+ return getColor(register);
59
+ }
60
+
61
+ // Filtered data based on enabled registers
62
+ const enabledRegisters = computed(() => {
63
+ const enabled = new Set<string>();
64
+ for (const [id, on] of Object.entries(registerEnabled)) {
65
+ if (on) enabled.add(id);
66
+ }
67
+ return enabled;
68
+ });
69
+
70
+ const visibleNodes = computed(() => {
71
+ const enabled = enabledRegisters.value;
72
+ return props.nodes.filter(n => enabled.has(n.register));
73
+ });
74
+
75
+ const visibleNodeUris = computed(() => {
76
+ const uris = new Set<string>();
77
+ for (const n of visibleNodes.value) uris.add(n.uri);
78
+ return uris;
79
+ });
80
+
81
+ const visibleEdges = computed(() => {
82
+ const uris = visibleNodeUris.value;
83
+ return props.edges.filter(e => uris.has(e.source) && uris.has(e.target));
84
+ });
85
+
86
+ const nodeCount = computed(() => visibleNodes.value.length);
87
+ const edgeCount = computed(() => visibleEdges.value.length);
88
+ const isCapped = computed(() => nodeCount.value > MAX_RENDER_NODES);
89
+
90
+ // Per-register stats (from ALL props, not filtered)
91
+ const registerStats = computed(() => {
92
+ const stats: Record<string, { nodes: number; edges: number }> = {};
93
+ for (const reg of props.registers) {
94
+ stats[reg.id] = { nodes: 0, edges: 0 };
95
+ }
96
+ for (const n of props.nodes) {
97
+ if (stats[n.register]) stats[n.register].nodes++;
98
+ }
99
+ for (const e of props.edges) {
100
+ const reg = e.register || (e.source.match(/glossarist\.org\/([^/]+)\/concept\//)?.[1] ?? '');
101
+ if (stats[reg]) stats[reg].edges++;
102
+ }
103
+ return stats;
104
+ });
105
+
106
+ interface SimNode extends SimulationNodeDatum {
107
+ uri: string;
108
+ register: string;
109
+ conceptId: string;
110
+ designation: string;
111
+ loaded: boolean;
112
+ }
113
+
114
+ interface SimLink extends SimulationLinkDatum<SimNode> {
115
+ source: SimNode | string;
116
+ target: SimNode | string;
117
+ type: string;
118
+ label?: string;
119
+ }
120
+
121
+ let simulation: ReturnType<typeof forceSimulation<SimNode>> | null = null;
122
+ let svg: Selection<SVGSVGElement, unknown, null, undefined> | null = null;
123
+ let g: Selection<SVGGElement, unknown, null, undefined> | null = null;
124
+ let zoomBehavior: ReturnType<typeof zoom<SVGSVGElement, unknown>> | null = null;
125
+
126
+ function initGraph() {
127
+ if (!svgRef.value || !containerRef.value) return;
128
+
129
+ const width = containerRef.value.clientWidth;
130
+ const height = containerRef.value.clientHeight;
131
+
132
+ svg = select(svgRef.value);
133
+ g = svg.append<SVGGElement>('g');
134
+
135
+ zoomBehavior = zoom<SVGSVGElement, unknown>()
136
+ .scaleExtent([0.1, 8])
137
+ .on('zoom', (event) => {
138
+ g?.attr('transform', event.transform.toString());
139
+ });
140
+ svg.call(zoomBehavior);
141
+
142
+ // Arrow marker
143
+ const defs = svg.append('defs');
144
+ defs.append('marker')
145
+ .attr('id', 'arrowhead')
146
+ .attr('viewBox', '0 -5 10 10')
147
+ .attr('refX', 20)
148
+ .attr('refY', 0)
149
+ .attr('markerWidth', 5)
150
+ .attr('markerHeight', 5)
151
+ .attr('orient', 'auto')
152
+ .append('path')
153
+ .attr('d', 'M0,-4L10,0L0,4')
154
+ .attr('fill', '#dddde6'); // ink-100
155
+
156
+ buildSimulation(width, height);
157
+ }
158
+
159
+ const MAX_RENDER_NODES = 3000;
160
+
161
+ function buildSimulation(width: number, height: number) {
162
+ if (!g) return;
163
+
164
+ const allVisible = visibleNodes.value;
165
+ const capped = allVisible.length > MAX_RENDER_NODES;
166
+ const renderNodes = capped ? allVisible.slice(0, MAX_RENDER_NODES) : allVisible;
167
+
168
+ const simNodes: SimNode[] = renderNodes.map(n => ({
169
+ uri: n.uri,
170
+ register: n.register,
171
+ conceptId: n.conceptId,
172
+ designation: Object.values(n.designations)[0] || n.conceptId,
173
+ loaded: n.loaded,
174
+ x: width / 2 + (Math.random() - 0.5) * 200,
175
+ y: height / 2 + (Math.random() - 0.5) * 200,
176
+ }));
177
+
178
+ const nodeMap = new Map(simNodes.map(n => [n.uri, n]));
179
+
180
+ const simLinks: SimLink[] = visibleEdges.value
181
+ .filter(e => nodeMap.has(e.source) && nodeMap.has(e.target))
182
+ .map(e => ({
183
+ source: e.source,
184
+ target: e.target,
185
+ type: e.type,
186
+ label: e.label,
187
+ }));
188
+
189
+ g.selectAll('*').remove();
190
+
191
+ const linkSel = g.append('g')
192
+ .attr('class', 'links')
193
+ .selectAll('line')
194
+ .data(simLinks)
195
+ .join('line')
196
+ .attr('stroke', '#dddde6')
197
+ .attr('stroke-width', 0.8)
198
+ .attr('marker-end', 'url(#arrowhead)');
199
+
200
+ const nodeSel = g.append('g')
201
+ .attr('class', 'nodes')
202
+ .selectAll<SVGGElement, SimNode>('g')
203
+ .data(simNodes, d => d.uri)
204
+ .join('g')
205
+ .attr('class', 'node')
206
+ .style('cursor', 'pointer');
207
+
208
+ nodeSel.append('circle')
209
+ .attr('r', d => d.loaded ? 5 : 3)
210
+ .attr('fill', d => d.loaded ? registerColor(d.register) : STUB_COLOR)
211
+ .attr('stroke', '#faf9f6') // surface
212
+ .attr('stroke-width', 1.5);
213
+
214
+ nodeSel.append('text')
215
+ .attr('dy', -9)
216
+ .attr('text-anchor', 'middle')
217
+ .attr('font-size', '8px')
218
+ .attr('font-family', '"DM Sans", system-ui, sans-serif')
219
+ .attr('font-weight', '500')
220
+ .attr('fill', '#636588') // ink-400
221
+ .attr('pointer-events', 'none')
222
+ .text(d => d.designation.slice(0, 18));
223
+
224
+ const dragBehavior = drag<SVGGElement, SimNode>()
225
+ .on('start', (event: D3DragEvent<SVGGElement, SimNode, SimNode>, d) => {
226
+ if (!event.active) simulation?.alphaTarget(0.3).restart();
227
+ d.fx = d.x;
228
+ d.fy = d.y;
229
+ })
230
+ .on('drag', (event: D3DragEvent<SVGGElement, SimNode, SimNode>, d) => {
231
+ d.fx = event.x;
232
+ d.fy = event.y;
233
+ })
234
+ .on('end', (event: D3DragEvent<SVGGElement, SimNode, SimNode>, d) => {
235
+ if (!event.active) simulation?.alphaTarget(0);
236
+ d.fx = null;
237
+ d.fy = null;
238
+ });
239
+
240
+ nodeSel.call(dragBehavior);
241
+
242
+ nodeSel.on('click', (_event, d) => {
243
+ const node = props.nodes.find(n => n.uri === d.uri);
244
+ if (node) {
245
+ selectedNode.value = selectedNode.value?.uri === node.uri ? null : node;
246
+ }
247
+ });
248
+
249
+ nodeSel.on('mouseenter', (_event, d) => {
250
+ linkSel
251
+ .attr('stroke', l => {
252
+ const src = typeof l.source === 'object' ? l.source.uri : l.source;
253
+ const tgt = typeof l.target === 'object' ? l.target.uri : l.target;
254
+ return src === d.uri || tgt === d.uri ? HIGHLIGHT_COLOR : '#eeeef2';
255
+ })
256
+ .attr('stroke-width', l => {
257
+ const src = typeof l.source === 'object' ? l.source.uri : l.source;
258
+ const tgt = typeof l.target === 'object' ? l.target.uri : l.target;
259
+ return src === d.uri || tgt === d.uri ? 1.5 : 0.8;
260
+ });
261
+ nodeSel.select('circle')
262
+ .attr('r', n => n.uri === d.uri ? 8 : n.loaded ? 5 : 3)
263
+ .attr('fill', n => n.uri === d.uri ? HIGHLIGHT_COLOR : n.loaded ? registerColor(n.register) : STUB_COLOR);
264
+ }).on('mouseleave', () => {
265
+ linkSel.attr('stroke', '#dddde6').attr('stroke-width', 0.8);
266
+ nodeSel.select('circle')
267
+ .attr('r', n => n.loaded ? 5 : 3)
268
+ .attr('fill', n => n.loaded ? registerColor(n.register) : STUB_COLOR);
269
+ });
270
+
271
+ const count = simNodes.length;
272
+ simulation = forceSimulation<SimNode>(simNodes)
273
+ .force('link', forceLink<SimNode, SimLink>(simLinks)
274
+ .id(d => d.uri)
275
+ .distance(count < 50 ? 80 : count < 200 ? 60 : 40)
276
+ .strength(0.5)
277
+ )
278
+ .force('charge', forceManyBody()
279
+ .strength(count < 50 ? -200 : count < 200 ? -100 : -50)
280
+ )
281
+ .force('center', forceCenter(width / 2, height / 2))
282
+ .force('collide', forceCollide<SimNode>().radius(count > 1000 ? 6 : 12))
283
+ .alpha(1)
284
+ .alphaDecay(count > 500 ? 0.05 : 0.02)
285
+ .on('tick', () => {
286
+ linkSel
287
+ .attr('x1', d => (d.source as SimNode).x ?? 0)
288
+ .attr('y1', d => (d.source as SimNode).y ?? 0)
289
+ .attr('x2', d => (d.target as SimNode).x ?? 0)
290
+ .attr('y2', d => (d.target as SimNode).y ?? 0);
291
+
292
+ nodeSel.attr('transform', d => `translate(${d.x ?? 0},${d.y ?? 0})`);
293
+ });
294
+ }
295
+
296
+ function rebuildGraph() {
297
+ if (!containerRef.value || !svgRef.value) return;
298
+ const width = containerRef.value.clientWidth;
299
+ const height = containerRef.value.clientHeight;
300
+
301
+ if (g) g.selectAll('*').remove();
302
+ if (simulation) simulation.stop();
303
+
304
+ if (!g) {
305
+ initGraph();
306
+ } else {
307
+ buildSimulation(width, height);
308
+ }
309
+ }
310
+
311
+ // Focus close button when node detail popup opens
312
+ watch(selectedNode, (node) => {
313
+ if (node) {
314
+ nextTick(() => {
315
+ detailCloseRef.value?.focus();
316
+ });
317
+ }
318
+ });
319
+
320
+ // Rebuild when data or filters change
321
+ let prevDataKey = '';
322
+ watch([() => props.nodes.length, () => props.edges.length], ([nn, ne]) => {
323
+ const key = `${nn}:${ne}`;
324
+ if (key !== prevDataKey && nn > 0) {
325
+ prevDataKey = key;
326
+ nextTick(rebuildGraph);
327
+ }
328
+ });
329
+
330
+ // Rebuild when register filters change
331
+ watch(registerEnabled, () => {
332
+ nextTick(rebuildGraph);
333
+ });
334
+
335
+ onMounted(() => {
336
+ nextTick(() => {
337
+ if (props.nodes.length > 0) {
338
+ initGraph();
339
+ prevDataKey = `${props.nodes.length}:${props.edges.length}`;
340
+ }
341
+ });
342
+ });
343
+
344
+ onUnmounted(() => {
345
+ simulation?.stop();
346
+ });
347
+
348
+ function resetZoom() {
349
+ if (svg && zoomBehavior) {
350
+ svg.transition().duration(500).call(zoomBehavior.transform, zoomIdentity);
351
+ }
352
+ }
353
+
354
+ function toggleAll(on: boolean) {
355
+ for (const reg of props.registers) {
356
+ registerEnabled[reg.id] = on;
357
+ }
358
+ }
359
+
360
+ function selectOnly(registerId: string) {
361
+ for (const reg of props.registers) {
362
+ registerEnabled[reg.id] = reg.id === registerId;
363
+ }
364
+ }
365
+
366
+ function selectedNodeColor(): string {
367
+ if (!selectedNode.value) return STUB_COLOR;
368
+ if (!selectedNode.value.loaded) return STUB_COLOR;
369
+ return registerColor(selectedNode.value.register);
370
+ }
371
+ </script>
372
+
373
+ <template>
374
+ <div ref="containerRef" class="w-full h-full relative bg-surface">
375
+ <!-- Control panel -->
376
+ <div class="absolute top-4 left-4 z-10">
377
+ <div class="bg-surface-raised/95 backdrop-blur rounded-xl border border-ink-100/60 overflow-hidden" style="box-shadow: 0 4px 12px rgba(26, 27, 46, 0.08);">
378
+ <button
379
+ @click="panelOpen = !panelOpen"
380
+ :aria-label="panelOpen ? 'Collapse controls' : 'Expand controls'"
381
+ class="w-full px-4 py-2.5 flex items-center justify-between hover:bg-ink-50/50 transition-colors"
382
+ >
383
+ <span class="text-xs font-semibold text-ink-600 tracking-wide">
384
+ {{ nodeCount.toLocaleString() }} nodes &middot; {{ edgeCount.toLocaleString() }} edges
385
+ </span>
386
+ <svg class="w-3.5 h-3.5 text-ink-300 transition-transform" :class="panelOpen ? 'rotate-180' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24">
387
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
388
+ </svg>
389
+ </button>
390
+
391
+ <div v-if="panelOpen" class="px-4 pb-4 border-t border-ink-100/40">
392
+ <!-- Dataset toggles -->
393
+ <div class="mt-3 space-y-2">
394
+ <div class="flex items-center gap-2 mb-3">
395
+ <button @click="toggleAll(true)" class="text-[10px] font-semibold text-ink-500 hover:text-ink-700 uppercase tracking-wide transition-colors">All</button>
396
+ <span class="text-ink-200 text-xs">|</span>
397
+ <button @click="toggleAll(false)" class="text-[10px] font-semibold text-ink-500 hover:text-ink-700 uppercase tracking-wide transition-colors">None</button>
398
+ </div>
399
+ <div
400
+ v-for="reg in registers"
401
+ :key="reg.id"
402
+ class="flex items-center gap-2.5 py-1"
403
+ >
404
+ <label class="cursor-pointer flex items-center">
405
+ <input
406
+ type="checkbox"
407
+ v-model="registerEnabled[reg.id]"
408
+ class="rounded border-ink-200 text-ink-800 focus:ring-ink-400/30"
409
+ />
410
+ </label>
411
+ <button
412
+ @click="selectOnly(reg.id)"
413
+ class="flex items-center gap-2 min-w-0 flex-1 text-left group"
414
+ >
415
+ <span
416
+ class="w-2.5 h-2.5 rounded-full flex-shrink-0 ring-2 ring-offset-1"
417
+ :style="{ backgroundColor: registerColor(reg.id), '--tw-ring-color': registerColor(reg.id) + '40' }"
418
+ ></span>
419
+ <span class="text-xs text-ink-600 group-hover:text-ink-800 truncate transition-colors">{{ reg.title }}</span>
420
+ <span class="text-[10px] text-ink-300 ml-auto tabular-nums">
421
+ {{ registerStats[reg.id]?.nodes ?? 0 }}
422
+ </span>
423
+ </button>
424
+ </div>
425
+ </div>
426
+
427
+ <!-- Actions -->
428
+ <div v-if="isCapped" class="text-[10px] text-amber-600 mt-2 leading-relaxed">
429
+ Rendering first {{ MAX_RENDER_NODES.toLocaleString() }} of {{ nodeCount.toLocaleString() }} nodes.
430
+ </div>
431
+
432
+ <div v-if="nodeCount > 0" class="flex gap-4 mt-3 pt-3 border-t border-ink-100/40">
433
+ <button @click="resetZoom" class="text-[10px] font-semibold text-ink-500 hover:text-ink-700 uppercase tracking-wide transition-colors">Reset zoom</button>
434
+ <button @click="rebuildGraph" class="text-[10px] font-semibold text-ink-500 hover:text-ink-700 uppercase tracking-wide transition-colors">Re-layout</button>
435
+ </div>
436
+
437
+ <div v-if="nodeCount === 0" class="text-xs text-ink-300 mt-3 leading-relaxed">
438
+ {{ props.edges.length > 0 ? 'Enable datasets to see their graph.' : 'Browse concepts with cross-references to populate the graph.' }}
439
+ </div>
440
+ </div>
441
+ </div>
442
+ </div>
443
+
444
+ <!-- Legend -->
445
+ <div v-if="nodeCount > 0" class="absolute top-4 right-4 z-10 bg-surface-raised/90 backdrop-blur rounded-lg px-3 py-2.5 border border-ink-100/60 text-xs" style="box-shadow: 0 2px 6px rgba(26, 27, 46, 0.04);">
446
+ <div class="font-semibold text-ink-400 text-[10px] uppercase tracking-wide mb-2">Datasets</div>
447
+ <div v-for="reg in registers" :key="reg.id" class="flex items-center gap-2 mb-1.5 last:mb-0">
448
+ <span
449
+ class="w-2.5 h-2.5 rounded-full inline-block flex-shrink-0"
450
+ :style="{ backgroundColor: registerColor(reg.id) }"
451
+ ></span>
452
+ <span class="text-ink-500">{{ reg.id }}</span>
453
+ </div>
454
+ <div class="flex items-center gap-2 mt-2 pt-2 border-t border-ink-100/40">
455
+ <span class="w-2 h-2 rounded-full inline-block" :style="{ backgroundColor: STUB_COLOR }"></span>
456
+ <span class="text-ink-300">Stub (not loaded)</span>
457
+ </div>
458
+ </div>
459
+
460
+ <svg ref="svgRef" class="w-full h-full" role="img" aria-label="Concept relationship graph visualization"></svg>
461
+
462
+ <!-- Node detail popup -->
463
+ <div
464
+ v-if="selectedNode"
465
+ @keydown.escape="selectedNode = null"
466
+ class="absolute bottom-6 left-6 right-6 max-w-xs bg-surface-raised rounded-xl border border-ink-100/60 p-5 z-20"
467
+ style="box-shadow: 0 8px 24px rgba(26, 27, 46, 0.12);"
468
+ >
469
+ <div class="flex items-start justify-between gap-3">
470
+ <div class="min-w-0">
471
+ <h3 class="font-serif text-base text-ink-800 leading-snug truncate">
472
+ {{ Object.values(selectedNode.designations)[0] || selectedNode.conceptId }}
473
+ </h3>
474
+ <p class="text-xs text-ink-300 font-mono mt-0.5">{{ selectedNode.conceptId }}</p>
475
+ <div class="flex items-center gap-1.5 mt-2">
476
+ <span
477
+ class="w-2 h-2 rounded-full inline-block flex-shrink-0"
478
+ :style="{ backgroundColor: selectedNodeColor() }"
479
+ ></span>
480
+ <span class="text-[10px] text-ink-400 uppercase tracking-wide">
481
+ {{ selectedNode.register || 'unknown' }} &middot;
482
+ {{ selectedNode.loaded ? 'loaded' : 'stub' }}
483
+ </span>
484
+ </div>
485
+ </div>
486
+ <button ref="detailCloseRef" @click="selectedNode = null" class="text-ink-300 hover:text-ink-600 transition-colors flex-shrink-0 mt-0.5" aria-label="Close">
487
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
488
+ </button>
489
+ </div>
490
+ <router-link
491
+ v-if="selectedNode.register"
492
+ :to="{ name: 'concept', params: { registerId: selectedNode.register, conceptId: selectedNode.conceptId } }"
493
+ class="btn-primary text-xs mt-4 inline-block"
494
+ >
495
+ View concept
496
+ </router-link>
497
+ </div>
498
+ </div>
499
+ </template>