@glossarist/concept-browser 0.7.51 → 0.7.52

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 (152) hide show
  1. package/cli/index.mjs +32 -0
  2. package/env.d.ts +15 -0
  3. package/package.json +12 -2
  4. package/scripts/__tests__/doctor.test.mjs +147 -0
  5. package/scripts/doctor.mjs +327 -0
  6. package/scripts/generate-data.mjs +136 -0
  7. package/scripts/generate-ontology-data.mjs +3 -3
  8. package/scripts/generate-ontology-schema.mjs +3 -3
  9. package/scripts/lib/agents-turtle.mjs +64 -0
  10. package/scripts/lib/bibliography-turtle.mjs +54 -0
  11. package/scripts/lib/build-activity-turtle.mjs +92 -0
  12. package/scripts/lib/build-cache.mjs +70 -0
  13. package/scripts/lib/dataset-turtle.mjs +79 -0
  14. package/scripts/lib/turtle-escape.mjs +0 -0
  15. package/scripts/lib/version-turtle.mjs +56 -0
  16. package/scripts/lib/vocab-turtle.mjs +64 -0
  17. package/scripts/normalize-yaml.mjs +99 -0
  18. package/scripts/sync-concept-model.mjs +86 -0
  19. package/scripts/validate-shacl.mjs +194 -0
  20. package/src/__fixtures__/concept-shape.ttl +20 -0
  21. package/src/__fixtures__/shacl/bad/concept.ttl +7 -0
  22. package/src/__fixtures__/shacl/empty/.gitkeep +0 -0
  23. package/src/__fixtures__/shacl/good/concept.ttl +8 -0
  24. package/src/__tests__/__fixtures__/concepts.ts +221 -0
  25. package/src/__tests__/adapters/concept-identity.test.ts +76 -0
  26. package/src/__tests__/components/error-boundary.test.ts +109 -0
  27. package/src/__tests__/composables/use-dataset-series.test.ts +262 -0
  28. package/src/__tests__/concept-rdf/agents-emitter.test.ts +110 -0
  29. package/src/__tests__/concept-rdf/bibliography-emitter.test.ts +159 -0
  30. package/src/__tests__/concept-rdf/build-activity-emitter.test.ts +119 -0
  31. package/src/__tests__/concept-rdf/coerce-date.test.ts +97 -0
  32. package/src/__tests__/concept-rdf/concept-emitter.test.ts +258 -0
  33. package/src/__tests__/concept-rdf/dataset-emitter.test.ts +224 -0
  34. package/src/__tests__/concept-rdf/differential.test.ts +96 -0
  35. package/src/__tests__/concept-rdf/group-emitter.test.ts +109 -0
  36. package/src/__tests__/concept-rdf/image-variant-emitter.test.ts +135 -0
  37. package/src/__tests__/concept-rdf/jsonld-writer.test.ts +109 -0
  38. package/src/__tests__/concept-rdf/nonverbal-rep.test.ts +177 -0
  39. package/src/__tests__/concept-rdf/property-based.test.ts +179 -0
  40. package/src/__tests__/concept-rdf/provenance.test.ts +110 -0
  41. package/src/__tests__/concept-rdf/quad-isomorphism.test.ts +43 -0
  42. package/src/__tests__/concept-rdf/quad-isomorphism.ts +47 -0
  43. package/src/__tests__/concept-rdf/rdf-components.test.ts +145 -0
  44. package/src/__tests__/concept-rdf/rdf-graph.test.ts +115 -0
  45. package/src/__tests__/concept-rdf/round-trip.test.ts +243 -0
  46. package/src/__tests__/concept-rdf/scoped-examples.test.ts +126 -0
  47. package/src/__tests__/concept-rdf/sections-builder.test.ts +94 -0
  48. package/src/__tests__/concept-rdf/shacl-conformance.test.ts +110 -0
  49. package/src/__tests__/concept-rdf/shape-consistency.test.ts +138 -0
  50. package/src/__tests__/concept-rdf/snapshot-generation.test.ts +75 -0
  51. package/src/__tests__/concept-rdf/table-formula-emitter.test.ts +142 -0
  52. package/src/__tests__/concept-rdf/turtle-writer.test.ts +114 -0
  53. package/src/__tests__/concept-rdf/use-rdf-document.test.ts +246 -0
  54. package/src/__tests__/concept-rdf/version-emitter.test.ts +120 -0
  55. package/src/__tests__/concept-rdf/vocabulary-ssot.test.ts +100 -0
  56. package/src/__tests__/concept-rdf-view.test.ts +136 -0
  57. package/src/__tests__/dataset-style.test.ts +12 -7
  58. package/src/__tests__/errors/errors.test.ts +142 -0
  59. package/src/__tests__/format-downloads.test.ts +47 -65
  60. package/src/__tests__/markdown-lite.test.ts +19 -0
  61. package/src/__tests__/perf/bundle-layout.test.ts +50 -0
  62. package/src/__tests__/perf/serialization-perf.test.ts +121 -0
  63. package/src/__tests__/scripts/agents-turtle.test.ts +61 -0
  64. package/src/__tests__/scripts/bibliography-turtle.test.ts +59 -0
  65. package/src/__tests__/scripts/build-activity-turtle.test.ts +75 -0
  66. package/src/__tests__/scripts/build-cache.test.ts +78 -0
  67. package/src/__tests__/scripts/build-integration.test.ts +134 -0
  68. package/src/__tests__/scripts/dataset-turtle.test.ts +94 -0
  69. package/src/__tests__/scripts/normalize-yaml.test.ts +98 -0
  70. package/src/__tests__/scripts/stryker-config.test.ts +33 -0
  71. package/src/__tests__/scripts/turtle-escape.test.ts +63 -0
  72. package/src/__tests__/scripts/version-turtle.test.ts +72 -0
  73. package/src/__tests__/scripts/vocab-turtle.test.ts +63 -0
  74. package/src/__tests__/use-format-registry.test.ts +125 -0
  75. package/src/__tests__/utils/bcp47.test.ts +166 -0
  76. package/src/__tests__/utils/color-theme.test.ts +143 -0
  77. package/src/__tests__/utils/url-safety.test.ts +65 -0
  78. package/src/__tests__/validate-shacl.test.ts +100 -0
  79. package/src/adapters/DatasetAdapter.ts +11 -5
  80. package/src/adapters/GraphDataSource.ts +2 -1
  81. package/src/adapters/UriRouter.ts +2 -1
  82. package/src/adapters/concept-identity.ts +69 -0
  83. package/src/adapters/factory.ts +3 -2
  84. package/src/adapters/model-bridge.ts +2 -1
  85. package/src/adapters/non-verbal/glossarist-augment.d.ts +7 -0
  86. package/src/adapters/non-verbal-resolver.ts +2 -1
  87. package/src/components/AppSidebar.vue +189 -93
  88. package/src/components/ConceptDetail.vue +8 -0
  89. package/src/components/ConceptEditionRail.vue +222 -0
  90. package/src/components/ConceptRdfView.vue +37 -377
  91. package/src/components/DatasetSeriesCard.vue +270 -0
  92. package/src/components/ErrorBoundary.vue +95 -0
  93. package/src/components/FormatDownloads.vue +17 -13
  94. package/src/components/HomeSeriesSection.vue +277 -0
  95. package/src/components/RelationSphere.vue +1672 -0
  96. package/src/components/SidebarSeriesSection.vue +239 -0
  97. package/src/components/concept-rdf/RdfInstanceHeader.vue +47 -0
  98. package/src/components/concept-rdf/RdfInstanceSection.vue +54 -0
  99. package/src/components/concept-rdf/RdfPrefixLegend.vue +27 -0
  100. package/src/components/concept-rdf/RdfSourcePanel.vue +72 -0
  101. package/src/components/concept-rdf/agents-emitter.ts +82 -0
  102. package/src/components/concept-rdf/bibliography-emitter.ts +83 -0
  103. package/src/components/concept-rdf/build-activity-emitter.ts +89 -0
  104. package/src/components/concept-rdf/concept-emitter.ts +443 -0
  105. package/src/components/concept-rdf/dataset-emitter.ts +95 -0
  106. package/src/components/concept-rdf/group-emitter.ts +69 -0
  107. package/src/components/concept-rdf/image-variant-emitter.ts +46 -0
  108. package/src/components/concept-rdf/jsonld-writer.ts +82 -0
  109. package/src/components/concept-rdf/predicates.ts +261 -0
  110. package/src/components/concept-rdf/provenance.ts +80 -0
  111. package/src/components/concept-rdf/rdf-graph.ts +211 -0
  112. package/src/components/concept-rdf/rdf-prefixes.ts +23 -0
  113. package/src/components/concept-rdf/sections-builder.ts +62 -0
  114. package/src/components/concept-rdf/table-formula-emitter.ts +101 -0
  115. package/src/components/concept-rdf/turtle-writer.ts +116 -0
  116. package/src/components/concept-rdf/use-rdf-document.ts +72 -0
  117. package/src/components/concept-rdf/version-emitter.ts +65 -0
  118. package/src/components/concept-rdf/vocabulary-emitter.ts +62 -0
  119. package/src/composables/use-color-theme.ts +82 -0
  120. package/src/composables/use-format-registry.ts +42 -0
  121. package/src/composables/useDatasetSeries.ts +258 -0
  122. package/src/composables/useSphereProjection.ts +125 -0
  123. package/src/config/group-types.ts +92 -0
  124. package/src/config/types.ts +81 -2
  125. package/src/config/use-site-config.ts +2 -1
  126. package/src/errors.ts +136 -0
  127. package/src/i18n/locales/eng.yml +24 -0
  128. package/src/i18n/locales/fra.yml +24 -0
  129. package/src/stores/vocabulary.ts +3 -1
  130. package/src/style.css +17 -2
  131. package/src/types/agents-version-turtle.d.ts +27 -0
  132. package/src/types/bibliography-turtle.d.ts +12 -0
  133. package/src/types/build-activity-turtle.d.ts +16 -0
  134. package/src/types/build-cache.d.ts +20 -0
  135. package/src/types/dataset-turtle.d.ts +32 -0
  136. package/src/types/normalize-yaml.d.ts +16 -0
  137. package/src/types/turtle-escape.d.ts +6 -0
  138. package/src/types/vocab-turtle.d.ts +13 -0
  139. package/src/utils/asciidoc-lite.ts +11 -6
  140. package/src/utils/bcp47.ts +141 -0
  141. package/src/utils/color-theme-integration.ts +11 -0
  142. package/src/utils/color-theme.ts +129 -0
  143. package/src/utils/dataset-style.ts +31 -6
  144. package/src/utils/locale.ts +6 -14
  145. package/src/utils/markdown-lite.ts +6 -1
  146. package/src/utils/relation-sphere-styling.ts +63 -0
  147. package/src/utils/relationship-categories.ts +30 -0
  148. package/src/utils/url-safety.ts +30 -0
  149. package/src/views/ConceptView.vue +183 -9
  150. package/src/views/DatasetView.vue +6 -0
  151. package/src/views/HomeView.vue +5 -0
  152. package/vite.config.ts +7 -0
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Dataset series — groups related editions of the same vocabulary.
3
+ *
4
+ * A "series" is a family of datasets that share a base name but differ in
5
+ * edition year/status. e.g. `viml-2022`, `viml-2013`, `viml-2000`, `viml-1968`
6
+ * all belong to the `viml` series.
7
+ *
8
+ * Resolution order (most authoritative first):
9
+ * 1. Explicit `datasetGroups` from site-config with `series: true` —
10
+ * config-driven series have stable ids, labels, and orderings.
11
+ * 2. Auto-derivation by naming convention — `name-YYYY` → `name`. Used
12
+ * only when NO configured groups exist (backward compat).
13
+ *
14
+ * Within a series, editions are sorted by year ascending so the timeline
15
+ * reads naturally left-to-right / top-to-bottom.
16
+ */
17
+
18
+ import { computed } from 'vue';
19
+ import { useVocabularyStore } from '../stores/vocabulary';
20
+ import { useSiteConfig } from '../config/use-site-config';
21
+ import { resolveGroupKind } from '../config/group-types';
22
+ import type { Manifest } from '../adapters/types';
23
+
24
+ export interface DatasetSeriesMember {
25
+ id: string;
26
+ ref: string;
27
+ year?: number;
28
+ status: string;
29
+ isCurrent: boolean;
30
+ isActive: boolean;
31
+ conceptCount?: number;
32
+ registerId: string;
33
+ }
34
+
35
+ export interface DatasetSeries {
36
+ /** Stable series key, e.g. `viml`. */
37
+ key: string;
38
+ /** Display title for the series, e.g. `VIML` or `International Vocabulary of Legal Metrology`. */
39
+ title: string;
40
+ /** Optional description from config. */
41
+ description?: string;
42
+ /** Optional accent color from config. Accepts single hex or { light, dark }. */
43
+ color?: string | { light: string; dark: string };
44
+ /** All known editions, oldest first. */
45
+ members: DatasetSeriesMember[];
46
+ /** The current (newest valid) member, if any. */
47
+ current?: DatasetSeriesMember;
48
+ /** Total concept count across the series (sum of members). */
49
+ totalConcepts: number;
50
+ /** Whether this series was explicitly configured (vs auto-derived). */
51
+ configured: boolean;
52
+ }
53
+
54
+ const YEAR_SUFFIX = /[-_:](\d{4})([a-z]?)$/i;
55
+
56
+ /** Strip trailing year from a dataset id to get the series key. */
57
+ export function deriveSeriesKey(id: string): string {
58
+ return id.replace(YEAR_SUFFIX, '').replace(/[-_:]+$/, '');
59
+ }
60
+
61
+ /** Extract a year from a string. Prefers the ISO convention
62
+ * (`:YYYY`, `:YYYYa`) used in ISO/IEC standard references, then
63
+ * falls back to a year-as-suffix match (`-YYYY`, `_YYYY`, ` YYYY`),
64
+ * then a bare 4-digit year. Returns undefined for out-of-range or
65
+ * 4-digit runs that are clearly standard numbers (e.g. `ISO 10241`
66
+ * → 1024 is rejected because it's not preceded by `:`). */
67
+ export function extractYear(source: string): number | undefined {
68
+ if (!source) return undefined;
69
+ const isoMatch = source.match(/:(\d{4})([a-z]?)$/i);
70
+ if (isoMatch) {
71
+ const year = parseInt(isoMatch[1], 10);
72
+ if (year >= 1900 && year <= 2100) return year;
73
+ }
74
+ const suffixMatch = source.match(/[-_\s](\d{4})([a-z]?)$/i);
75
+ if (suffixMatch) {
76
+ const year = parseInt(suffixMatch[1], 10);
77
+ if (year >= 1900 && year <= 2100) return year;
78
+ }
79
+ if (/^\d{4}$/.test(source.trim())) {
80
+ const year = parseInt(source.trim(), 10);
81
+ if (year >= 1900 && year <= 2100) return year;
82
+ }
83
+ return undefined;
84
+ }
85
+
86
+ /** Build the series title from a member — `OIML V 1:2022` → `OIML V 1`.
87
+ * Returns the dataset id when the manifest is unavailable. */
88
+ function deriveSeriesTitle(m: Manifest | undefined): string {
89
+ if (!m) return '';
90
+ const ref = m.ref ?? m.title;
91
+ return ref.replace(/[:\s-]\d{4}([a-z]?)$/i, '').trim() || m.title;
92
+ }
93
+
94
+ function manifestToMember(m: Manifest, activeDatasetId?: string): DatasetSeriesMember {
95
+ const year = extractYear(m.id) ?? extractYear(m.ref ?? '') ?? extractYear(m.title);
96
+ return {
97
+ id: m.id,
98
+ ref: m.ref ?? m.title,
99
+ year,
100
+ status: m.status ?? m.editionStatus ?? 'unknown',
101
+ isCurrent: false,
102
+ isActive: m.id === activeDatasetId,
103
+ conceptCount: m.conceptCount,
104
+ registerId: m.id,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Group manifests into series. Pure function.
110
+ *
111
+ * Strategy:
112
+ * 1. If `configuredGroups` is provided AND has any group whose `kind` is
113
+ * `lineage` (or the legacy `series: true` flag), use ONLY configured
114
+ * lineage groups — config is the source of truth.
115
+ * 2. Otherwise, fall back to auto-derivation by naming convention.
116
+ */
117
+ export function groupManifestsIntoSeries(
118
+ manifests: Manifest[],
119
+ activeDatasetId?: string,
120
+ configuredGroups?: Array<{
121
+ id: string;
122
+ label?: string;
123
+ description?: string;
124
+ color?: string | { light: string; dark: string };
125
+ datasets: string[];
126
+ series?: boolean;
127
+ kind?: 'lineage' | 'topic' | 'family' | 'collection' | 'default';
128
+ current?: string;
129
+ }>,
130
+ ): DatasetSeries[] {
131
+ const manifestMap = new Map(manifests.map(m => [m.id, m] as const));
132
+
133
+ /* Strategy 1: config-driven lineage series */
134
+ const configSeries = (configuredGroups ?? []).filter(g => resolveGroupKind(g) === 'lineage');
135
+ if (configSeries.length > 0) {
136
+ const series: DatasetSeries[] = [];
137
+ for (const g of configSeries) {
138
+ const members: DatasetSeriesMember[] = [];
139
+ /* Include EVERY dataset in the config, even if its manifest isn't loaded
140
+ yet. Without this, visiting /dataset/viml-2000 (only manifest loaded)
141
+ would shrink the series to one member and mis-mark viml-2000 as current. */
142
+ for (const id of g.datasets) {
143
+ const m = manifestMap.get(id);
144
+ if (m) {
145
+ members.push(manifestToMember(m, activeDatasetId));
146
+ } else {
147
+ /* Stub member from id alone — year derived from `<name>-YYYY` pattern. */
148
+ members.push({
149
+ id,
150
+ ref: id,
151
+ year: extractYear(id),
152
+ status: 'unknown',
153
+ isCurrent: false,
154
+ isActive: id === activeDatasetId,
155
+ conceptCount: undefined,
156
+ registerId: id,
157
+ });
158
+ }
159
+ }
160
+ if (members.length === 0) continue;
161
+ members.sort((a, b) => (a.year ?? 0) - (b.year ?? 0));
162
+
163
+ /* Determine current edition:
164
+ 1. Explicit `current` field from config (most authoritative)
165
+ 2. Newest member with status='valid'
166
+ 3. Last member (newest by year) */
167
+ let current: DatasetSeriesMember | undefined;
168
+ if (g.current) {
169
+ current = members.find(m => m.id === g.current);
170
+ }
171
+ if (!current) {
172
+ const validMembers = members.filter(m => m.status === 'valid');
173
+ current = validMembers[validMembers.length - 1] ?? members[members.length - 1];
174
+ }
175
+ if (current) current.isCurrent = true;
176
+
177
+ const totalConcepts = members.reduce((sum, m) => sum + (m.conceptCount ?? 0), 0);
178
+ series.push({
179
+ key: g.id,
180
+ title: g.label ?? (deriveSeriesTitle(manifestMap.get(members[0].id)) || g.id),
181
+ description: g.description,
182
+ color: g.color,
183
+ members,
184
+ current,
185
+ totalConcepts,
186
+ configured: true,
187
+ });
188
+ }
189
+ return series;
190
+ }
191
+
192
+ /* Strategy 2: auto-derive by naming convention */
193
+ const groups = new Map<string, Manifest[]>();
194
+ for (const m of manifests) {
195
+ const key = deriveSeriesKey(m.id);
196
+ if (!groups.has(key)) groups.set(key, []);
197
+ groups.get(key)!.push(m);
198
+ }
199
+
200
+ const series: DatasetSeries[] = [];
201
+ for (const [key, members] of groups) {
202
+ if (members.length === 0) continue;
203
+ const enriched = members.map(m => manifestToMember(m, activeDatasetId));
204
+ enriched.sort((a, b) => (a.year ?? 0) - (b.year ?? 0));
205
+ const validMembers = enriched.filter(m => m.status === 'valid');
206
+ const current = validMembers[validMembers.length - 1] ?? enriched[enriched.length - 1];
207
+ if (current) current.isCurrent = true;
208
+ const totalConcepts = enriched.reduce((sum, m) => sum + (m.conceptCount ?? 0), 0);
209
+ series.push({
210
+ key,
211
+ title: deriveSeriesTitle(members[0]),
212
+ members: enriched,
213
+ current,
214
+ totalConcepts,
215
+ configured: false,
216
+ });
217
+ }
218
+
219
+ series.sort((a, b) => a.key.localeCompare(b.key));
220
+ return series;
221
+ }
222
+
223
+ /**
224
+ * Composable — exposes series for the currently-loaded datasets.
225
+ * Reactive: re-derives when the store's dataset list changes.
226
+ */
227
+ export function useDatasetSeries(activeDatasetId?: () => string | undefined) {
228
+ const store = useVocabularyStore();
229
+ const { datasetGroups } = useSiteConfig();
230
+
231
+ const series = computed<DatasetSeries[]>(() => {
232
+ const manifests: Manifest[] = [];
233
+ for (const [, adapter] of store.datasets.entries()) {
234
+ const m = adapter.manifest;
235
+ if (m) manifests.push(m);
236
+ }
237
+ const activeId = activeDatasetId?.();
238
+ const configured = datasetGroups.value?.map(g => ({
239
+ id: g.id,
240
+ label: g.label,
241
+ description: g.description,
242
+ color: g.color,
243
+ datasets: g.datasets,
244
+ series: g.series,
245
+ kind: g.kind,
246
+ current: g.current,
247
+ }));
248
+ return groupManifestsIntoSeries(manifests, activeId, configured);
249
+ });
250
+
251
+ const seriesForActive = computed<DatasetSeries | undefined>(() => {
252
+ const activeId = activeDatasetId?.();
253
+ if (!activeId) return undefined;
254
+ return series.value.find(s => s.members.some(m => m.id === activeId));
255
+ });
256
+
257
+ return { series, seriesForActive };
258
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Sphere projection + force-layout math for RelationSphere.
3
+ *
4
+ * All functions are pure — no Vue, no DOM. The component passes positions in,
5
+ * gets screen coordinates out. This keeps the math testable and the component
6
+ * focused on rendering.
7
+ *
8
+ * Coordinates: unit sphere (‖p‖ = 1). The "front pole" is (0, 0, 1) — the
9
+ * focal point of the camera. The simulation runs in 3D and is projected to
10
+ * 2D with perspective at render time.
11
+ */
12
+
13
+ export interface Vec3 {
14
+ x: number;
15
+ y: number;
16
+ z: number;
17
+ }
18
+
19
+ /** Perspective projection constants — tuned for unit sphere. */
20
+ const FOCAL = 600;
21
+ const Z_OFFSET = 800;
22
+ const SPHERE_R = 360;
23
+
24
+ /** Slow-fast-slow easing — cubic ease-in-out. */
25
+ export function easeInOutCubic(t: number): number {
26
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
27
+ }
28
+
29
+ /** Great-circle interpolation between two unit-sphere points. */
30
+ export function slerp(a: Vec3, b: Vec3, t: number): Vec3 {
31
+ const dot = Math.max(-1, Math.min(1, a.x * b.x + a.y * b.y + a.z * b.z));
32
+ const omega = Math.acos(dot);
33
+ if (omega < 0.001) {
34
+ return {
35
+ x: a.x + (b.x - a.x) * t,
36
+ y: a.y + (b.y - a.y) * t,
37
+ z: a.z + (b.z - a.z) * t,
38
+ };
39
+ }
40
+ const sinO = Math.sin(omega);
41
+ const wa = Math.sin((1 - t) * omega) / sinO;
42
+ const wb = Math.sin(t * omega) / sinO;
43
+ return {
44
+ x: a.x * wa + b.x * wb,
45
+ y: a.y * wa + b.y * wb,
46
+ z: a.z * wa + b.z * wb,
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Initial sphere position by depth band. Focus at (0,0,1); 1°/2°/3° neighbors
52
+ * distributed at increasing angular distance from the focus.
53
+ *
54
+ * Depth 1 forms an EVEN ring (best spacing for small N). Deeper levels use
55
+ * the golden-angle offset so they interleave with the previous ring.
56
+ */
57
+ export function fibonacciSpherePosition(
58
+ depth: number,
59
+ idx: number,
60
+ total: number,
61
+ jitterSeed: number,
62
+ ): Vec3 {
63
+ if (depth === 0) return { x: 0, y: 0, z: 1 };
64
+ /* Angular distance from north pole — each ring further out. */
65
+ const thetaByDepth: Record<number, number> = { 1: 1.15, 2: 1.55, 3: 1.95 };
66
+ const theta = thetaByDepth[depth] ?? 1.15;
67
+ const j = (jitterSeed % 7) * 0.008;
68
+ const t = theta + j;
69
+ let phi: number;
70
+ if (depth === 1) {
71
+ /* Even ring for depth 1 — gives best spread for small N */
72
+ phi = (idx / Math.max(total, 1)) * Math.PI * 2;
73
+ } else {
74
+ /* Golden-angle spiral for deeper rings, offset by depth so rings interleave */
75
+ phi = (idx * 2.39996 + depth * 1.7 + (jitterSeed % 11) * 0.4) % (Math.PI * 2);
76
+ }
77
+ return {
78
+ x: Math.sin(t) * Math.cos(phi),
79
+ y: Math.sin(t) * Math.sin(phi),
80
+ z: Math.cos(t),
81
+ };
82
+ }
83
+
84
+ /** 3D unit-sphere point → 2D screen offset from sphere center. */
85
+ export interface Projected {
86
+ x: number;
87
+ y: number;
88
+ scale: number;
89
+ z: number;
90
+ }
91
+
92
+ export function project(p: Vec3): Projected {
93
+ const persp = FOCAL / (Z_OFFSET - p.z * SPHERE_R);
94
+ return {
95
+ x: p.x * SPHERE_R * persp,
96
+ y: -p.y * SPHERE_R * persp,
97
+ scale: persp,
98
+ z: p.z,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Find the point on a rectangle's edge in the direction of an external point.
104
+ * Used so SVG edge paths start/end on the rim of the card, not its center.
105
+ */
106
+ export function cardEdge(
107
+ from: { x: number; y: number },
108
+ to: { x: number; y: number },
109
+ w: number,
110
+ h: number,
111
+ ): { x: number; y: number } {
112
+ const dx = to.x - from.x;
113
+ const dy = to.y - from.y;
114
+ const len = Math.sqrt(dx * dx + dy * dy) || 1;
115
+ const ux = dx / len;
116
+ const uy = dy / len;
117
+ const absUx = Math.abs(ux);
118
+ const absUy = Math.abs(uy);
119
+ const halfW = w / 2;
120
+ const halfH = h / 2;
121
+ const tX = absUx > 0.001 ? halfW / absUx : Infinity;
122
+ const tY = absUy > 0.001 ? halfH / absUy : Infinity;
123
+ const t = Math.min(tX, tY);
124
+ return { x: from.x + ux * t, y: from.y + uy * t };
125
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Dataset group type registry.
3
+ *
4
+ * Maps each `DatasetGroupKind` to its semantic metadata. Adding a new kind
5
+ * is a single entry here + a new renderer component — no edits to existing
6
+ * components needed (open/closed principle).
7
+ *
8
+ * The registry is intentionally pure data — no Vue imports — so it can be
9
+ * consumed by both the sidebar (compact rendering) and the home page (rich
10
+ * rendering) without coupling.
11
+ */
12
+ import type { DatasetGroupKind } from './types';
13
+
14
+ export interface GroupTypeMeta {
15
+ /** Discriminator value matching `DatasetGroup.kind`. */
16
+ kind: DatasetGroupKind;
17
+ /** Human label for the kind, e.g. "Edition series". */
18
+ label: string;
19
+ /** Short description shown in tooltips / section headers. */
20
+ description: string;
21
+ /** Icon glyph — used in section headers and breadcrumbs. */
22
+ glyph: string;
23
+ /** Whether members have an inherent temporal ordering. */
24
+ ordered: boolean;
25
+ /** Whether members have a supersession chain (newer supersedes older). */
26
+ supersession: boolean;
27
+ /** Whether members share the same vocabulary identity across editions. */
28
+ sameVocabulary: boolean;
29
+ }
30
+
31
+ export const GROUP_TYPES: Record<DatasetGroupKind, GroupTypeMeta> = {
32
+ lineage: {
33
+ kind: 'lineage',
34
+ label: 'Edition series',
35
+ description: 'Same vocabulary, different editions over time',
36
+ glyph: '⏳',
37
+ ordered: true,
38
+ supersession: true,
39
+ sameVocabulary: true,
40
+ },
41
+ topic: {
42
+ kind: 'topic',
43
+ label: 'Topic bundle',
44
+ description: 'Different vocabularies covering the same subject',
45
+ glyph: '◆',
46
+ ordered: false,
47
+ supersession: false,
48
+ sameVocabulary: false,
49
+ },
50
+ family: {
51
+ kind: 'family',
52
+ label: 'Publication family',
53
+ description: 'Related vocabularies from the same publisher or program',
54
+ glyph: '✦',
55
+ ordered: false,
56
+ supersession: false,
57
+ sameVocabulary: false,
58
+ },
59
+ collection: {
60
+ kind: 'collection',
61
+ label: 'Curated collection',
62
+ description: 'Hand-picked bundle of datasets for a specific audience',
63
+ glyph: '❖',
64
+ ordered: false,
65
+ supersession: false,
66
+ sameVocabulary: false,
67
+ },
68
+ default: {
69
+ kind: 'default',
70
+ label: 'Datasets',
71
+ description: 'Grouped datasets',
72
+ glyph: '▸',
73
+ ordered: false,
74
+ supersession: false,
75
+ sameVocabulary: false,
76
+ },
77
+ };
78
+
79
+ /**
80
+ * Normalize a group config (which may use the legacy `series: true` flag or
81
+ * the new `kind` discriminator) into a canonical `kind`. Pure function.
82
+ */
83
+ export function resolveGroupKind(group: { kind?: DatasetGroupKind; series?: boolean }): DatasetGroupKind {
84
+ if (group.kind) return group.kind;
85
+ if (group.series) return 'lineage';
86
+ return 'default';
87
+ }
88
+
89
+ /** Lookup the metadata for any group, using `resolveGroupKind` for compat. */
90
+ export function groupTypeMeta(group: { kind?: DatasetGroupKind; series?: boolean }): GroupTypeMeta {
91
+ return GROUP_TYPES[resolveGroupKind(group)];
92
+ }
@@ -82,6 +82,8 @@ export interface RoutingEntry {
82
82
 
83
83
  // === Dataset ===
84
84
 
85
+ export type DatasetColorSpec = string | { light: string; dark: string };
86
+
85
87
  export interface DatasetConfig {
86
88
  id: string;
87
89
  uri: string;
@@ -92,7 +94,13 @@ export interface DatasetConfig {
92
94
  title: string;
93
95
  description?: string;
94
96
  owner?: string;
95
- color?: string;
97
+ /**
98
+ * Dataset accent color. Accepts either a single hex (applied to both
99
+ * light and dark modes) or an explicit `{ light, dark }` pair.
100
+ * Per-deployment overrides via `site-config.json` `colors.dataset[id]`
101
+ * take precedence.
102
+ */
103
+ color?: DatasetColorSpec;
96
104
  tags?: string[];
97
105
  languageOrder?: string[];
98
106
  ref?: string;
@@ -142,17 +150,86 @@ export interface PageConfig {
142
150
 
143
151
  // === Dataset Groups ===
144
152
 
153
+ /**
154
+ * Kind of dataset group. Determines how the group is rendered in the sidebar
155
+ * and home page, and what semantic relationships between members are assumed.
156
+ *
157
+ * - `lineage` — same vocabulary, different editions (e.g. VIML 1968/2000/2013/2022).
158
+ * Members have temporal ordering and a supersession chain. Rendered as a
159
+ * timeline with year badges and "current" markers.
160
+ *
161
+ * - `topic` — different vocabularies on the same subject (e.g. three SDOs
162
+ * publishing "intelligent transport systems" terminology). Members may
163
+ * overlap in concepts but have no temporal ordering. Rendered as a card
164
+ * grid with overlap indicators.
165
+ *
166
+ * - `family` — related vocabularies from the same publisher or program
167
+ * (e.g. all OIML publications). Hierarchical grouping, no required
168
+ * relationships between members. Rendered as a flat list under a labeled
169
+ * header.
170
+ *
171
+ * - `collection` — curated bundle of datasets (e.g. "Starter pack for new
172
+ * metrologists"). Arbitrary selection, often cross-publisher. Rendered as
173
+ * a featured card with custom descriptions.
174
+ *
175
+ * - `default` (omitted) — backward-compatible flat list. No special
176
+ * semantics. Used when no `kind` is specified.
177
+ *
178
+ * The registry in `src/config/group-types.ts` maps each kind to its renderer
179
+ * component, so new kinds can be added without modifying existing code
180
+ * (open/closed principle).
181
+ */
182
+ export type DatasetGroupKind = 'lineage' | 'topic' | 'family' | 'collection' | 'default';
183
+
145
184
  export interface DatasetGroup {
146
185
  id: string;
147
186
  label: string;
148
187
  description?: string;
149
- color?: string;
188
+ /**
189
+ * Group accent color. Same shape as DatasetConfig.color.
190
+ * Per-deployment overrides via `site-config.json` `colors.group[id]`.
191
+ */
192
+ color?: DatasetColorSpec;
150
193
  datasets: string[];
151
194
  translations?: Record<string, { label?: string; description?: string }>;
195
+ /**
196
+ * Discriminator for the group's semantic type and UX. See DatasetGroupKind
197
+ * for the full list of supported values. Defaults to 'default' (flat list).
198
+ *
199
+ * Replaces the older `series?: boolean` flag — use `kind: lineage` instead.
200
+ */
201
+ kind?: DatasetGroupKind;
202
+ /**
203
+ * For lineage series: the dataset id of the current (newest valid) edition.
204
+ * If omitted, the newest member by year (or last in `datasets` order) is
205
+ * used. Setting this explicitly avoids misdetection when only a subset of
206
+ * editions happen to be loaded.
207
+ */
208
+ current?: string;
209
+ /**
210
+ * @deprecated Use `kind: 'lineage'` instead. Still respected as a
211
+ * backward-compat shorthand: `series: true` is treated as `kind: 'lineage'`.
212
+ */
213
+ series?: boolean;
152
214
  }
153
215
 
154
216
  // === Site Config ===
155
217
 
218
+ export interface SiteColors {
219
+ /** Per-dataset color overrides. Keyed by dataset id. */
220
+ dataset?: Record<string, DatasetColorSpec>;
221
+ /** Per-group color overrides. Keyed by group id. */
222
+ group?: Record<string, DatasetColorSpec>;
223
+ /** Per-relation-type color overrides. Keyed by type id (e.g. "supersedes"). */
224
+ relationshipType?: Record<string, DatasetColorSpec>;
225
+ /** Per-relation-category color overrides. Keyed by category id (e.g. "lifecycle"). */
226
+ relationshipCategory?: Record<string, DatasetColorSpec>;
227
+ /** Per-concept-status color overrides. Keyed by status id. */
228
+ conceptStatus?: Record<string, DatasetColorSpec>;
229
+ /** Per-group-kind color overrides. Keyed by DatasetGroupKind. */
230
+ groupKind?: Record<string, DatasetColorSpec>;
231
+ }
232
+
156
233
  export interface SiteConfig {
157
234
  id: string;
158
235
  domain: string;
@@ -171,6 +248,8 @@ export interface SiteConfig {
171
248
  social?: SocialLinks;
172
249
  nav?: NavItem[];
173
250
  footerNav?: NavItem[];
251
+ /** Color overrides. Merged over `data/colors.json` defaults. */
252
+ colors?: SiteColors;
174
253
  defaults: {
175
254
  language?: string;
176
255
  languageOrder?: string[];
@@ -1,5 +1,5 @@
1
1
  import { ref, computed } from 'vue';
2
- import type { PageConfig } from './types';
2
+ import type { PageConfig, SiteColors } from './types';
3
3
  import type { DatasetGroup } from './types';
4
4
  import { locale } from '../i18n';
5
5
 
@@ -38,6 +38,7 @@ export interface RuntimeSiteConfig {
38
38
  pages?: PageConfig[];
39
39
  contributors?: { name: string; role?: string; organization?: string; url?: string; email?: string }[];
40
40
  copyright?: string;
41
+ colors?: SiteColors;
41
42
  }
42
43
 
43
44
  const siteConfig = ref<RuntimeSiteConfig | null>(null);