@glossarist/concept-browser 0.7.78 → 0.7.81

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 (57) hide show
  1. package/package.json +2 -2
  2. package/scripts/build-edges.js +109 -39
  3. package/scripts/generate-data.mjs +63 -27
  4. package/src/__tests__/build-edges-redundancy.test.ts +101 -0
  5. package/src/__tests__/composables/use-concept-diff.test.ts +240 -0
  6. package/src/__tests__/composables/use-concept-edges-v2.test.ts +132 -0
  7. package/src/__tests__/composables/use-dataset-stats.test.ts +96 -0
  8. package/src/__tests__/config/page-types.test.ts +82 -0
  9. package/src/__tests__/model-bridge-bridges.test.ts +3 -2
  10. package/src/__tests__/model-bridge-citation.test.ts +1 -1
  11. package/src/__tests__/ontology-registry.test.ts +2 -2
  12. package/src/__tests__/partitive-relation-styling.test.ts +82 -0
  13. package/src/__tests__/partitive-relation.test.ts +285 -0
  14. package/src/adapters/GraphDataSource.ts +16 -6
  15. package/src/adapters/dataset-stats.ts +13 -0
  16. package/src/adapters/non-verbal/glossarist-augment.d.ts +155 -4
  17. package/src/adapters/types.ts +27 -17
  18. package/src/components/AppSidebar.vue +14 -57
  19. package/src/components/ConceptDetail.vue +29 -0
  20. package/src/components/ConceptDiffModal.vue +120 -0
  21. package/src/components/ConceptDiffView.vue +192 -0
  22. package/src/components/ConceptEditionRail.vue +61 -36
  23. package/src/components/PartitiveRelationList.vue +139 -0
  24. package/src/components/groups/DatasetGroupRenderer.vue +14 -4
  25. package/src/components/groups/DefaultGroupSidebar.vue +28 -11
  26. package/src/components/groups/LineageGroupSidebar.vue +17 -29
  27. package/src/composables/use-concept-diff.ts +231 -0
  28. package/src/composables/use-concept-edges.ts +66 -45
  29. package/src/composables/use-dataset-stats.ts +52 -0
  30. package/src/config/page-types.ts +67 -0
  31. package/src/config/types.ts +6 -1
  32. package/src/config/use-site-config.ts +13 -29
  33. package/src/data/taxonomies.json +24 -1
  34. package/src/i18n/locales/eng.yml +6 -0
  35. package/src/main.ts +1 -1
  36. package/src/style.css +1 -1
  37. package/src/utils/partitive-relation-styling.ts +62 -0
  38. package/src/views/DatasetView.vue +5 -3
  39. package/src/views/StatsView.vue +9 -22
  40. package/src/__tests__/hyperedge-styling.test.ts +0 -57
  41. package/src/__tests__/partitive-hyperedge.test.ts +0 -199
  42. package/src/components/PartitiveHyperedgeList.vue +0 -112
  43. package/src/islands/BibliographyIsland.vue +0 -26
  44. package/src/islands/ConceptDetailIsland.vue +0 -27
  45. package/src/islands/ConceptDiffIsland.vue +0 -214
  46. package/src/islands/ConceptIsland.vue +0 -102
  47. package/src/islands/GraphIsland.vue +0 -15
  48. package/src/islands/GroupRendererIsland.vue +0 -15
  49. package/src/islands/HeaderIsland.vue +0 -10
  50. package/src/islands/NonVerbalIsland.vue +0 -75
  51. package/src/islands/RelationSphereIsland.vue +0 -20
  52. package/src/islands/SearchIsland.vue +0 -23
  53. package/src/islands/SeriesCardIsland.vue +0 -12
  54. package/src/islands/ShortcutsIsland.vue +0 -13
  55. package/src/islands/SidebarIsland.vue +0 -15
  56. package/src/islands/SupersessionDiffIsland.vue +0 -267
  57. package/src/utils/hyperedge-styling.ts +0 -48
