@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.
- package/cli/index.mjs +12 -13
- package/package.json +3 -2
- package/scripts/__tests__/fetch-datasets.test.mjs +105 -0
- package/scripts/fetch-datasets.mjs +53 -51
- package/scripts/generate-data.mjs +41 -19
- package/scripts/lib/build/image-assets.mjs +190 -0
- package/scripts/lib/build/non-verbal-consumer.mjs +221 -0
- package/scripts/lib/local-path-safety.mjs +68 -0
- package/src/__tests__/bibliography-adapter.test.ts +79 -0
- package/src/__tests__/content-renderer-nvr-mentions.test.ts +57 -0
- package/src/__tests__/locale.test.ts +46 -0
- package/src/__tests__/model-bridge-entity-refs.test.ts +114 -0
- package/src/__tests__/non-verbal-anchor.test.ts +33 -0
- package/src/__tests__/non-verbal-cross-ref.test.ts +146 -0
- package/src/__tests__/non-verbal-highlight.test.ts +56 -0
- package/src/__tests__/non-verbal-kind.test.ts +77 -0
- package/src/__tests__/non-verbal-list.test.ts +67 -0
- package/src/__tests__/non-verbal-rep-display.test.ts +85 -0
- package/src/__tests__/non-verbal-scroll-guard.test.ts +116 -0
- package/src/__tests__/use-concept-entities.test.ts +76 -0
- package/src/adapters/bibliography-adapter.ts +49 -0
- package/src/adapters/factory.ts +14 -0
- package/src/adapters/model-bridge.ts +51 -0
- package/src/adapters/non-verbal/figure-bridge.ts +101 -0
- package/src/adapters/non-verbal/formula-bridge.ts +48 -0
- package/src/adapters/non-verbal/index.ts +55 -0
- package/src/adapters/non-verbal/kind.ts +46 -0
- package/src/adapters/non-verbal/prefix.ts +67 -0
- package/src/adapters/non-verbal/source-bridge.ts +81 -0
- package/src/adapters/non-verbal/table-bridge.ts +98 -0
- package/src/adapters/non-verbal/types.ts +133 -0
- package/src/adapters/non-verbal-resolver.ts +101 -0
- package/src/components/ConceptDetail.vue +17 -4
- package/src/components/LanguageDetail.vue +0 -3
- package/src/components/NonVerbalRepDisplay.vue +82 -24
- package/src/components/figure/FigureDisplay.vue +132 -0
- package/src/components/figure/FigureImages.vue +111 -0
- package/src/components/figure/figure-image-pick.ts +56 -0
- package/src/components/figure/figure-layout.ts +26 -0
- package/src/components/formula/FormulaDisplay.vue +90 -0
- package/src/components/formula/FormulaExpression.vue +70 -0
- package/src/components/non-verbal/NonVerbalCaption.vue +104 -0
- package/src/components/non-verbal/NonVerbalFallback.vue +69 -0
- package/src/components/non-verbal/NonVerbalList.vue +118 -0
- package/src/components/non-verbal/NonVerbalSources.vue +61 -0
- package/src/components/table/TableDisplay.vue +99 -0
- package/src/components/table/TableMarkup.vue +63 -0
- package/src/components/table/TableStructured.vue +66 -0
- package/src/composables/use-concept-entities.ts +70 -0
- package/src/composables/use-non-verbal-cross-ref.ts +79 -0
- package/src/composables/use-non-verbal-entity.ts +58 -0
- package/src/composables/use-reduced-motion.ts +26 -0
- package/src/composables/use-render-options.ts +30 -33
- package/src/router/index.ts +3 -0
- package/src/router/non-verbal-scroll-guard.ts +56 -0
- package/src/style.css +17 -0
- package/src/utils/content-renderer.ts +76 -64
- package/src/utils/locale.ts +92 -0
- package/src/utils/non-verbal-anchor.ts +51 -0
- 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,
|
|
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
|
-
|
|
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
|
|
3
|
-
import {
|
|
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="
|
|
38
|
+
<div v-if="v3Reps.length" class="space-y-3">
|
|
12
39
|
<div class="section-label">Non-verbal representations</div>
|
|
13
|
-
<
|
|
14
|
-
<
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
<
|
|
32
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
</
|
|
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>
|