@glossarist/concept-browser 0.7.77 → 0.7.80
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/astro.config.mjs +1 -0
- package/package.json +2 -2
- package/scripts/bridge-to-astro.mjs +6 -1
- package/scripts/build-edges.js +154 -1
- package/scripts/generate-data.mjs +138 -54
- package/src/__tests__/build-edges-redundancy.test.ts +101 -0
- package/src/__tests__/composables/use-concept-diff.test.ts +240 -0
- package/src/__tests__/composables/use-concept-edges-v2.test.ts +132 -0
- package/src/__tests__/composables/use-dataset-stats.test.ts +96 -0
- package/src/__tests__/config/page-types.test.ts +82 -0
- package/src/__tests__/model-bridge-bridges.test.ts +3 -2
- package/src/__tests__/model-bridge-citation.test.ts +1 -1
- package/src/__tests__/ontology-registry.test.ts +2 -2
- package/src/__tests__/partitive-relation-styling.test.ts +82 -0
- package/src/__tests__/partitive-relation.test.ts +285 -0
- package/src/__tests__/sphere-math.test.ts +56 -0
- package/src/adapters/GraphDataSource.ts +22 -5
- package/src/adapters/UriRouter.ts +5 -0
- package/src/adapters/dataset-stats.ts +13 -0
- package/src/adapters/model-bridge.ts +1 -1
- package/src/adapters/non-verbal/glossarist-augment.d.ts +155 -4
- package/src/adapters/types.ts +36 -0
- package/src/components/AppSidebar.vue +14 -57
- package/src/components/ConceptDetail.vue +29 -0
- package/src/components/ConceptDiffModal.vue +120 -0
- package/src/components/ConceptDiffView.vue +192 -0
- package/src/components/ConceptEditionRail.vue +61 -36
- package/src/components/PartitiveRelationList.vue +139 -0
- package/src/components/RelationSphere.vue +2 -36
- package/src/components/groups/DatasetGroupRenderer.vue +14 -4
- package/src/components/groups/DefaultGroupSidebar.vue +28 -11
- package/src/components/groups/LineageGroupSidebar.vue +17 -29
- package/src/composables/use-concept-diff.ts +231 -0
- package/src/composables/use-concept-edges.ts +98 -5
- package/src/composables/use-dataset-stats.ts +52 -0
- package/src/config/page-types.ts +67 -0
- package/src/config/types.ts +6 -1
- package/src/config/use-site-config.ts +13 -29
- package/src/data/taxonomies.json +24 -1
- package/src/i18n/locales/eng.yml +6 -0
- package/src/utils/partitive-relation-styling.ts +62 -0
- package/src/utils/sphere-math.ts +54 -0
- package/src/views/DatasetView.vue +5 -3
- package/src/views/StatsView.vue +9 -22
- package/src/islands/BibliographyIsland.vue +0 -26
- package/src/islands/ConceptDetailIsland.vue +0 -27
- package/src/islands/ConceptDiffIsland.vue +0 -214
- package/src/islands/ConceptIsland.vue +0 -102
- package/src/islands/GraphIsland.vue +0 -15
- package/src/islands/GroupRendererIsland.vue +0 -15
- package/src/islands/HeaderIsland.vue +0 -10
- package/src/islands/NonVerbalIsland.vue +0 -75
- package/src/islands/RelationSphereIsland.vue +0 -20
- package/src/islands/SearchIsland.vue +0 -23
- package/src/islands/SeriesCardIsland.vue +0 -12
- package/src/islands/ShortcutsIsland.vue +0 -13
- package/src/islands/SidebarIsland.vue +0 -15
- package/src/islands/SupersessionDiffIsland.vue +0 -267
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
3
|
* LineageGroupSidebar — timeline-style entries for edition series.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Invoked via DatasetGroupRenderer dispatch when group.kind === 'lineage'.
|
|
5
|
+
*
|
|
6
|
+
* Slot `expanded` receives `{ entry, isCurrent }` per entry so callers
|
|
7
|
+
* can render shared per-entry content (sub-pages, sections, etc.)
|
|
8
|
+
* without touching this component.
|
|
6
9
|
*/
|
|
7
10
|
import { useRouter } from 'vue-router';
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
defineProps<{
|
|
10
13
|
entries: Array<{
|
|
11
14
|
id: string;
|
|
12
15
|
title: string;
|
|
@@ -22,24 +25,27 @@ const props = defineProps<{
|
|
|
22
25
|
|
|
23
26
|
const router = useRouter();
|
|
24
27
|
|
|
25
|
-
function navigate(id: string) {
|
|
26
|
-
if (id ===
|
|
28
|
+
function navigate(id: string, current: string) {
|
|
29
|
+
if (id === current) return;
|
|
27
30
|
router.push({ name: 'dataset', params: { registerId: id } });
|
|
28
31
|
}
|
|
29
32
|
</script>
|
|
30
33
|
|
|
31
34
|
<template>
|
|
32
|
-
<div
|
|
35
|
+
<div
|
|
36
|
+
v-for="ds in entries"
|
|
37
|
+
:key="ds.id"
|
|
38
|
+
class="rounded-lg transition-all duration-150"
|
|
39
|
+
:class="currentDataset === ds.id ? 'bg-surface' : ''"
|
|
40
|
+
>
|
|
33
41
|
<button
|
|
34
|
-
v-for="ds in entries"
|
|
35
|
-
:key="ds.id"
|
|
36
42
|
type="button"
|
|
37
43
|
class="series-entry w-full text-left flex items-center gap-2 pl-6 pr-3 py-1.5 rounded-md text-sm border-l-2 transition-all duration-150"
|
|
38
44
|
:class="currentDataset === ds.id
|
|
39
45
|
? 'bg-amber-50/70 dark:bg-amber-400/10 border-l-[3px] text-ink-900 dark:text-ink-50 font-semibold'
|
|
40
46
|
: 'border-transparent text-ink-600 dark:text-ink-300 hover:bg-ink-50 dark:hover:bg-ink-700/40 hover:text-ink-900 dark:hover:text-ink-50'"
|
|
41
|
-
:style="currentDataset === ds.id ? { borderLeftColor: 'var(--
|
|
42
|
-
@click="navigate(ds.id)"
|
|
47
|
+
:style="currentDataset === ds.id ? { borderLeftColor: 'var(--gold-accent, #B8935A)' } : {}"
|
|
48
|
+
@click="navigate(ds.id, currentDataset)"
|
|
43
49
|
>
|
|
44
50
|
<span class="flex-1 truncate text-[13.5px] font-medium leading-snug">{{ ds.ref || ds.title || ds.id }}</span>
|
|
45
51
|
<span
|
|
@@ -52,24 +58,6 @@ function navigate(id: string) {
|
|
|
52
58
|
title="Current edition"
|
|
53
59
|
>✦</span>
|
|
54
60
|
</button>
|
|
61
|
+
<slot name="expanded" :entry="ds" :is-current="currentDataset === ds.id" />
|
|
55
62
|
</div>
|
|
56
63
|
</template>
|
|
57
|
-
|
|
58
|
-
<style scoped>
|
|
59
|
-
.series-entry {
|
|
60
|
-
position: relative;
|
|
61
|
-
}
|
|
62
|
-
.current-star {
|
|
63
|
-
display: inline-flex;
|
|
64
|
-
align-items: center;
|
|
65
|
-
justify-content: center;
|
|
66
|
-
font-size: 16px;
|
|
67
|
-
line-height: 1;
|
|
68
|
-
color: var(--group-kind-lineage-light, #B8935A);
|
|
69
|
-
filter: drop-shadow(0 0 4px rgba(184, 147, 90, 0.45));
|
|
70
|
-
}
|
|
71
|
-
:global(.dark) .current-star {
|
|
72
|
-
color: var(--group-kind-lineage-dark, #D4AF6E);
|
|
73
|
-
filter: drop-shadow(0 0 4px rgba(212, 175, 110, 0.55));
|
|
74
|
-
}
|
|
75
|
-
</style>
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useConceptDiff — diff two concepts and derive render-ready sections.
|
|
3
|
+
*
|
|
4
|
+
* Consolidates logic that was previously duplicated across
|
|
5
|
+
* ConceptDiffIsland.vue and SupersessionDiffIsland.vue (DRY).
|
|
6
|
+
*
|
|
7
|
+
* Pipeline:
|
|
8
|
+
* raw wire data → DiffableConcept → diffConcepts → ConceptDiff
|
|
9
|
+
* ↓
|
|
10
|
+
* deriveDiffSections
|
|
11
|
+
* computeSimilarity
|
|
12
|
+
*
|
|
13
|
+
* `similarity` is not a field on ConceptDiff (it doesn't exist in
|
|
14
|
+
* glossarist-js's type). It is computed from stats:
|
|
15
|
+
* similarity = 1 - (added + removed + changed) / total
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { ref } from 'vue';
|
|
19
|
+
import { diffConcepts } from 'glossarist/diff';
|
|
20
|
+
import type { ConceptDiff } from 'glossarist/diff';
|
|
21
|
+
|
|
22
|
+
// ── Inputs ────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/** Wire shape of a concept as stored in fetched JSON or content collections. */
|
|
25
|
+
export interface ConceptLikeData {
|
|
26
|
+
conceptId?: string;
|
|
27
|
+
id?: string;
|
|
28
|
+
uri?: string;
|
|
29
|
+
status?: string;
|
|
30
|
+
languages?: string[];
|
|
31
|
+
localizations?: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Normalized concept shape consumed by diffConcepts. */
|
|
35
|
+
export interface DiffableConcept {
|
|
36
|
+
readonly id: string;
|
|
37
|
+
readonly termid: string;
|
|
38
|
+
readonly status?: string;
|
|
39
|
+
readonly uri?: string;
|
|
40
|
+
readonly languages: readonly string[];
|
|
41
|
+
localization(lang: string): {
|
|
42
|
+
languageCode: string;
|
|
43
|
+
terms: unknown[];
|
|
44
|
+
definitions: unknown[];
|
|
45
|
+
notes: unknown[];
|
|
46
|
+
examples: unknown[];
|
|
47
|
+
} | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Outputs ───────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export type DiffHunk = { type: 'added' | 'removed' | 'equal'; text: string };
|
|
53
|
+
|
|
54
|
+
export interface DefinitionDiff {
|
|
55
|
+
hunks?: readonly DiffHunk[];
|
|
56
|
+
type?: 'added' | 'removed';
|
|
57
|
+
value?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DesignationsDiff {
|
|
61
|
+
items: Array<{ type?: string; text: string }>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SimpleChangesDiff {
|
|
65
|
+
items: Array<{ type?: string; text: string }>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface DiffSections {
|
|
69
|
+
definition?: DefinitionDiff;
|
|
70
|
+
designations?: DesignationsDiff;
|
|
71
|
+
terms?: { hunks?: readonly DiffHunk[]; oldValue?: string; newValue?: string };
|
|
72
|
+
notes?: SimpleChangesDiff;
|
|
73
|
+
examples?: SimpleChangesDiff;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Adapters ──────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export function toDiffableConcept(data: ConceptLikeData): DiffableConcept {
|
|
79
|
+
const langs = data.languages
|
|
80
|
+
?? (data.localizations ? Object.keys(data.localizations) : []);
|
|
81
|
+
const id = String(data.conceptId ?? data.id ?? '');
|
|
82
|
+
return {
|
|
83
|
+
id,
|
|
84
|
+
termid: id,
|
|
85
|
+
status: data.status,
|
|
86
|
+
uri: data.uri,
|
|
87
|
+
languages: langs,
|
|
88
|
+
localization(lang: string) {
|
|
89
|
+
const locs = (data.localizations ?? {}) as Record<string, any>;
|
|
90
|
+
const loc = locs[lang];
|
|
91
|
+
if (!loc) return null;
|
|
92
|
+
return {
|
|
93
|
+
languageCode: loc.languageCode ?? lang,
|
|
94
|
+
terms: loc.terms ?? [],
|
|
95
|
+
definitions: loc.definitions ?? [],
|
|
96
|
+
notes: loc.notes ?? [],
|
|
97
|
+
examples: loc.examples ?? [],
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Pure derivations ──────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
interface ReadableConceptDiff {
|
|
106
|
+
localization?(lang: string): any;
|
|
107
|
+
localizations?: Record<string, any>;
|
|
108
|
+
stats?: { added?: number; removed?: number; changed?: number; total?: number };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function asReadable(diff: ConceptDiff): ReadableConceptDiff {
|
|
112
|
+
return diff as unknown as ReadableConceptDiff;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function extractDesignationText(value: any): string {
|
|
116
|
+
if (value == null) return '';
|
|
117
|
+
if (typeof value === 'string') return value;
|
|
118
|
+
return value.designation ?? value.text ?? String(value);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function extractContentText(value: any): string {
|
|
122
|
+
if (value == null) return '';
|
|
123
|
+
if (typeof value === 'string') return value;
|
|
124
|
+
return value.content ?? value.text ?? String(value);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Derive render-ready DiffSections from a ConceptDiff for a single language.
|
|
129
|
+
* Pure function — same diff + lang always yields the same sections.
|
|
130
|
+
*/
|
|
131
|
+
export function deriveDiffSections(diff: ConceptDiff | null, lang: string): DiffSections {
|
|
132
|
+
if (!diff) return {};
|
|
133
|
+
const d = asReadable(diff);
|
|
134
|
+
const loc = d.localization?.(lang) ?? d.localizations?.[lang];
|
|
135
|
+
if (!loc) return {};
|
|
136
|
+
const out: DiffSections = {};
|
|
137
|
+
|
|
138
|
+
const defChanged = loc.definitions?.changed?.[0];
|
|
139
|
+
if (defChanged?.textDiff?.hunks?.length) {
|
|
140
|
+
out.definition = { hunks: defChanged.textDiff.hunks };
|
|
141
|
+
} else if (loc.definitions?.added?.length) {
|
|
142
|
+
out.definition = { type: 'added', value: extractContentText(loc.definitions.added[0].value) };
|
|
143
|
+
} else if (loc.definitions?.removed?.length) {
|
|
144
|
+
out.definition = { type: 'removed', value: extractContentText(loc.definitions.removed[0].value) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const hasDesignationChanges =
|
|
148
|
+
loc.designations?.added?.length
|
|
149
|
+
|| loc.designations?.removed?.length
|
|
150
|
+
|| loc.designations?.changed?.length;
|
|
151
|
+
if (hasDesignationChanges) {
|
|
152
|
+
const items: Array<{ type?: string; text: string }> = [];
|
|
153
|
+
for (const d of loc.designations.added ?? []) {
|
|
154
|
+
items.push({ type: 'added', text: extractDesignationText(d.value) });
|
|
155
|
+
}
|
|
156
|
+
for (const d of loc.designations.removed ?? []) {
|
|
157
|
+
items.push({ type: 'removed', text: extractDesignationText(d.value) });
|
|
158
|
+
}
|
|
159
|
+
for (const d of loc.designations.changed ?? []) {
|
|
160
|
+
items.push({ type: 'removed', text: extractDesignationText(d.oldValue) });
|
|
161
|
+
items.push({ type: 'added', text: extractDesignationText(d.newValue) });
|
|
162
|
+
}
|
|
163
|
+
out.designations = { items };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (loc.notes?.added?.length || loc.notes?.removed?.length) {
|
|
167
|
+
out.notes = {
|
|
168
|
+
items: [
|
|
169
|
+
...(loc.notes.added ?? []).map((n: any) => ({ type: 'added', text: extractContentText(n.value) })),
|
|
170
|
+
...(loc.notes.removed ?? []).map((n: any) => ({ type: 'removed', text: extractContentText(n.value) })),
|
|
171
|
+
],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (loc.examples?.added?.length || loc.examples?.removed?.length) {
|
|
176
|
+
out.examples = {
|
|
177
|
+
items: [
|
|
178
|
+
...(loc.examples.added ?? []).map((e: any) => ({ type: 'added', text: extractContentText(e.value) })),
|
|
179
|
+
...(loc.examples.removed ?? []).map((e: any) => ({ type: 'removed', text: extractContentText(e.value) })),
|
|
180
|
+
],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Similarity score in [0, 1]. Returns null when no comparable stats.
|
|
189
|
+
*
|
|
190
|
+
* Computed as 1 − (added + removed + changed) / total — i.e. the
|
|
191
|
+
* fraction of the concept that did NOT change. `added` counts as a
|
|
192
|
+
* change because it represents content present in only one version.
|
|
193
|
+
*/
|
|
194
|
+
export function computeSimilarity(diff: ConceptDiff | null): number | null {
|
|
195
|
+
if (!diff) return null;
|
|
196
|
+
const stats = asReadable(diff).stats;
|
|
197
|
+
if (!stats || !stats.total) return null;
|
|
198
|
+
const changed = (stats.added ?? 0) + (stats.removed ?? 0) + (stats.changed ?? 0);
|
|
199
|
+
return Math.max(0, Math.min(1, 1 - changed / stats.total));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Composable ────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
export function useConceptDiff() {
|
|
205
|
+
const diffResult = ref<ConceptDiff | null>(null);
|
|
206
|
+
const loading = ref(false);
|
|
207
|
+
const error = ref<string | null>(null);
|
|
208
|
+
|
|
209
|
+
function diff(oldData: ConceptLikeData | null, newData: ConceptLikeData | null): void {
|
|
210
|
+
loading.value = true;
|
|
211
|
+
error.value = null;
|
|
212
|
+
try {
|
|
213
|
+
diffResult.value = diffConcepts(
|
|
214
|
+
oldData ? toDiffableConcept(oldData) : null,
|
|
215
|
+
newData ? toDiffableConcept(newData) : null,
|
|
216
|
+
);
|
|
217
|
+
} catch (e: unknown) {
|
|
218
|
+
error.value = e instanceof Error ? e.message : String(e);
|
|
219
|
+
diffResult.value = null;
|
|
220
|
+
} finally {
|
|
221
|
+
loading.value = false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function clear(): void {
|
|
226
|
+
diffResult.value = null;
|
|
227
|
+
error.value = null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return { diffResult, loading, error, diff, clear };
|
|
231
|
+
}
|
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import { computed, type ComputedRef } from 'vue';
|
|
2
2
|
import type { Router } from 'vue-router';
|
|
3
|
-
import type {
|
|
4
|
-
|
|
3
|
+
import type {
|
|
4
|
+
Concept,
|
|
5
|
+
RelatedConcept,
|
|
6
|
+
PartitiveRelation as GlsPartitiveRelation,
|
|
7
|
+
PartitiveMember as GlsPartitiveMember,
|
|
8
|
+
TypeSharedPlurality as GlsTypeSharedPlurality,
|
|
9
|
+
ConceptRef,
|
|
10
|
+
} from 'glossarist';
|
|
11
|
+
import type {
|
|
12
|
+
Manifest,
|
|
13
|
+
GraphEdge,
|
|
14
|
+
PartitiveRelationWire,
|
|
15
|
+
PartitiveMemberWire,
|
|
16
|
+
TypeSharedPluralityWire,
|
|
17
|
+
} from '../adapters/types';
|
|
5
18
|
import { getFactory } from '../adapters/factory';
|
|
6
19
|
import { conceptUri } from '../adapters/model-bridge';
|
|
20
|
+
import { UriRouter } from '../adapters/UriRouter';
|
|
7
21
|
import { useVocabularyStore } from '../stores/vocabulary';
|
|
8
22
|
import { useDsStyle } from '../utils/dataset-style';
|
|
9
23
|
import { categorizeRelationship, relationshipLabel, INVERSE_RELATIONSHIPS } from '../utils/relationship-categories';
|
|
@@ -111,9 +125,80 @@ export function useConceptEdges(
|
|
|
111
125
|
content: '',
|
|
112
126
|
};
|
|
113
127
|
});
|
|
114
|
-
return [...direct, ...derived]
|
|
128
|
+
return [...direct, ...derived].filter(edge => {
|
|
129
|
+
if (edge.type === 'broader_partitive' || edge.type === 'narrower_partitive') {
|
|
130
|
+
const refUri = resolveRefUri(edge.ref as { source: string | null; id: string | null } | null);
|
|
131
|
+
if (!refUri) return true;
|
|
132
|
+
const rels = conceptPartitiveRelations.value;
|
|
133
|
+
return !rels.some(rel =>
|
|
134
|
+
rel.comprehensive === refUri
|
|
135
|
+
|| rel.partitives.some(m => m.uri === refUri),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
});
|
|
115
140
|
});
|
|
116
141
|
|
|
142
|
+
// Concept-level partitive relations (ISO 704 one-to-many decompositions).
|
|
143
|
+
// Reads the glossarist-js native model (`concept.partitiveRelations`)
|
|
144
|
+
// and projects each relation into a URI-resolved wire shape for display.
|
|
145
|
+
// Independent of binary `conceptRelated`.
|
|
146
|
+
// v2 shape per concept-model/TODO.partitive-relation-v2.
|
|
147
|
+
const conceptPartitiveRelations = computed<PartitiveRelationWire[]>(() => {
|
|
148
|
+
const source = conceptUriValue.value;
|
|
149
|
+
const relations: readonly GlsPartitiveRelation[] = concept.value.partitiveRelations;
|
|
150
|
+
return relations
|
|
151
|
+
.map((rel): PartitiveRelationWire | null => {
|
|
152
|
+
const comprehensive = resolveConceptRefUri(rel.comprehensive);
|
|
153
|
+
if (!comprehensive) return null;
|
|
154
|
+
const partitives: PartitiveMemberWire[] = rel.partitives
|
|
155
|
+
.map((m: GlsPartitiveMember): PartitiveMemberWire | null => {
|
|
156
|
+
const uri = resolveConceptRefUri(m.ref);
|
|
157
|
+
if (!uri || uri === source) return null;
|
|
158
|
+
return { uri, certainty: m.certainty };
|
|
159
|
+
})
|
|
160
|
+
.filter((m: PartitiveMemberWire | null): m is PartitiveMemberWire => m !== null);
|
|
161
|
+
if (partitives.length === 0) return null;
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
source,
|
|
165
|
+
comprehensive,
|
|
166
|
+
partitives,
|
|
167
|
+
completeness: rel.completeness,
|
|
168
|
+
plurality: projectPlurality(rel.plurality),
|
|
169
|
+
criterion: rel.criterion ?? undefined,
|
|
170
|
+
register: registerId.value,
|
|
171
|
+
};
|
|
172
|
+
})
|
|
173
|
+
.filter((r: PartitiveRelationWire | null): r is PartitiveRelationWire => r !== null);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
function projectPlurality(p: GlsTypeSharedPlurality | null): TypeSharedPluralityWire | null {
|
|
177
|
+
if (!p) return null;
|
|
178
|
+
const sharedTypeRef: ConceptRef | null = p.sharedType;
|
|
179
|
+
const sharedType = sharedTypeRef
|
|
180
|
+
? [sharedTypeRef.source, sharedTypeRef.id].filter(Boolean).join(':') || null
|
|
181
|
+
: null;
|
|
182
|
+
return {
|
|
183
|
+
isShared: p.isShared,
|
|
184
|
+
isUncertain: p.isUncertain,
|
|
185
|
+
sharedType,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function resolveConceptRefUri(ref: ConceptRef | null): string | null {
|
|
190
|
+
if (!ref) return null;
|
|
191
|
+
return resolveRefUri({ source: ref.source ?? null, id: ref.id ?? null });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function resolveRefUri(ref: { source: string | null; id: string | null } | null): string | null {
|
|
195
|
+
const target = resolveRelatedRef(ref);
|
|
196
|
+
if (!target) return null;
|
|
197
|
+
const m = store.manifests.get(target.registerId);
|
|
198
|
+
if (!m) return null;
|
|
199
|
+
return UriRouter.buildConceptUri(m.uriBase, target.registerId, target.conceptId);
|
|
200
|
+
}
|
|
201
|
+
|
|
117
202
|
function resolveRelatedRef(ref: { source: string | null; id: string | null } | null) {
|
|
118
203
|
return factory.resolveRelatedRef(ref, registerId.value);
|
|
119
204
|
}
|
|
@@ -134,8 +219,15 @@ export function useConceptEdges(
|
|
|
134
219
|
return resolvedRefs.value.get(key) ?? { target: resolveRelatedRef(ref) };
|
|
135
220
|
}
|
|
136
221
|
|
|
137
|
-
function relatedLabel(dr: { content?: string | null; ref?: { source: string | null; id: string | null } | null }): string {
|
|
138
|
-
if (dr.content)
|
|
222
|
+
function relatedLabel(dr: { content?: Record<string, string> | string | null; ref?: { source: string | null; id: string | null } | null }): string {
|
|
223
|
+
if (dr.content) {
|
|
224
|
+
// Content is a localized hash { eng: "...", fra: "..." }.
|
|
225
|
+
// Legacy data may still carry a plain string; tolerate both.
|
|
226
|
+
const c = dr.content as Record<string, string> | string;
|
|
227
|
+
if (typeof c === 'string') return c;
|
|
228
|
+
const values = Object.values(c);
|
|
229
|
+
if (values.length > 0) return values[0];
|
|
230
|
+
}
|
|
139
231
|
const resolved = dr.ref ? getResolvedRef(dr.ref).target : null;
|
|
140
232
|
if (resolved) {
|
|
141
233
|
const m = store.manifests.get(resolved.registerId);
|
|
@@ -173,6 +265,7 @@ export function useConceptEdges(
|
|
|
173
265
|
edgeBadgeColor,
|
|
174
266
|
inverseEdgeType,
|
|
175
267
|
conceptRelated,
|
|
268
|
+
conceptPartitiveRelations,
|
|
176
269
|
resolveRelatedRef,
|
|
177
270
|
getResolvedRef,
|
|
178
271
|
relatedLabel,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useDatasetStats — loads stats.json for a dataset register.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for the DatasetStats wire shape; both
|
|
5
|
+
* DatasetView (summary badges) and StatsView (full table) consume it.
|
|
6
|
+
*/
|
|
7
|
+
import { ref, watch, type Ref } from 'vue';
|
|
8
|
+
import type { DatasetStats } from '../adapters/dataset-stats';
|
|
9
|
+
|
|
10
|
+
export function useDatasetStats(
|
|
11
|
+
registerId: Ref<string> | (() => string),
|
|
12
|
+
baseUrl: string = import.meta.env.BASE_URL,
|
|
13
|
+
) {
|
|
14
|
+
const stats = ref<DatasetStats | null>(null);
|
|
15
|
+
const loading = ref(false);
|
|
16
|
+
const error = ref<string | null>(null);
|
|
17
|
+
|
|
18
|
+
const resolved = typeof registerId === 'function' ? registerId() : registerId.value;
|
|
19
|
+
|
|
20
|
+
async function load(id: string) {
|
|
21
|
+
if (!id) return;
|
|
22
|
+
loading.value = true;
|
|
23
|
+
error.value = null;
|
|
24
|
+
try {
|
|
25
|
+
const resp = await fetch(`${baseUrl}data/${id}/stats.json`);
|
|
26
|
+
if (resp.ok) {
|
|
27
|
+
stats.value = await resp.json() as DatasetStats;
|
|
28
|
+
} else {
|
|
29
|
+
stats.value = null;
|
|
30
|
+
}
|
|
31
|
+
} catch (e: unknown) {
|
|
32
|
+
stats.value = null;
|
|
33
|
+
error.value = e instanceof Error ? e.message : String(e);
|
|
34
|
+
} finally {
|
|
35
|
+
loading.value = false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (typeof registerId === 'function') {
|
|
40
|
+
// Re-fetch on app boot only; callers can call reload() to refresh.
|
|
41
|
+
load(resolved);
|
|
42
|
+
} else {
|
|
43
|
+
watch(registerId, id => load(id), { immediate: true });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function reload() {
|
|
47
|
+
const id = typeof registerId === 'function' ? registerId() : registerId.value;
|
|
48
|
+
load(id);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { stats, loading, error, reload };
|
|
52
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page type registry — OCP-compliant metadata for synthesized pages.
|
|
3
|
+
*
|
|
4
|
+
* Adding a new built-in page type = add one entry to PAGE_TYPES.
|
|
5
|
+
* `synthesizeGlobalPages` and `synthesizeDatasetPages` iterate the
|
|
6
|
+
* registry; no switches to edit.
|
|
7
|
+
*
|
|
8
|
+
* PageConfig.type stays a string — it tags the page's semantic kind
|
|
9
|
+
* for consumers (sidebar, future renderers). The router is the SSOT
|
|
10
|
+
* for route → view dispatch, not this registry.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors the group-renderers.ts pattern: registry + lookup helper.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface PageTypeDefinition {
|
|
16
|
+
readonly type: string;
|
|
17
|
+
readonly route: string;
|
|
18
|
+
readonly title: string;
|
|
19
|
+
readonly icon: string;
|
|
20
|
+
readonly scope: 'global' | 'dataset';
|
|
21
|
+
/** When defined, the page is only synthesized when features[flag] is truthy
|
|
22
|
+
* (or, if the flag starts with '!', when features[flag.slice(1)] is falsy). */
|
|
23
|
+
readonly featureFlag?: string;
|
|
24
|
+
/** Synthesize automatically unless a declared page already uses this route. */
|
|
25
|
+
readonly autoSynthesize?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const PAGE_TYPES: readonly PageTypeDefinition[] = [
|
|
29
|
+
{ type: 'home', route: '', title: 'Home', icon: 'home', scope: 'global', autoSynthesize: true },
|
|
30
|
+
{ type: 'concepts', route: '', title: 'Concepts', icon: 'list', scope: 'dataset', autoSynthesize: true },
|
|
31
|
+
{ type: 'search', route: 'search', title: 'Search', icon: 'search', scope: 'global', featureFlag: '!search', autoSynthesize: true },
|
|
32
|
+
{ type: 'graph', route: 'graph', title: 'Graph', icon: 'graph', scope: 'global', featureFlag: '!graph', autoSynthesize: true },
|
|
33
|
+
{ type: 'ontology', route: 'ontology',title: 'Ontology', icon: 'schema', scope: 'global', featureFlag: '!ontology', autoSynthesize: true },
|
|
34
|
+
{ type: 'news', route: 'news', title: 'News', icon: 'newspaper', scope: 'global', featureFlag: 'news', autoSynthesize: true },
|
|
35
|
+
{ type: 'stats', route: 'stats', title: 'Statistics', icon: 'chart', scope: 'dataset', featureFlag: '!stats', autoSynthesize: true },
|
|
36
|
+
{ type: 'sources', route: 'sources', title: 'Sources', icon: 'database', scope: 'dataset', autoSynthesize: true },
|
|
37
|
+
{ type: 'about', route: 'about', title: 'About', icon: 'info', scope: 'dataset', featureFlag: '!about', autoSynthesize: true },
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
export type BuiltinPageType = typeof PAGE_TYPES[number]['type'];
|
|
41
|
+
|
|
42
|
+
const PAGE_TYPE_INDEX: ReadonlyMap<string, PageTypeDefinition> = new Map(
|
|
43
|
+
PAGE_TYPES.map(p => [p.type, p]),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
export function pageTypeOf(type: string): PageTypeDefinition | undefined {
|
|
47
|
+
return PAGE_TYPE_INDEX.get(type);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function featureEnabled(features: Record<string, unknown> | undefined, flag: string): boolean {
|
|
51
|
+
if (flag.startsWith('!')) {
|
|
52
|
+
return features?.[flag.slice(1)] !== false;
|
|
53
|
+
}
|
|
54
|
+
return Boolean(features?.[flag]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Synthesize the auto-on pages for a given scope, respecting feature flags
|
|
58
|
+
* and skipping routes already declared by the user. */
|
|
59
|
+
export function synthesizePages(
|
|
60
|
+
scope: 'global' | 'dataset',
|
|
61
|
+
features: Record<string, unknown> | undefined,
|
|
62
|
+
declaredRoutes: ReadonlySet<string>,
|
|
63
|
+
): PageTypeDefinition[] {
|
|
64
|
+
return PAGE_TYPES.filter(p => p.scope === scope && p.autoSynthesize)
|
|
65
|
+
.filter(p => !p.featureFlag || featureEnabled(features, p.featureFlag))
|
|
66
|
+
.filter(p => !declaredRoutes.has(p.route));
|
|
67
|
+
}
|
package/src/config/types.ts
CHANGED
|
@@ -137,7 +137,12 @@ export const FORMAT_LABELS: Record<string, string> = {
|
|
|
137
137
|
|
|
138
138
|
// === Pages ===
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
/**
|
|
141
|
+
* Page type tag. Any string is allowed — built-in tags are listed in
|
|
142
|
+
* `page-types.ts` PAGE_TYPES registry. Adding a new built-in type is
|
|
143
|
+
* a registry entry, not an edit to this union.
|
|
144
|
+
*/
|
|
145
|
+
export type PageType = string;
|
|
141
146
|
|
|
142
147
|
export interface PageConfig {
|
|
143
148
|
type: PageType;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ref, computed } from 'vue';
|
|
2
2
|
import type { PageConfig, SiteColors } from './types';
|
|
3
3
|
import type { DatasetGroup } from './types';
|
|
4
|
+
import { synthesizePages } from './page-types';
|
|
4
5
|
import { locale } from '../i18n';
|
|
5
6
|
|
|
6
7
|
export interface RuntimeSiteConfig {
|
|
@@ -128,43 +129,26 @@ function synthesizeGlobalPages(features?: Record<string, unknown>, pages?: PageC
|
|
|
128
129
|
const declared = pages?.filter(p => !p.datasetScoped) ?? [];
|
|
129
130
|
const declaredRoutes = new Set(declared.map(p => p.route));
|
|
130
131
|
|
|
131
|
-
const
|
|
132
|
-
{ type:
|
|
133
|
-
];
|
|
134
|
-
if (features?.search !== false && !declaredRoutes.has('search')) {
|
|
135
|
-
result.push({ type: 'custom', route: 'search', title: 'Search', icon: 'search' });
|
|
136
|
-
}
|
|
137
|
-
if (features?.graph !== false && !declaredRoutes.has('graph')) {
|
|
138
|
-
result.push({ type: 'custom', route: 'graph', title: 'Graph', icon: 'graph' });
|
|
139
|
-
}
|
|
140
|
-
if (features?.ontology !== false && !declaredRoutes.has('ontology')) {
|
|
141
|
-
result.push({ type: 'custom', route: 'ontology', title: 'Ontology', icon: 'schema' });
|
|
142
|
-
}
|
|
143
|
-
if (features?.news && !declaredRoutes.has('news')) {
|
|
144
|
-
result.push({ type: 'news', route: 'news', title: 'News', icon: 'newspaper' });
|
|
145
|
-
}
|
|
132
|
+
const synthesized = synthesizePages('global', features, declaredRoutes)
|
|
133
|
+
.map(p => ({ type: p.type, route: p.route, title: p.title, icon: p.icon }));
|
|
146
134
|
|
|
147
|
-
return [...
|
|
135
|
+
return [...synthesized, ...declared];
|
|
148
136
|
}
|
|
149
137
|
|
|
150
138
|
function synthesizeDatasetPages(features?: Record<string, unknown>, pages?: PageConfig[]): PageConfig[] {
|
|
151
139
|
const declared = pages?.filter(p => p.datasetScoped) ?? [];
|
|
152
140
|
const declaredRoutes = new Set(declared.map(p => p.route));
|
|
153
141
|
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
if (features?.about !== false && !declaredRoutes.has('about')) {
|
|
164
|
-
result.push({ type: 'about', route: 'about', title: 'About', icon: 'info', datasetScoped: true });
|
|
165
|
-
}
|
|
142
|
+
const synthesized = synthesizePages('dataset', features, declaredRoutes)
|
|
143
|
+
.map(p => ({
|
|
144
|
+
type: p.type,
|
|
145
|
+
route: p.route,
|
|
146
|
+
title: p.title,
|
|
147
|
+
icon: p.icon,
|
|
148
|
+
datasetScoped: true as const,
|
|
149
|
+
}));
|
|
166
150
|
|
|
167
|
-
return [...
|
|
151
|
+
return [...synthesized, ...declared];
|
|
168
152
|
}
|
|
169
153
|
|
|
170
154
|
export function useSiteConfig() {
|