@glossarist/concept-browser 0.7.43 → 0.7.45

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 (60) hide show
  1. package/cli/index.mjs +12 -13
  2. package/package.json +3 -2
  3. package/scripts/__tests__/fetch-datasets.test.mjs +105 -0
  4. package/scripts/fetch-datasets.mjs +53 -51
  5. package/scripts/generate-data.mjs +41 -19
  6. package/scripts/lib/build/image-assets.mjs +190 -0
  7. package/scripts/lib/build/non-verbal-consumer.mjs +221 -0
  8. package/scripts/lib/local-path-safety.mjs +68 -0
  9. package/src/__tests__/bibliography-adapter.test.ts +79 -0
  10. package/src/__tests__/content-renderer-nvr-mentions.test.ts +57 -0
  11. package/src/__tests__/locale.test.ts +46 -0
  12. package/src/__tests__/model-bridge-entity-refs.test.ts +114 -0
  13. package/src/__tests__/non-verbal-anchor.test.ts +33 -0
  14. package/src/__tests__/non-verbal-cross-ref.test.ts +146 -0
  15. package/src/__tests__/non-verbal-highlight.test.ts +56 -0
  16. package/src/__tests__/non-verbal-kind.test.ts +77 -0
  17. package/src/__tests__/non-verbal-list.test.ts +67 -0
  18. package/src/__tests__/non-verbal-rep-display.test.ts +85 -0
  19. package/src/__tests__/non-verbal-scroll-guard.test.ts +116 -0
  20. package/src/__tests__/use-concept-entities.test.ts +76 -0
  21. package/src/adapters/bibliography-adapter.ts +49 -0
  22. package/src/adapters/factory.ts +14 -0
  23. package/src/adapters/model-bridge.ts +51 -0
  24. package/src/adapters/non-verbal/figure-bridge.ts +101 -0
  25. package/src/adapters/non-verbal/formula-bridge.ts +48 -0
  26. package/src/adapters/non-verbal/index.ts +55 -0
  27. package/src/adapters/non-verbal/kind.ts +46 -0
  28. package/src/adapters/non-verbal/prefix.ts +67 -0
  29. package/src/adapters/non-verbal/source-bridge.ts +81 -0
  30. package/src/adapters/non-verbal/table-bridge.ts +98 -0
  31. package/src/adapters/non-verbal/types.ts +133 -0
  32. package/src/adapters/non-verbal-resolver.ts +101 -0
  33. package/src/components/ConceptDetail.vue +17 -4
  34. package/src/components/LanguageDetail.vue +0 -3
  35. package/src/components/NonVerbalRepDisplay.vue +82 -24
  36. package/src/components/figure/FigureDisplay.vue +132 -0
  37. package/src/components/figure/FigureImages.vue +111 -0
  38. package/src/components/figure/figure-image-pick.ts +56 -0
  39. package/src/components/figure/figure-layout.ts +26 -0
  40. package/src/components/formula/FormulaDisplay.vue +90 -0
  41. package/src/components/formula/FormulaExpression.vue +70 -0
  42. package/src/components/non-verbal/NonVerbalCaption.vue +104 -0
  43. package/src/components/non-verbal/NonVerbalFallback.vue +69 -0
  44. package/src/components/non-verbal/NonVerbalList.vue +118 -0
  45. package/src/components/non-verbal/NonVerbalSources.vue +61 -0
  46. package/src/components/table/TableDisplay.vue +99 -0
  47. package/src/components/table/TableMarkup.vue +63 -0
  48. package/src/components/table/TableStructured.vue +66 -0
  49. package/src/composables/use-concept-entities.ts +70 -0
  50. package/src/composables/use-non-verbal-cross-ref.ts +79 -0
  51. package/src/composables/use-non-verbal-entity.ts +58 -0
  52. package/src/composables/use-reduced-motion.ts +26 -0
  53. package/src/composables/use-render-options.ts +30 -33
  54. package/src/router/index.ts +3 -0
  55. package/src/router/non-verbal-scroll-guard.ts +56 -0
  56. package/src/style.css +17 -0
  57. package/src/utils/content-renderer.ts +76 -64
  58. package/src/utils/locale.ts +92 -0
  59. package/src/utils/non-verbal-anchor.ts +51 -0
  60. package/src/utils/non-verbal-highlight.ts +27 -0
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Non-verbal entity model — TypeScript projection of the authoritative
3
+ * glossarist-ruby model.
4
+ *
5
+ * The authoritative model lives in glossarist-ruby (Figure, Table, Formula
6
+ * inherit from NonVerbalEntity). This file mirrors that model in TypeScript
7
+ * for the consumer side. It does not redefine the model — every field here
8
+ * corresponds to a field in the authoritative source.
9
+ *
10
+ * See:
11
+ * ../glossarist-ruby/lib/glossarist/non_verbal_entity.rb
12
+ * ../glossarist-ruby/lib/glossarist/figure.rb
13
+ * ../glossarist-ruby/lib/glossarist/table.rb
14
+ * ../glossarist-ruby/lib/glossarist/formula.rb
15
+ * ../glossarist-ruby/lib/glossarist/figure_image.rb
16
+ */
17
+
18
+ export type LocalizedString = Record<string, string>;
19
+
20
+ export type NonVerbalKind = 'figure' | 'table' | 'formula';
21
+
22
+ export type FigureImageFormat = 'svg' | 'png' | 'jpg' | 'jpeg' | 'gif' | 'webp' | 'avif';
23
+
24
+ export type FigureImageRole = 'vector' | 'raster' | 'dark' | 'light' | 'print';
25
+
26
+ export interface FigureImage {
27
+ src: string;
28
+ format: FigureImageFormat;
29
+ role?: FigureImageRole;
30
+ width?: number;
31
+ height?: number;
32
+ scale?: number;
33
+ }
34
+
35
+ export interface NonVerbalSourceRef {
36
+ source?: string;
37
+ id?: string;
38
+ version?: string;
39
+ text?: string;
40
+ }
41
+
42
+ export interface NonVerbalSourceLocality {
43
+ type?: string;
44
+ referenceFrom?: string;
45
+ referenceTo?: string;
46
+ }
47
+
48
+ export interface NonVerbalSourceOrigin {
49
+ ref?: NonVerbalSourceRef;
50
+ locality?: NonVerbalSourceLocality;
51
+ link?: string;
52
+ id?: string;
53
+ version?: string;
54
+ source?: string;
55
+ }
56
+
57
+ export interface NonVerbalSource {
58
+ id?: string;
59
+ type?: string;
60
+ status?: string;
61
+ modification?: string;
62
+ origin?: NonVerbalSourceOrigin;
63
+ }
64
+
65
+ export interface NonVerbalEntityBase {
66
+ id: string;
67
+ identifier?: string;
68
+ caption?: LocalizedString;
69
+ description?: LocalizedString;
70
+ alt?: LocalizedString;
71
+ sources?: NonVerbalSource[];
72
+ }
73
+
74
+ export interface Figure extends NonVerbalEntityBase {
75
+ kind: 'figure';
76
+ images: FigureImage[];
77
+ subfigures?: Figure[];
78
+ }
79
+
80
+ export type TableFormat = 'html' | 'markdown' | 'asciidoc';
81
+
82
+ export type TableContent =
83
+ | { kind: 'structured'; headers: LocalizedString[]; rows: LocalizedString[][] }
84
+ | { kind: 'markup'; markup: LocalizedString };
85
+
86
+ export interface Table extends NonVerbalEntityBase {
87
+ kind: 'table';
88
+ content: TableContent;
89
+ format?: TableFormat;
90
+ }
91
+
92
+ export type FormulaNotation = 'latex' | 'mathml' | 'asciimath';
93
+
94
+ export interface Formula extends NonVerbalEntityBase {
95
+ kind: 'formula';
96
+ expression: LocalizedString;
97
+ notation: FormulaNotation;
98
+ }
99
+
100
+ export type NonVerbalEntity = Figure | Table | Formula;
101
+
102
+ /**
103
+ * V3 NonVerbRep runtime shape.
104
+ *
105
+ * glossarist-js's runtime `NonVerbRep` (post-V3 reshape) holds the same
106
+ * localized fields as `NonVerbalEntityBase` plus a `type` discriminator
107
+ * and an `images[]` array. The published `.d.ts` still describes the
108
+ * pre-V3 `ref`/`text` shape; this local interface lets consumer code
109
+ * type-check against reality. Remove when upstream ships a corrected
110
+ * `.d.ts` (TODO.figures/19).
111
+ */
112
+ export interface NonVerbRepV3 extends NonVerbalEntityBase {
113
+ type: string | null;
114
+ images: FigureImage[];
115
+ }
116
+
117
+ export interface NonVerbalReference {
118
+ kind: NonVerbalKind;
119
+ entityId: string;
120
+ display?: string;
121
+ }
122
+
123
+ export function isFigure(entity: NonVerbalEntity): entity is Figure {
124
+ return entity.kind === 'figure';
125
+ }
126
+
127
+ export function isTable(entity: NonVerbalEntity): entity is Table {
128
+ return entity.kind === 'table';
129
+ }
130
+
131
+ export function isFormula(entity: NonVerbalEntity): entity is Formula {
132
+ return entity.kind === 'formula';
133
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * NonVerbalEntityResolver — the single runtime access point for non-verbal
3
+ * entities.
4
+ *
5
+ * Responsibilities:
6
+ * - Fetch entity JSON-LD from `public/data/{ds}/{kind-dir}/{id}.json`.
7
+ * - Bridge to the TS model via `KIND_TO_BRIDGE`.
8
+ * - Cache per `(kind, datasetId, id)` with Promise dedup for concurrent
9
+ * callers.
10
+ * - Compute image URLs (basePath-aware for GitHub Pages).
11
+ * - Compute stable anchor IDs via the anchor SSOT.
12
+ *
13
+ * Components and the content-renderer go through this resolver; they never
14
+ * call `fetch()` directly. The resolver is owned by `AdapterFactory` —
15
+ * exactly one instance per app.
16
+ */
17
+
18
+ import type { NonVerbalEntity, NonVerbalKind } from './non-verbal/types';
19
+ import { KIND_TO_DIR, KIND_TO_BRIDGE } from './non-verbal/kind';
20
+ import { anchorId } from '../utils/non-verbal-anchor';
21
+
22
+ export type { NonVerbalEntity, NonVerbalKind } from './non-verbal/types';
23
+
24
+ export interface NonVerbalEntityResolverOptions {
25
+ basePath?: string;
26
+ fetcher?: (url: string) => Promise<Response>;
27
+ }
28
+
29
+ interface CacheEntry {
30
+ promise: Promise<NonVerbalEntity | null>;
31
+ resolved: NonVerbalEntity | null | undefined;
32
+ }
33
+
34
+ export class NonVerbalEntityResolver {
35
+ private readonly cache = new Map<string, CacheEntry>();
36
+ private readonly basePath: string;
37
+ private readonly fetcher: (url: string) => Promise<Response>;
38
+
39
+ constructor(opts: NonVerbalEntityResolverOptions = {}) {
40
+ this.basePath = opts.basePath ?? import.meta.env.BASE_URL ?? '/';
41
+ this.fetcher = opts.fetcher ?? ((url: string) => fetch(url));
42
+ }
43
+
44
+ resolve(kind: NonVerbalKind, datasetId: string, entityId: string): Promise<NonVerbalEntity | null> {
45
+ const key = `${kind}|${datasetId}|${entityId}`;
46
+ const existing = this.cache.get(key);
47
+ if (existing) return existing.promise;
48
+
49
+ const promise = (async () => {
50
+ const dir = KIND_TO_DIR[kind];
51
+ const url = `${this.basePath}data/${datasetId}/${dir}/${entityId}.json`;
52
+ const resp = await this.fetcher(url);
53
+ if (resp.status === 404) return null;
54
+ if (!resp.ok) {
55
+ throw new Error(`Failed to load ${kind} ${entityId} from ${datasetId}: ${resp.status}`);
56
+ }
57
+ const doc = (await resp.json()) as Record<string, unknown>;
58
+ const entity = KIND_TO_BRIDGE[kind](doc);
59
+ return entity;
60
+ })();
61
+
62
+ const entry: CacheEntry = { promise, resolved: undefined };
63
+ promise.then(
64
+ v => { entry.resolved = v; return v; },
65
+ () => { this.cache.delete(key); return null; },
66
+ );
67
+ this.cache.set(key, entry);
68
+ return promise;
69
+ }
70
+
71
+ /**
72
+ * Synchronous peek at the cache. Returns the entity if the previous
73
+ * `resolve` already completed, otherwise `undefined`. Useful for SSR or
74
+ * pre-hydration checks where the cache is warm.
75
+ */
76
+ peek(kind: NonVerbalKind, datasetId: string, entityId: string): NonVerbalEntity | null | undefined {
77
+ return this.cache.get(`${kind}|${datasetId}|${entityId}`)?.resolved;
78
+ }
79
+
80
+ resolveImageUrl(datasetId: string, src: string): string {
81
+ const cleanSrc = src.startsWith('images/') ? src.slice('images/'.length) : src;
82
+ return `${this.basePath}data/${datasetId}/images/${cleanSrc}`;
83
+ }
84
+
85
+ anchor(kind: NonVerbalKind, datasetId: string, entityId: string): string {
86
+ return anchorId(kind, datasetId, entityId);
87
+ }
88
+
89
+ /**
90
+ * Drop the cached entry for one entity. Locale switching does NOT need
91
+ * this — the cached entity is the raw JSON-LD, and localization happens
92
+ * at render time via the locale SSOT.
93
+ */
94
+ invalidate(kind: NonVerbalKind, datasetId: string, entityId: string): void {
95
+ this.cache.delete(`${kind}|${datasetId}|${entityId}`);
96
+ }
97
+
98
+ clear(): void {
99
+ this.cache.clear();
100
+ }
101
+ }
@@ -16,6 +16,8 @@ import { getFactory } from '../adapters/factory';
16
16
  import { useRenderOptions } from '../composables/use-render-options';
17
17
  import { useConceptEdges } from '../composables/use-concept-edges';
18
18
  import { useConceptContent } from '../composables/use-concept-content';
19
+ import { useConceptEntities } from '../composables/use-concept-entities';
20
+ import { useNonVerbalCrossRef } from '../composables/use-non-verbal-cross-ref';
19
21
  import { relationshipLabel, INVERSE_RELATIONSHIPS } from '../utils/relationship-categories';
20
22
  import { slugify } from '../utils/slugify';
21
23
  import { useSiteConfig } from '../config/use-site-config';
@@ -23,6 +25,7 @@ import ConceptTimeline from './ConceptTimeline.vue';
23
25
  import ConceptRdfView from './ConceptRdfView.vue';
24
26
  import FormatDownloads from './FormatDownloads.vue';
25
27
  import NonVerbalRepDisplay from './NonVerbalRepDisplay.vue';
28
+ import NonVerbalList from './non-verbal/NonVerbalList.vue';
26
29
  import CitationDisplay from './CitationDisplay.vue';
27
30
  import DesignationList from './DesignationList.vue';
28
31
  import { useI18n } from '../i18n';
@@ -103,7 +106,7 @@ const conceptSources = computed(() => props.concept.sources);
103
106
  const conceptTags = computed(() => props.concept.tags ?? []);
104
107
 
105
108
  const factory = getFactory();
106
- const { ensureBibLoaded, bibResolver, figResolver } = useRenderOptions(() => props.registerId);
109
+ const { ensureBibLoaded, bibResolver, nonVerbalRefResolver } = useRenderOptions(() => props.registerId);
107
110
 
108
111
  const renderOpts = computed<RenderOptions>(() => ({
109
112
  xrefResolver: (uri, term) => {
@@ -125,11 +128,18 @@ const renderOpts = computed<RenderOptions>(() => ({
125
128
  return `<a href="#" class="xref-link" data-register="${escapeAttr(props.registerId)}" data-concept="${escapeAttr(resolvedId)}">${escapeAttr(term)}</a>`;
126
129
  },
127
130
  bibResolver,
128
- figResolver,
131
+ nonVerbalRefResolver,
129
132
  }));
130
133
 
131
134
  watch(() => props.registerId, () => { ensureBibLoaded(); }, { immediate: true });
132
135
 
136
+ const structuralEntityRefs = useConceptEntities(
137
+ computed(() => props.concept),
138
+ computed(() => props.registerId),
139
+ );
140
+
141
+ useNonVerbalCrossRef();
142
+
133
143
  function handleContentClick(e: MouseEvent) {
134
144
  const target = (e.target as HTMLElement).closest('.xref-link') as HTMLElement | null;
135
145
  if (!target) return;
@@ -416,7 +426,7 @@ const nonVerbalReps = computed(() => {
416
426
  </div>
417
427
 
418
428
  <!-- Non-verbal representations -->
419
- <NonVerbalRepDisplay v-if="lc.lc.nonVerbalRep?.length" :reps="lc.lc.nonVerbalRep" />
429
+ <NonVerbalRepDisplay v-if="lc.lc.nonVerbalRep?.length" :reps="lc.lc.nonVerbalRep" :locale="lc.lang" :register-id="registerId" :dataset-locales="languages" />
420
430
 
421
431
  <!-- Sources -->
422
432
  <div v-if="lc.sources.length" class="space-y-2">
@@ -467,7 +477,10 @@ const nonVerbalReps = computed(() => {
467
477
  </div>
468
478
 
469
479
  <!-- Non-verbal reps (concept-level) -->
470
- <NonVerbalRepDisplay v-if="nonVerbalReps.length" :reps="nonVerbalReps" />
480
+ <NonVerbalRepDisplay v-if="nonVerbalReps.length" :reps="nonVerbalReps" :locale="languages[0] ?? 'eng'" :register-id="registerId" :dataset-locales="languages" />
481
+
482
+ <!-- Structural entity refs (concept-level figures/tables/formulas) -->
483
+ <NonVerbalList v-if="structuralEntityRefs.length" :refs="structuralEntityRefs" />
471
484
  </div>
472
485
 
473
486
  <!-- Right sidebar -->
@@ -63,9 +63,6 @@ const renderOpts: RenderOptions = {
63
63
  bibResolver: (refId, title) => {
64
64
  return `<span class="bib-ref">${escapeAttr(title)}</span>`;
65
65
  },
66
- figResolver: (figId) => {
67
- return `<span class="fig-ref">${escapeAttr(figId)}</span>`;
68
- },
69
66
  };
70
67
 
71
68
  function handleContentClick(e: MouseEvent) {
@@ -1,38 +1,96 @@
1
1
  <script setup lang="ts">
2
- import type { NonVerbRep, ConceptSource } from 'glossarist';
3
- import { sourceStatusInfo, sourceTypeInfo } from '../utils/designation-registry';
2
+ import { computed } from 'vue';
3
+ import type { NonVerbRep, Citation } from 'glossarist';
4
+ import type { FigureImage, LocalizedString, NonVerbRepV3, NonVerbalSource } from '../adapters/non-verbal/types';
5
+ import { resolveFallbackChain } from '../utils/locale';
6
+ import FigureImages from './figure/FigureImages.vue';
7
+ import NonVerbalCaption from './non-verbal/NonVerbalCaption.vue';
8
+ import CitationDisplay from './CitationDisplay.vue';
4
9
 
5
- defineProps<{
10
+ const props = defineProps<{
6
11
  reps: NonVerbRep[];
12
+ locale: string;
13
+ registerId: string;
14
+ datasetLocales?: readonly string[];
7
15
  }>();
16
+
17
+ const fallbackChain = computed(() => resolveFallbackChain(props.datasetLocales));
18
+
19
+ // Cast once at the boundary: glossarist-js's published `.d.ts` still
20
+ // describes pre-V3 NonVerbRep, but the runtime exposes the V3 shape
21
+ // (images/alt/caption/description/sources). See TODO.figures/19.
22
+ const v3Reps = computed<NonVerbRepV3[]>(() => props.reps as unknown as NonVerbRepV3[]);
23
+
24
+ function imagesOf(rep: NonVerbRepV3): FigureImage[] {
25
+ return rep.images ?? [];
26
+ }
27
+
28
+ function hasImages(rep: NonVerbRepV3): boolean {
29
+ return imagesOf(rep).length > 0;
30
+ }
31
+
32
+ function asCitation(origin: NonVerbalSource['origin']): Citation | null {
33
+ return (origin as unknown as Citation) ?? null;
34
+ }
8
35
  </script>
9
36
 
10
37
  <template>
11
- <div v-if="reps.length" class="space-y-3">
38
+ <div v-if="v3Reps.length" class="space-y-3">
12
39
  <div class="section-label">Non-verbal representations</div>
13
- <div v-for="(rep, i) in reps" :key="i" class="card p-4 space-y-2">
14
- <div class="flex items-center gap-2">
15
- <span class="badge text-[10px] bg-violet-50 text-violet-700">{{ rep.type ?? 'representation' }}</span>
16
- <span v-if="rep.text" class="text-sm text-ink-700">{{ rep.text }}</span>
17
- </div>
40
+ <figure v-for="(rep, i) in v3Reps" :key="i" class="card p-4 space-y-2">
41
+ <span class="badge text-[10px] bg-violet-50 text-violet-700">{{ rep.type ?? 'representation' }}</span>
18
42
 
19
- <!-- Image -->
20
- <div v-if="rep.type === 'image' && rep.ref">
21
- <img :src="rep.ref" :alt="rep.text || 'Non-verbal representation'" class="max-h-64 rounded border border-ink-100" loading="lazy" />
22
- </div>
43
+ <FigureImages
44
+ v-if="hasImages(rep)"
45
+ :images="imagesOf(rep)"
46
+ :alt="(rep.alt as LocalizedString | undefined) ?? undefined"
47
+ :dataset-id="registerId"
48
+ :locale="locale"
49
+ :fallback-chain="fallbackChain"
50
+ :has-description="!!rep.description && Object.keys(rep.description).length > 0"
51
+ :entity-label="rep.type ?? 'representation'"
52
+ />
23
53
 
24
- <!-- Table / Formula reference -->
25
- <div v-if="(rep.type === 'table' || rep.type === 'formula') && rep.ref">
26
- <a :href="rep.ref" target="_blank" rel="noopener" class="text-sm concept-link break-all">{{ rep.ref }}</a>
27
- </div>
54
+ <NonVerbalCaption
55
+ :caption="(rep.caption as LocalizedString | undefined) ?? undefined"
56
+ :description="(rep.description as LocalizedString | undefined) ?? undefined"
57
+ :locale="locale"
58
+ :fallback-chain="fallbackChain"
59
+ />
28
60
 
29
- <!-- Sources for this representation -->
30
- <div v-if="rep.sources?.length" class="flex flex-wrap gap-1.5">
31
- <div v-for="(src, si) in rep.sources" :key="si" class="text-xs text-ink-400">
32
- <span v-if="src.type" class="badge text-[9px]" :class="sourceTypeInfo(src.type).color">{{ sourceTypeInfo(src.type).label }}</span>
33
- <span v-if="src.origin?.ref" class="ml-1">{{ src.origin.ref.source }}{{ src.origin.ref.id ? ' ' + src.origin.ref.id : '' }}</span>
34
- </div>
61
+ <div v-if="rep.sources?.length" class="nv-rep__sources">
62
+ <div class="nv-rep__sources-label">Sources</div>
63
+ <ol class="nv-rep__sources-list">
64
+ <li v-for="(src, si) in rep.sources" :key="si" class="nv-rep__source">
65
+ <CitationDisplay v-if="asCitation(src.origin)" :citation="asCitation(src.origin)!" :register-id="registerId" />
66
+ <span v-if="src.modification" class="nv-rep__source-modification">— {{ src.modification }}</span>
67
+ </li>
68
+ </ol>
35
69
  </div>
36
- </div>
70
+ </figure>
37
71
  </div>
38
72
  </template>
73
+
74
+ <style scoped>
75
+ .nv-rep__sources {
76
+ font-size: 0.75rem;
77
+ color: var(--ink-500, #666);
78
+ margin-top: 0.5rem;
79
+ }
80
+ .nv-rep__sources-label {
81
+ font-weight: 600;
82
+ text-transform: uppercase;
83
+ letter-spacing: 0.04em;
84
+ margin-bottom: 0.25rem;
85
+ }
86
+ .nv-rep__sources-list {
87
+ list-style: decimal inside;
88
+ display: flex;
89
+ flex-direction: column;
90
+ gap: 0.25rem;
91
+ }
92
+ .nv-rep__source-modification {
93
+ color: var(--ink-400, #888);
94
+ font-style: italic;
95
+ }
96
+ </style>
@@ -0,0 +1,132 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * FigureDisplay — main component for rendering a dataset-level Figure.
4
+ *
5
+ * Fetches the entity via the resolver, renders the image(s), caption,
6
+ * description, and sources. Composite figures (with subfigures) render
7
+ * recursively — each subfigure is itself a FigureDisplay.
8
+ *
9
+ * Self-anchoring: the outer `<figure>` element receives the anchor ID
10
+ * so `{{fig:id}}` mentions can scroll to it via the cross-ref composable.
11
+ */
12
+ import { computed, ref } from 'vue';
13
+ import type { Figure } from '../../adapters/non-verbal/types';
14
+ import { useNonVerbalEntity } from '../../composables/use-non-verbal-entity';
15
+ import { resolveFallbackChain } from '../../utils/locale';
16
+ import { anchorId } from '../../utils/non-verbal-anchor';
17
+ import { deriveLayout } from './figure-layout';
18
+ import NonVerbalCaption from '../non-verbal/NonVerbalCaption.vue';
19
+ import NonVerbalSources from '../non-verbal/NonVerbalSources.vue';
20
+ import NonVerbalFallback from '../non-verbal/NonVerbalFallback.vue';
21
+ import FigureImages from './FigureImages.vue';
22
+
23
+ const props = defineProps<{
24
+ datasetId: string;
25
+ entityId: string;
26
+ locale: string;
27
+ /** Languages configured on the dataset — drives fallback chain. */
28
+ datasetLocales?: readonly string[];
29
+ }>();
30
+
31
+ const k = () => 'figure' as const;
32
+ const ds = () => props.datasetId;
33
+ const id = () => props.entityId;
34
+ const { entity, state, error } = useNonVerbalEntity(k, ds, id);
35
+
36
+ const fallbackChain = computed(() => resolveFallbackChain(props.datasetLocales));
37
+ const layout = computed(() => entity.value ? deriveLayout(entity.value as Figure) : 'single');
38
+ const anchor = computed(() => anchorId('figure', props.datasetId, props.entityId));
39
+ const descriptionId = computed(() => `${anchor.value}-desc`);
40
+
41
+ const topLevelImages = computed(() => (entity.value as Figure | null)?.images ?? []);
42
+ </script>
43
+
44
+ <template>
45
+ <figure
46
+ v-if="entity && state === 'loaded'"
47
+ :id="anchor"
48
+ :class="`figure figure--${layout}`"
49
+ >
50
+ <div v-if="topLevelImages.length" :class="`figure__media figure__media--${layout}`">
51
+ <FigureImages
52
+ :images="topLevelImages"
53
+ :alt="(entity as Figure).alt"
54
+ :dataset-id="datasetId"
55
+ :locale="locale"
56
+ :fallback-chain="fallbackChain"
57
+ :hasDescription="!!(entity as Figure).description && Object.keys((entity as Figure).description ?? {}).length > 0"
58
+ :description-id="descriptionId"
59
+ entity-label="Figure"
60
+ />
61
+ </div>
62
+
63
+ <template v-if="(entity as Figure).subfigures?.length">
64
+ <div :class="`figure__subfigures figure__subfigures--${layout}`">
65
+ <FigureDisplay
66
+ v-for="sub in (entity as Figure).subfigures"
67
+ :key="sub.id"
68
+ :dataset-id="datasetId"
69
+ :entity-id="sub.id"
70
+ :locale="locale"
71
+ :dataset-locales="datasetLocales"
72
+ />
73
+ </div>
74
+ </template>
75
+
76
+ <NonVerbalCaption
77
+ :identifier="(entity as Figure).identifier"
78
+ :caption="(entity as Figure).caption"
79
+ :description="(entity as Figure).description"
80
+ :locale="locale"
81
+ :fallback-chain="fallbackChain"
82
+ :description-id="descriptionId"
83
+ />
84
+
85
+ <NonVerbalSources
86
+ v-if="(entity as Figure).sources?.length"
87
+ :sources="(entity as Figure).sources!"
88
+ />
89
+ </figure>
90
+
91
+ <NonVerbalFallback
92
+ v-else-if="state === 'loading' || state === 'not-found' || state === 'error'"
93
+ :state="state"
94
+ kind="figure"
95
+ :entity-id="entityId"
96
+ :message="error ?? undefined"
97
+ />
98
+ </template>
99
+
100
+ <style scoped>
101
+ .figure {
102
+ margin: 1rem 0;
103
+ display: flex;
104
+ flex-direction: column;
105
+ gap: 0.5rem;
106
+ }
107
+ .figure__media--row,
108
+ .figure__media--grid {
109
+ display: grid;
110
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
111
+ gap: 0.75rem;
112
+ }
113
+ .figure__media--column {
114
+ display: flex;
115
+ flex-direction: column;
116
+ gap: 0.75rem;
117
+ }
118
+ .figure__subfigures--row { flex-direction: row; flex-wrap: wrap; gap: 0.75rem; }
119
+ .figure__subfigures--column { flex-direction: column; gap: 0.75rem; }
120
+ .figure__subfigures--grid {
121
+ display: grid;
122
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
123
+ gap: 0.75rem;
124
+ }
125
+
126
+ [dir='rtl'] .figure__subfigures--row { flex-direction: row-reverse; }
127
+
128
+ .figure:focus-visible {
129
+ outline: 2px solid var(--blue-500, #3b82f6);
130
+ outline-offset: 4px;
131
+ }
132
+ </style>
@@ -0,0 +1,111 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import type { FigureImage, LocalizedString } from '../../adapters/non-verbal/types';
4
+ import { pickLocaleText, hasLocale } from '../../utils/locale';
5
+ import { getFactory } from '../../adapters/factory';
6
+
7
+ const props = withDefaults(defineProps<{
8
+ images: FigureImage[];
9
+ alt?: LocalizedString;
10
+ datasetId: string;
11
+ locale: string;
12
+ fallbackChain?: readonly string[];
13
+ descriptionId?: string;
14
+ hasDescription?: boolean;
15
+ entityLabel?: string;
16
+ }>(), {
17
+ entityLabel: 'Figure',
18
+ });
19
+
20
+ const resolver = getFactory().nonVerbalResolver;
21
+
22
+ const altText = computed(() => pickLocaleText(props.alt, props.locale, props.fallbackChain));
23
+ const altMissing = computed(() => !props.alt || Object.keys(props.alt).length === 0);
24
+ const altEmpty = computed(() =>
25
+ !!props.alt && hasLocale(props.alt, props.locale) && props.alt[props.locale] === '',
26
+ );
27
+
28
+ interface SourceVariant { src: string; type: string; media?: string; }
29
+
30
+ const sourceVariants = computed<SourceVariant[]>(() => {
31
+ const out: SourceVariant[] = [];
32
+ for (const img of props.images) {
33
+ if (!img.role || img.role === 'vector' || img.role === 'raster') continue;
34
+ const src = resolver.resolveImageUrl(props.datasetId, img.src);
35
+ const type = img.format === 'svg' ? 'image/svg+xml' : `image/${img.format === 'jpg' ? 'jpeg' : img.format}`;
36
+ let media: string | undefined;
37
+ if (img.role === 'dark') media = '(prefers-color-scheme: dark)';
38
+ else if (img.role === 'light') media = '(prefers-color-scheme: light)';
39
+ else if (img.role === 'print') media = 'print';
40
+ out.push({ src, type, media });
41
+ }
42
+ return out;
43
+ });
44
+
45
+ const defaultImg = computed(() => {
46
+ const nonRole = props.images.find(i => !i.role || i.role === 'vector' || i.role === 'raster');
47
+ const chosen = nonRole ?? props.images[0];
48
+ if (!chosen) return null;
49
+ return {
50
+ src: resolver.resolveImageUrl(props.datasetId, chosen.src),
51
+ width: chosen.width,
52
+ height: chosen.height,
53
+ };
54
+ });
55
+ </script>
56
+
57
+ <template>
58
+ <div class="figure__images">
59
+ <picture v-if="!altMissing">
60
+ <source
61
+ v-for="(v, i) in sourceVariants"
62
+ :key="i"
63
+ :type="v.type"
64
+ :srcset="v.src"
65
+ :media="v.media"
66
+ />
67
+ <img
68
+ v-if="defaultImg"
69
+ :src="defaultImg.src"
70
+ :alt="altEmpty ? '' : altText"
71
+ :width="defaultImg.width"
72
+ :height="defaultImg.height"
73
+ loading="lazy"
74
+ :aria-describedby="hasDescription && descriptionId ? descriptionId : undefined"
75
+ class="figure__img"
76
+ >
77
+ </picture>
78
+ <div
79
+ v-else
80
+ class="figure__alt-missing"
81
+ role="img"
82
+ :aria-label="`${entityLabel} is missing alt text`"
83
+ >
84
+ <p>{{ entityLabel }}: alt text missing</p>
85
+ </div>
86
+ </div>
87
+ </template>
88
+
89
+ <style scoped>
90
+ .figure__images {
91
+ margin: 0;
92
+ }
93
+ .figure__img {
94
+ max-width: 100%;
95
+ height: auto;
96
+ display: block;
97
+ border-radius: 0.375rem;
98
+ }
99
+ .figure__alt-missing {
100
+ padding: 1rem;
101
+ background: #fef3c7;
102
+ border: 1px dashed #d97706;
103
+ border-radius: 0.375rem;
104
+ color: #92400e;
105
+ font-size: 0.8125rem;
106
+ }
107
+
108
+ @media (prefers-color-scheme: dark) {
109
+ .figure__img { background: var(--surface-alt, #222); }
110
+ }
111
+ </style>