@@ -1,7 +1,20 @@
1
1
  import { computed, type ComputedRef } from 'vue';
2
2
  import type { Router } from 'vue-router';
3
- import type { Concept, PartitiveHyperedge as GlsPartitiveHyperedge, RelatedConcept } from 'glossarist';
4
- import type { Manifest, GraphEdge, PartitiveHyperedge } from '../adapters/types';
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';
7
20
  import { UriRouter } from '../adapters/UriRouter';
@@ -114,63 +127,71 @@ export function useConceptEdges(
114
127
  });
115
128
  return [...direct, ...derived].filter(edge => {
116
129
  if (edge.type === 'broader_partitive' || edge.type === 'narrower_partitive') {
117
- const heUris = conceptPartitiveHyperedges.value;
118
- return !heUris.some(he => he.parts.includes(edge.ref as string) || he.comprehensive === edge.ref);
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
+ );
119
137
  }
120
138
  return true;
121
139
  });
122
140
  });
123
141
 
124
- const VALID_MARKERS = new Set(['double', 'dashed']);
125
-
126
- function validateHyperedgeMarkers(markers: Iterable<string>): ('double' | 'dashed')[] {
127
- const out: ('double' | 'dashed')[] = [];
128
- for (const m of markers) {
129
- if (!VALID_MARKERS.has(m)) {
130
- throw new Error(`Invalid partitive hyperedge marker: "${m}". Allowed: double, dashed`);
131
- }
132
- out.push(m as 'double' | 'dashed');
133
- }
134
- return out;
135
- }
136
-
137
- function resolveLocalizedContent(content: unknown): string | undefined {
138
- if (content == null) return undefined;
139
- if (typeof content === 'string') return content;
140
- if (typeof content === 'object' && content !== null) {
141
- const obj = content as Record<string, string>;
142
- return obj[registerId.value] ?? obj.default ?? obj.eng ?? Object.values(obj)[0];
143
- }
144
- return undefined;
145
- }
146
-
147
- // Concept-level partitive hyperedges (one-to-many decompositions).
148
- // Each hyperedge is resolved to concrete target URIs for display.
149
- // Independent of binary `conceptRelated` — see TODO.hyperedge/00.
150
- const conceptPartitiveHyperedges = computed<PartitiveHyperedge[]>(() => {
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[]>(() => {
151
148
  const source = conceptUriValue.value;
152
- return (concept.value.partitiveHyperedges ?? [])
153
- .map((he: GlsPartitiveHyperedge): PartitiveHyperedge | null => {
154
- const comprehensive = resolveRefUri(he.comprehensive);
149
+ const relations: readonly GlsPartitiveRelation[] = concept.value.partitiveRelations;
150
+ return relations
151
+ .map((rel): PartitiveRelationWire | null => {
152
+ const comprehensive = resolveConceptRefUri(rel.comprehensive);
155
153
  if (!comprehensive) return null;
156
- const parts = he.parts
157
- .map(resolveRefUri)
158
- .filter((u): u is string => !!u && u !== source);
159
- if (parts.length === 0) 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
+
160
163
  return {
161
164
  source,
162
165
  comprehensive,
163
- parts,
164
- enumeration: he.isOpen ? 'open' : 'closed',
165
- markers: validateHyperedgeMarkers(he.markers),
166
- label: resolveLocalizedContent(he.content),
166
+ partitives,
167
+ completeness: rel.completeness,
168
+ plurality: projectPlurality(rel.plurality),
169
+ criterion: rel.criterion ?? undefined,
167
170
  register: registerId.value,
168
171
  };
169
172
  })
170
- .filter((he): he is PartitiveHyperedge => he !== null);
173
+ .filter((r: PartitiveRelationWire | null): r is PartitiveRelationWire => r !== null);
171
174
  });
172
175
 
173
- function resolveRefUri(ref: { source?: string | null; id?: string | null } | null): string | null {
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 {
174
195
  const target = resolveRelatedRef(ref);
175
196
  if (!target) return null;
176
197
  const m = store.manifests.get(target.registerId);
@@ -244,7 +265,7 @@ export function useConceptEdges(
244
265
  edgeBadgeColor,
245
266
  inverseEdgeType,
246
267
  conceptRelated,
247
- conceptPartitiveHyperedges,
268
+ conceptPartitiveRelations,
248
269
  resolveRelatedRef,
249
270
  getResolvedRef,
250
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
+ }
@@ -137,7 +137,12 @@ export const FORMAT_LABELS: Record<string, string> = {
137
137
 
138
138
  // === Pages ===
139
139
 
140
- export type PageType = 'page' | 'news' | 'contributors' | 'about' | 'stats' | 'custom';
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 result: PageConfig[] = [
132
- { type: 'custom', route: '', title: 'Home', icon: 'home' },
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 [...result, ...declared];
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 result: PageConfig[] = [
155
- { type: 'custom', route: '', title: 'Concepts', icon: 'list', datasetScoped: true },
156
- ];
157
- if (features?.stats !== false && !declaredRoutes.has('stats')) {
158
- result.push({ type: 'stats', route: 'stats', title: 'Statistics', icon: 'chart', datasetScoped: true });
159
- }
160
- if (!declaredRoutes.has('sources')) {
161
- result.push({ type: 'sources', route: 'sources', title: 'Sources', icon: 'database', datasetScoped: true });
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 [...result, ...declared];
151
+ return [...synthesized, ...declared];
168
152
  }
169
153
 
170
154
  export function useSiteConfig() {
@@ -45,6 +45,12 @@
45
45
  "iri": "gloss:status/retired",
46
46
  "prefLabel": "retired",
47
47
  "definition": "The concept has been retired from active use."
48
+ },
49
+ "external": {
50
+ "id": "external",
51
+ "iri": "gloss:status/external",
52
+ "prefLabel": "external",
53
+ "definition": "An external concept: referenced but not defined here. May be resolved later when another dataset is loaded (concept-model TODO.partitive-relation-v2 item 06)."
48
54
  }
49
55
  },
50
56
  "colors": {
@@ -54,7 +60,8 @@
54
60
  "not_valid": "bg-red-50 text-red-700",
55
61
  "invalid": "bg-red-50 text-red-700",
56
62
  "superseded": "bg-red-50 text-red-700",
57
- "retired": "badge-gray"
63
+ "retired": "badge-gray",
64
+ "external": "bg-purple-50 text-purple-700"
58
65
  }
59
66
  },
60
67
  "entryStatus": {
@@ -264,6 +271,22 @@
264
271
  "category": "lifecycle",
265
272
  "inverseOf": "supersedes"
266
273
  },
274
+ "provides": {
275
+ "id": "provides",
276
+ "iri": "gloss:rel/provides",
277
+ "prefLabel": "provides",
278
+ "definition": "This concept provides substance for an external concept (one we reference but don't define). Direction: subject → external. Per concept-model TODO.partitive-relation-v2 item 07.",
279
+ "category": "lifecycle",
280
+ "inverseOf": "provided_by"
281
+ },
282
+ "provided_by": {
283
+ "id": "provided_by",
284
+ "iri": "gloss:rel/provided_by",
285
+ "prefLabel": "provided by",
286
+ "definition": "This external concept is provided by (fulfilled by) the object concept. Inverse of provides.",
287
+ "category": "lifecycle",
288
+ "inverseOf": "provides"
289
+ },
267
290
  "compare": {
268
291
  "id": "compare",
269
292
  "iri": "gloss:rel/compare",
@@ -58,6 +58,12 @@ concept.example: Example
58
58
  concept.sources: Sources
59
59
  concept.designations: Designations
60
60
  concept.relations: Relations
61
+ concept.compareWith: Compare with this edition
62
+ concept.compareEditions: Compare editions
63
+ concept.partitiveRelations: Partitive relations
64
+ partitive.relations: Partitive relations
65
+ partitive.plurality.shared: shared type
66
+ partitive.plurality.uncertain: uncertain
61
67
  concept.classification: Classification
62
68
  concept.domains: Domains
63
69
  concept.tags: Tags
package/src/main.ts CHANGED
@@ -3,7 +3,7 @@ import { createPinia } from 'pinia';
3
3
  import App from './App.vue';
4
4
  import router from './router';
5
5
  import { vMath } from './directives/v-math';
6
- import './style.css';
6
+ import './styles/tailwind.css';
7
7
 
8
8
  const app = createApp(App);
9
9
  app.use(createPinia());
package/src/style.css CHANGED
@@ -492,7 +492,7 @@
492
492
 
493
493
  /* Section labels brighter in dark mode */
494
494
  .dark .section-label {
495
- color: theme('colors.ink.300') !important;
495
+ color: var(--color-ink-300) !important;
496
496
  }
497
497
 
498
498
  /* Scrollbar hide utility */
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Partitive relation styling — colors for one-to-many decompositions.
3
+ *
4
+ * Independent of relationship-categories.ts (which handles binary edges).
5
+ * Partitive relations have their own visual language:
6
+ * - completeness (complete/partial) affects opacity
7
+ * - plurality (type-shared, uncertain) adds accent badges
8
+ *
9
+ * v2 redesign: replaces v1 hyperedge-styling.ts per
10
+ * concept-model/TODO.partitive-relation-v2.
11
+ */
12
+
13
+ import type { TypeSharedPluralityWire } from '../adapters/types';
14
+
15
+ const BASE_LIGHT = '#0d9488';
16
+ const BASE_DARK = '#2dd4bf';
17
+ const ACCENT_SHARED_LIGHT = '#3b82f6';
18
+ const ACCENT_SHARED_DARK = '#60a5fa';
19
+ const ACCENT_UNCERTAIN_LIGHT = '#f59e0b';
20
+ const ACCENT_UNCERTAIN_DARK = '#fbbf24';
21
+
22
+ export interface PartitiveRelationStyle {
23
+ color: string;
24
+ badgeClass: string;
25
+ opacity: number;
26
+ }
27
+
28
+ export function partitiveRelationStyle(
29
+ completeness: 'complete' | 'partial',
30
+ plurality: TypeSharedPluralityWire | null,
31
+ isDark: boolean,
32
+ ): PartitiveRelationStyle {
33
+ const base = isDark ? BASE_DARK : BASE_LIGHT;
34
+ const opacity = completeness === 'partial' ? 0.6 : 1.0;
35
+
36
+ let badgeClass = 'badge-teal';
37
+ if (plurality?.isShared) {
38
+ badgeClass = 'badge-blue';
39
+ if (plurality.isUncertain) {
40
+ badgeClass = 'badge-yellow';
41
+ }
42
+ }
43
+
44
+ return { color: base, badgeClass, opacity };
45
+ }
46
+
47
+ export function pluralityColor(
48
+ plurality: TypeSharedPluralityWire,
49
+ isDark: boolean,
50
+ ): string {
51
+ if (plurality.isUncertain) return isDark ? ACCENT_UNCERTAIN_DARK : ACCENT_UNCERTAIN_LIGHT;
52
+ if (plurality.isShared) return isDark ? ACCENT_SHARED_DARK : ACCENT_SHARED_LIGHT;
53
+ return isDark ? BASE_DARK : BASE_LIGHT;
54
+ }
55
+
56
+ export function completenessLabel(completeness: 'complete' | 'partial'): string {
57
+ return completeness === 'partial' ? 'Partial' : 'Complete';
58
+ }
59
+
60
+ export function certaintyLabel(certainty: 'confirmed' | 'possible'): string {
61
+ return certainty === 'possible' ? 'Possible' : 'Confirmed';
62
+ }
@@ -5,6 +5,7 @@ import { useVocabularyStore } from '../stores/vocabulary';
5
5
  import { useUiStore } from '../stores/ui';
6
6
  import { useDsStyle } from '../utils/dataset-style';
7
7
  import { useDatasetLoader } from '../composables/use-dataset-loader';
8
+ import { useDatasetStats } from '../composables/use-dataset-stats';
8
9
  import { FORMAT_LABELS } from '../config/types';
9
10
  import { langName, langLabel, sortLanguages } from '../utils/lang';
10
11
  import ConceptCard from '../components/ConceptCard.vue';
@@ -21,6 +22,7 @@ const store = useVocabularyStore();
21
22
  const uiStore = useUiStore();
22
23
  const { getStyle } = useDsStyle();
23
24
  const { ensureLoaded, loading, localError } = useDatasetLoader(() => props.registerId);
25
+ const { stats: datasetStats } = useDatasetStats(() => props.registerId);
24
26
  const { t } = useI18n();
25
27
  const { localizedDatasetField } = useSiteConfig();
26
28
  const route = useRoute();
@@ -331,10 +333,10 @@ function clearSection() {
331
333
  <router-link :to="{ name: 'about', params: { registerId } }" class="badge badge-purple hover:opacity-80 transition-opacity">
332
334
  {{ t('nav.about') }}
333
335
  </router-link>
334
- <router-link v-if="manifest.sourceCount" :to="{ name: 'sources', params: { registerId } }" class="badge badge-gray hover:opacity-80 transition-opacity">
335
- {{ manifest.sourceCount }} {{ t('dataset.sources') }}
336
+ <router-link v-if="datasetStats?.sourceCount" :to="{ name: 'sources', params: { registerId } }" class="badge badge-gray hover:opacity-80 transition-opacity">
337
+ {{ datasetStats.sourceCount }} {{ t('dataset.sources') }}
336
338
  </router-link>
337
- <span v-if="manifest.relationshipCount" class="badge badge-yellow">{{ manifest.relationshipCount.toLocaleString() }} {{ t('dataset.relationships') }}</span>
339
+ <span v-if="datasetStats?.relationshipCount" class="badge badge-yellow">{{ datasetStats.relationshipCount.toLocaleString() }} {{ t('dataset.relationships') }}</span>
338
340
  </div>
339
341
  </div>
340
342
 
@@ -1,8 +1,9 @@
1
1
  <script setup lang="ts">
2
- import { ref, computed, watch } from 'vue';
2
+ import { computed } from 'vue';
3
3
  import { useVocabularyStore } from '../stores/vocabulary';
4
4
  import { useDsStyle } from '../utils/dataset-style';
5
5
  import { useDatasetLoader } from '../composables/use-dataset-loader';
6
+ import { useDatasetStats } from '../composables/use-dataset-stats';
6
7
  import { langName, langLabel } from '../utils/lang';
7
8
  import { useI18n } from '../i18n';
8
9
 
@@ -11,6 +12,7 @@ const props = defineProps<{ registerId?: string }>();
11
12
  const store = useVocabularyStore();
12
13
  const { getColor } = useDsStyle();
13
14
  const { loading, localError, ensureLoaded, resolvedId } = useDatasetLoader(() => props.registerId);
15
+ const { stats: statsData, reload: reloadStats } = useDatasetStats(resolvedId);
14
16
  const { t } = useI18n();
15
17
 
16
18
  const manifest = computed(() => store.manifests.get(resolvedId.value));
@@ -54,26 +56,6 @@ function coverageColor(ratio: number): string {
54
56
  return 'bg-red-400';
55
57
  }
56
58
 
57
- interface StatsData {
58
- sourceCount: number;
59
- sources: Array<{ ref: string; types: string[]; conceptCount: number }>;
60
- relationshipCount: number;
61
- relationshipTypes: Record<string, number>;
62
- }
63
-
64
- const statsData = ref<StatsData | null>(null);
65
-
66
- async function loadStats() {
67
- if (!resolvedId.value) return;
68
- const base = import.meta.env.BASE_URL;
69
- try {
70
- const resp = await fetch(`${base}data/${resolvedId.value}/stats.json`);
71
- if (resp.ok) statsData.value = await resp.json();
72
- } catch {}
73
- }
74
-
75
- watch(() => resolvedId.value, () => { loadStats(); }, { immediate: true });
76
-
77
59
  const relEntries = computed(() => {
78
60
  if (!statsData.value?.relationshipTypes) return [];
79
61
  return Object.entries(statsData.value.relationshipTypes)
@@ -92,6 +74,11 @@ function sourceTypeBadge(type: string): string {
92
74
  if (type === 'lineage') return 'badge-yellow';
93
75
  return 'badge-gray';
94
76
  }
77
+
78
+ async function reloadAll() {
79
+ await ensureLoaded();
80
+ reloadStats();
81
+ }
95
82
  </script>
96
83
 
97
84
  <template>
@@ -115,7 +102,7 @@ function sourceTypeBadge(type: string): string {
115
102
  <div class="card p-8 border-red-200 bg-red-50/50 text-center">
116
103
  <p class="text-red-700 font-medium mb-1">{{ t('stats.failedToLoad') }}</p>
117
104
  <p class="text-sm text-red-600/80 mb-4">{{ localError }}</p>
118
- <button @click="ensureLoaded" class="btn-primary">{{ t('stats.retry') }}</button>
105
+ <button @click="reloadAll" class="btn-primary">{{ t('stats.retry') }}</button>
119
106
  </div>
120
107
  </template>
121
108
  <template v-else-if="manifest">
@@ -1,57 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { hyperedgeStyle, markerColor, enumerationLabel } from '../utils/hyperedge-styling';
3
-
4
- describe('hyperedge-styling', () => {
5
- describe('hyperedgeStyle', () => {
6
- it('returns full opacity for closed enumeration', () => {
7
- expect(hyperedgeStyle('closed', [], false).opacity).toBe(1.0);
8
- });
9
-
10
- it('returns reduced opacity for open enumeration', () => {
11
- expect(hyperedgeStyle('open', [], false).opacity).toBe(0.6);
12
- });
13
-
14
- it('uses teal badge class for closed with no markers', () => {
15
- expect(hyperedgeStyle('closed', [], false).badgeClass).toBe('badge-teal');
16
- });
17
-
18
- it('uses blue badge class for double marker', () => {
19
- expect(hyperedgeStyle('closed', ['double'], false).badgeClass).toBe('badge-blue');
20
- });
21
-
22
- it('uses yellow badge class for dashed marker', () => {
23
- expect(hyperedgeStyle('closed', ['dashed'], false).badgeClass).toBe('badge-yellow');
24
- });
25
-
26
- it('returns different colors in dark mode', () => {
27
- const light = hyperedgeStyle('closed', [], false);
28
- const dark = hyperedgeStyle('closed', [], true);
29
- expect(light.color).not.toBe(dark.color);
30
- });
31
- });
32
-
33
- describe('markerColor', () => {
34
- it('returns blue for double marker', () => {
35
- expect(markerColor('double', false)).toBe('#3b82f6');
36
- });
37
-
38
- it('returns amber for dashed marker', () => {
39
- expect(markerColor('dashed', false)).toBe('#f59e0b');
40
- });
41
-
42
- it('returns dark-mode variants', () => {
43
- expect(markerColor('double', true)).toBe('#60a5fa');
44
- expect(markerColor('dashed', true)).toBe('#fbbf24');
45
- });
46
- });
47
-
48
- describe('enumerationLabel', () => {
49
- it('labels closed as "Closed (complete)"', () => {
50
- expect(enumerationLabel('closed')).toBe('Closed (complete)');
51
- });
52
-
53
- it('labels open as "Open (partial)"', () => {
54
- expect(enumerationLabel('open')).toBe('Open (partial)');
55
- });
56
- });
57
- });