@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.
- package/README.md +319 -0
- package/cli/index.mjs +119 -0
- package/env.d.ts +7 -0
- package/index.html +16 -0
- package/package.json +78 -0
- package/postcss.config.js +6 -0
- package/scripts/build-edges.js +112 -0
- package/scripts/fetch-datasets.mjs +195 -0
- package/scripts/generate-404.js +15 -0
- package/scripts/generate-data.mjs +606 -0
- package/scripts/load-site-config.mjs +56 -0
- package/src/App.vue +98 -0
- package/src/__tests__/data-integration.test.ts +135 -0
- package/src/__tests__/data-integrity.test.ts +101 -0
- package/src/__tests__/dataset-adapter.test.ts +336 -0
- package/src/__tests__/dataset-style.test.ts +37 -0
- package/src/__tests__/graph.test.ts +187 -0
- package/src/__tests__/lang.test.ts +29 -0
- package/src/__tests__/math.test.ts +113 -0
- package/src/__tests__/reference-resolver.test.ts +122 -0
- package/src/__tests__/site-config.test.ts +52 -0
- package/src/__tests__/uri-router.test.ts +76 -0
- package/src/adapters/DatasetAdapter.ts +270 -0
- package/src/adapters/ReferenceResolver.ts +95 -0
- package/src/adapters/UriRouter.ts +41 -0
- package/src/adapters/factory.ts +78 -0
- package/src/adapters/types.ts +162 -0
- package/src/components/AppHeader.vue +99 -0
- package/src/components/AppSidebar.vue +133 -0
- package/src/components/ConceptCard.vue +65 -0
- package/src/components/ConceptDetail.vue +540 -0
- package/src/components/ConceptTimeline.vue +410 -0
- package/src/components/FormatDownloads.vue +46 -0
- package/src/components/GraphPanel.vue +499 -0
- package/src/components/LanguageDetail.vue +211 -0
- package/src/components/NavIcon.vue +20 -0
- package/src/components/SearchBar.vue +241 -0
- package/src/composables/use-dataset-loader.ts +27 -0
- package/src/config/types.ts +130 -0
- package/src/config/use-site-config.ts +144 -0
- package/src/graph/GraphEngine.ts +137 -0
- package/src/graph/index.ts +1 -0
- package/src/main.ts +11 -0
- package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/src/router/index.ts +43 -0
- package/src/router/page-routes.ts +35 -0
- package/src/stores/ui.ts +59 -0
- package/src/stores/vocabulary.ts +309 -0
- package/src/style.css +314 -0
- package/src/utils/asciidoc-lite.ts +123 -0
- package/src/utils/concept-formats.ts +157 -0
- package/src/utils/dataset-style.ts +54 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/lang.ts +32 -0
- package/src/utils/math.ts +100 -0
- package/src/views/AboutView.vue +122 -0
- package/src/views/ConceptView.vue +119 -0
- package/src/views/ContributorsView.vue +110 -0
- package/src/views/DatasetView.vue +249 -0
- package/src/views/GraphView.vue +65 -0
- package/src/views/HomeView.vue +168 -0
- package/src/views/NewsView.vue +146 -0
- package/src/views/ResolveView.vue +63 -0
- package/src/views/SearchView.vue +33 -0
- package/src/views/StatsView.vue +121 -0
- package/tailwind.config.js +43 -0
- package/tsconfig.json +24 -0
- 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 · {{ 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' }} ·
|
|
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>
|