@glossarist/concept-browser 0.7.44 → 0.7.46
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/package.json +2 -2
- package/scripts/generate-data.mjs +22 -13
- package/scripts/lib/build/image-assets.mjs +190 -0
- package/scripts/lib/build/non-verbal-consumer.mjs +221 -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,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>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figure image picker — single SSOT for choosing the best variant from
|
|
3
|
+
* a Figure's image array.
|
|
4
|
+
*
|
|
5
|
+
* Decision order:
|
|
6
|
+
* 1. If user prefers dark color scheme and a dark variant exists, use it.
|
|
7
|
+
* 2. If user prefers light color scheme and a light variant exists, use it.
|
|
8
|
+
* 3. If a vector (SVG) variant exists and the user prefers vector, use it.
|
|
9
|
+
* 4. Otherwise, fall back to the first declared variant.
|
|
10
|
+
*
|
|
11
|
+
* The `print` role is reserved for print stylesheets (selected via CSS
|
|
12
|
+
* @media print in FigureImages.vue), not picked here.
|
|
13
|
+
*/
|
|
14
|
+
import type { FigureImage } from '../../adapters/non-verbal/types';
|
|
15
|
+
|
|
16
|
+
export interface PickOptions {
|
|
17
|
+
prefersDark?: boolean;
|
|
18
|
+
prefersVector?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function pickBestImage(images: FigureImage[], opts: PickOptions = {}): FigureImage | null {
|
|
22
|
+
if (images.length === 0) return null;
|
|
23
|
+
if (opts.prefersDark) {
|
|
24
|
+
const dark = images.find(i => i.role === 'dark');
|
|
25
|
+
if (dark) return dark;
|
|
26
|
+
}
|
|
27
|
+
if (!opts.prefersDark) {
|
|
28
|
+
const light = images.find(i => i.role === 'light');
|
|
29
|
+
if (light) return light;
|
|
30
|
+
}
|
|
31
|
+
if (opts.prefersVector) {
|
|
32
|
+
const vector = images.find(i => i.format === 'svg' || i.role === 'vector');
|
|
33
|
+
if (vector) return vector;
|
|
34
|
+
}
|
|
35
|
+
return images[0] ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Group image variants by role for the <picture> element. Returns the
|
|
40
|
+
* responsive sources in the order they should appear (most-specific
|
|
41
|
+
* first), plus the default `<img>` source.
|
|
42
|
+
*/
|
|
43
|
+
export function groupImageVariants(images: FigureImage[]): {
|
|
44
|
+
sources: FigureImage[];
|
|
45
|
+
img: FigureImage | null;
|
|
46
|
+
print: FigureImage | null;
|
|
47
|
+
} {
|
|
48
|
+
const print = images.find(i => i.role === 'print') ?? null;
|
|
49
|
+
const dark = images.find(i => i.role === 'dark');
|
|
50
|
+
const light = images.find(i => i.role === 'light');
|
|
51
|
+
const sources: FigureImage[] = [];
|
|
52
|
+
if (dark) sources.push(dark);
|
|
53
|
+
if (light) sources.push(light);
|
|
54
|
+
const img = pickBestImage(images, {}) ?? images[0] ?? null;
|
|
55
|
+
return { sources, img, print };
|
|
56
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figure layout derivation — derives a layout label from subfigure count.
|
|
3
|
+
*
|
|
4
|
+
* Per the architectural rule "derive layout, don't store it", this helper
|
|
5
|
+
* inspects a Figure's subfigures and returns the layout kind:
|
|
6
|
+
*
|
|
7
|
+
* - `single` — no subfigures
|
|
8
|
+
* - `row` — 2 subfigures side-by-side
|
|
9
|
+
* - `column` — 3+ subfigures in a vertical stack (default for many)
|
|
10
|
+
* - `grid` — 4+ subfigures in a 2-col grid
|
|
11
|
+
*
|
|
12
|
+
* Authors who want different behavior can split a composite figure into
|
|
13
|
+
* multiple top-level figures. V1 does not expose a `layout` field.
|
|
14
|
+
*/
|
|
15
|
+
import type { Figure } from '../../adapters/non-verbal/types';
|
|
16
|
+
|
|
17
|
+
export type FigureLayout = 'single' | 'row' | 'column' | 'grid';
|
|
18
|
+
|
|
19
|
+
export function deriveLayout(fig: Figure): FigureLayout {
|
|
20
|
+
const count = fig.subfigures?.length ?? 0;
|
|
21
|
+
if (count === 0) return 'single';
|
|
22
|
+
if (count === 1) return 'column';
|
|
23
|
+
if (count === 2) return 'row';
|
|
24
|
+
if (count === 3) return 'column';
|
|
25
|
+
return 'grid';
|
|
26
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* FormulaDisplay — renders a dataset-level Formula entity.
|
|
4
|
+
*
|
|
5
|
+
* Caption + expression + description + sources. The expression renders
|
|
6
|
+
* via Plurimath (LaTeX/MathML/AsciiMath). Self-anchoring — the outer
|
|
7
|
+
* `<figure>` receives the anchor ID for `{{formula:id}}` mentions.
|
|
8
|
+
*/
|
|
9
|
+
import { computed } from 'vue';
|
|
10
|
+
import type { Formula } from '../../adapters/non-verbal/types';
|
|
11
|
+
import { useNonVerbalEntity } from '../../composables/use-non-verbal-entity';
|
|
12
|
+
import { resolveFallbackChain } from '../../utils/locale';
|
|
13
|
+
import { anchorId } from '../../utils/non-verbal-anchor';
|
|
14
|
+
import NonVerbalCaption from '../non-verbal/NonVerbalCaption.vue';
|
|
15
|
+
import NonVerbalSources from '../non-verbal/NonVerbalSources.vue';
|
|
16
|
+
import NonVerbalFallback from '../non-verbal/NonVerbalFallback.vue';
|
|
17
|
+
import FormulaExpression from './FormulaExpression.vue';
|
|
18
|
+
|
|
19
|
+
const props = defineProps<{
|
|
20
|
+
datasetId: string;
|
|
21
|
+
entityId: string;
|
|
22
|
+
locale: string;
|
|
23
|
+
datasetLocales?: readonly string[];
|
|
24
|
+
}>();
|
|
25
|
+
|
|
26
|
+
const k = () => 'formula' as const;
|
|
27
|
+
const { entity, state, error } = useNonVerbalEntity(k, () => props.datasetId, () => props.entityId);
|
|
28
|
+
|
|
29
|
+
const fallbackChain = computed(() => resolveFallbackChain(props.datasetLocales));
|
|
30
|
+
const anchor = computed(() => anchorId('formula', props.datasetId, props.entityId));
|
|
31
|
+
const descriptionId = computed(() => `${anchor.value}-desc`);
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<figure
|
|
36
|
+
v-if="entity && state === 'loaded'"
|
|
37
|
+
:id="anchor"
|
|
38
|
+
class="formula-entity"
|
|
39
|
+
>
|
|
40
|
+
<div class="formula__expr-line">
|
|
41
|
+
<FormulaExpression
|
|
42
|
+
:expression="(entity as Formula).expression"
|
|
43
|
+
:notation="(entity as Formula).notation"
|
|
44
|
+
:locale="locale"
|
|
45
|
+
:fallback-chain="fallbackChain"
|
|
46
|
+
/>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<NonVerbalCaption
|
|
50
|
+
:identifier="(entity as Formula).identifier"
|
|
51
|
+
:caption="(entity as Formula).caption"
|
|
52
|
+
:description="(entity as Formula).description"
|
|
53
|
+
:locale="locale"
|
|
54
|
+
:fallback-chain="fallbackChain"
|
|
55
|
+
:description-id="descriptionId"
|
|
56
|
+
/>
|
|
57
|
+
|
|
58
|
+
<NonVerbalSources
|
|
59
|
+
v-if="(entity as Formula).sources?.length"
|
|
60
|
+
:sources="(entity as Formula).sources!"
|
|
61
|
+
/>
|
|
62
|
+
</figure>
|
|
63
|
+
|
|
64
|
+
<NonVerbalFallback
|
|
65
|
+
v-else-if="state === 'loading' || state === 'not-found' || state === 'error'"
|
|
66
|
+
:state="state"
|
|
67
|
+
kind="formula"
|
|
68
|
+
:entity-id="entityId"
|
|
69
|
+
:message="error ?? undefined"
|
|
70
|
+
/>
|
|
71
|
+
</template>
|
|
72
|
+
|
|
73
|
+
<style scoped>
|
|
74
|
+
.formula-entity {
|
|
75
|
+
margin: 1rem 0;
|
|
76
|
+
padding: 0.75rem 1rem;
|
|
77
|
+
border-left: 3px solid var(--ink-200, #e5e5e5);
|
|
78
|
+
display: flex;
|
|
79
|
+
flex-direction: column;
|
|
80
|
+
gap: 0.5rem;
|
|
81
|
+
}
|
|
82
|
+
.formula__expr-line {
|
|
83
|
+
font-size: 1.125rem;
|
|
84
|
+
padding: 0.25rem 0;
|
|
85
|
+
}
|
|
86
|
+
.formula-entity:focus-visible {
|
|
87
|
+
outline: 2px solid var(--blue-500, #3b82f6);
|
|
88
|
+
outline-offset: 4px;
|
|
89
|
+
}
|
|
90
|
+
</style>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* FormulaExpression — renders one formula expression in its notation.
|
|
4
|
+
*
|
|
5
|
+
* Uses Plurimath (the project's existing math library, NOT KaTeX) to
|
|
6
|
+
* render LaTeX / AsciiMath / MathML to MathML+HTML. Plurimath output
|
|
7
|
+
* includes semantic MathML for screen readers as a by-product of its
|
|
8
|
+
* internal pipeline, so no extra a11y work is needed.
|
|
9
|
+
*
|
|
10
|
+
* The expression is a LocalizedString — different locales can name
|
|
11
|
+
* variables in their local language while keeping the same notation type.
|
|
12
|
+
*/
|
|
13
|
+
import { computed, ref, watch, onMounted } from 'vue';
|
|
14
|
+
import type { LocalizedString, FormulaNotation } from '../../adapters/non-verbal/types';
|
|
15
|
+
import { pickLocaleMap, localeToBcp47 } from '../../utils/locale';
|
|
16
|
+
import { loadPlurimath } from '../../utils/plurimath';
|
|
17
|
+
|
|
18
|
+
const props = defineProps<{
|
|
19
|
+
expression: LocalizedString;
|
|
20
|
+
notation: FormulaNotation;
|
|
21
|
+
locale: string;
|
|
22
|
+
fallbackChain?: readonly string[];
|
|
23
|
+
}>();
|
|
24
|
+
|
|
25
|
+
const resolved = computed(() => pickLocaleMap(props.expression, props.locale, props.fallbackChain));
|
|
26
|
+
const html = ref<string>('');
|
|
27
|
+
const lang = computed(() => resolved.value ? localeToBcp47(resolved.value.locale) : undefined);
|
|
28
|
+
|
|
29
|
+
const PLURIMATH_FORMAT: Record<FormulaNotation, string> = {
|
|
30
|
+
latex: 'latex',
|
|
31
|
+
asciimath: 'asciimath',
|
|
32
|
+
mathml: 'mathml',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
async function render() {
|
|
36
|
+
const r = resolved.value;
|
|
37
|
+
if (!r) { html.value = ''; return; }
|
|
38
|
+
try {
|
|
39
|
+
const Plurimath = await loadPlurimath();
|
|
40
|
+
const p = new Plurimath(r.text, PLURIMATH_FORMAT[props.notation]);
|
|
41
|
+
html.value = p.toMathml().replace('display="block"', 'display="inline"').trim();
|
|
42
|
+
} catch {
|
|
43
|
+
html.value = `<code class="formula-fallback">${r.text}</code>`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
onMounted(render);
|
|
48
|
+
watch([resolved, () => props.notation], render);
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<template>
|
|
52
|
+
<span class="formula__expression" :lang="lang">
|
|
53
|
+
<span v-if="html" v-html="html"></span>
|
|
54
|
+
<code v-else></code>
|
|
55
|
+
</span>
|
|
56
|
+
</template>
|
|
57
|
+
|
|
58
|
+
<style scoped>
|
|
59
|
+
.formula__expression {
|
|
60
|
+
display: inline-block;
|
|
61
|
+
font-family: 'Latin Modern Math', 'STIX Two Math', serif;
|
|
62
|
+
}
|
|
63
|
+
.formula-fallback {
|
|
64
|
+
font-family: var(--font-mono, monospace);
|
|
65
|
+
background: var(--surface-alt, #f5f5f5);
|
|
66
|
+
padding: 0.125rem 0.375rem;
|
|
67
|
+
border-radius: 0.25rem;
|
|
68
|
+
font-size: 0.875rem;
|
|
69
|
+
}
|
|
70
|
+
</style>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* NonVerbalCaption — shared caption + identifier + description.
|
|
4
|
+
*
|
|
5
|
+
* Renders identically for Figure, Table, Formula. The shape:
|
|
6
|
+
*
|
|
7
|
+
* <figcaption>
|
|
8
|
+
* <span class="nv-caption__identifier">Figure 7c.</span>
|
|
9
|
+
* <span class="nv-caption__text" lang="en">Caption text.</span>
|
|
10
|
+
* <details>
|
|
11
|
+
* <summary>Detailed description</summary>
|
|
12
|
+
* <p lang="en">Long description text.</p>
|
|
13
|
+
* </details>
|
|
14
|
+
* </figcaption>
|
|
15
|
+
*
|
|
16
|
+
* The `lang` attribute reflects the ACTUAL resolved locale (not the
|
|
17
|
+
* requested one), so a French page with an English-only caption shows
|
|
18
|
+
* `lang="en"` on the caption — correct for screen readers.
|
|
19
|
+
*/
|
|
20
|
+
import { computed } from 'vue';
|
|
21
|
+
import type { LocalizedString } from '../../adapters/non-verbal/types';
|
|
22
|
+
import { pickLocaleMap, localeToBcp47 } from '../../utils/locale';
|
|
23
|
+
|
|
24
|
+
const props = defineProps<{
|
|
25
|
+
identifier?: string;
|
|
26
|
+
caption?: LocalizedString;
|
|
27
|
+
description?: LocalizedString;
|
|
28
|
+
locale: string;
|
|
29
|
+
fallbackChain?: readonly string[];
|
|
30
|
+
descriptionId?: string;
|
|
31
|
+
}>();
|
|
32
|
+
|
|
33
|
+
const captionResolved = computed(() =>
|
|
34
|
+
pickLocaleMap(props.caption, props.locale, props.fallbackChain),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const descriptionResolved = computed(() =>
|
|
38
|
+
pickLocaleMap(props.description, props.locale, props.fallbackChain),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const captionLang = computed(() =>
|
|
42
|
+
captionResolved.value ? localeToBcp47(captionResolved.value.locale) : undefined,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const descriptionLang = computed(() =>
|
|
46
|
+
descriptionResolved.value ? localeToBcp47(descriptionResolved.value.locale) : undefined,
|
|
47
|
+
);
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<template>
|
|
51
|
+
<figcaption class="nv-caption">
|
|
52
|
+
<span v-if="identifier" class="nv-caption__identifier">{{ identifier }}.</span>
|
|
53
|
+
<span
|
|
54
|
+
v-if="captionResolved"
|
|
55
|
+
class="nv-caption__text"
|
|
56
|
+
:lang="captionLang"
|
|
57
|
+
>{{ captionResolved.text }}</span>
|
|
58
|
+
<details
|
|
59
|
+
v-if="descriptionResolved"
|
|
60
|
+
:id="descriptionId"
|
|
61
|
+
class="nv-caption__desc"
|
|
62
|
+
>
|
|
63
|
+
<summary>Detailed description</summary>
|
|
64
|
+
<p :lang="descriptionLang">{{ descriptionResolved.text }}</p>
|
|
65
|
+
</details>
|
|
66
|
+
</figcaption>
|
|
67
|
+
</template>
|
|
68
|
+
|
|
69
|
+
<style scoped>
|
|
70
|
+
.nv-caption {
|
|
71
|
+
font-size: 0.875rem;
|
|
72
|
+
color: var(--ink-700, #444);
|
|
73
|
+
line-height: 1.5;
|
|
74
|
+
display: flex;
|
|
75
|
+
flex-direction: column;
|
|
76
|
+
gap: 0.25rem;
|
|
77
|
+
}
|
|
78
|
+
.nv-caption__identifier {
|
|
79
|
+
font-weight: 600;
|
|
80
|
+
color: var(--ink-800, #222);
|
|
81
|
+
}
|
|
82
|
+
.nv-caption__text {
|
|
83
|
+
font-style: italic;
|
|
84
|
+
}
|
|
85
|
+
.nv-caption__desc {
|
|
86
|
+
margin-top: 0.25rem;
|
|
87
|
+
font-size: 0.8125rem;
|
|
88
|
+
color: var(--ink-500, #666);
|
|
89
|
+
}
|
|
90
|
+
.nv-caption__desc > summary {
|
|
91
|
+
cursor: pointer;
|
|
92
|
+
font-weight: 500;
|
|
93
|
+
color: var(--ink-600, #555);
|
|
94
|
+
}
|
|
95
|
+
.nv-caption__desc > p {
|
|
96
|
+
margin-top: 0.5rem;
|
|
97
|
+
line-height: 1.6;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@media (prefers-contrast: more) {
|
|
101
|
+
.nv-caption__text { font-weight: 600; }
|
|
102
|
+
.nv-caption__identifier { font-weight: 700; }
|
|
103
|
+
}
|
|
104
|
+
</style>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* NonVerbalFallback — shared loading / not-found / error state.
|
|
4
|
+
*
|
|
5
|
+
* Each entity display component renders its own fallback rather than
|
|
6
|
+
* hoisting errors to a global banner. Per the project rule
|
|
7
|
+
* "Error handling: local over global", a missing figure should not
|
|
8
|
+
* break the page — it shows a compact notice inline.
|
|
9
|
+
*/
|
|
10
|
+
defineProps<{
|
|
11
|
+
state: 'loading' | 'not-found' | 'error';
|
|
12
|
+
kind: 'figure' | 'table' | 'formula';
|
|
13
|
+
entityId: string;
|
|
14
|
+
message?: string;
|
|
15
|
+
}>();
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<div
|
|
20
|
+
class="nv-fallback"
|
|
21
|
+
:class="`nv-fallback--${state}`"
|
|
22
|
+
role="status"
|
|
23
|
+
:aria-live="state === 'error' ? 'assertive' : 'polite'"
|
|
24
|
+
>
|
|
25
|
+
<template v-if="state === 'loading'">
|
|
26
|
+
<span class="nv-fallback__spinner" aria-hidden="true"></span>
|
|
27
|
+
Loading {{ kind }} <code>{{ entityId }}</code>…
|
|
28
|
+
</template>
|
|
29
|
+
<template v-else-if="state === 'not-found'">
|
|
30
|
+
{{ kind }} <code>{{ entityId }}</code> not found
|
|
31
|
+
</template>
|
|
32
|
+
<template v-else>
|
|
33
|
+
Failed to load {{ kind }} <code>{{ entityId }}</code>
|
|
34
|
+
<span v-if="message" class="nv-fallback__detail">: {{ message }}</span>
|
|
35
|
+
</template>
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|
|
38
|
+
|
|
39
|
+
<style scoped>
|
|
40
|
+
.nv-fallback {
|
|
41
|
+
display: inline-flex;
|
|
42
|
+
align-items: center;
|
|
43
|
+
gap: 0.5rem;
|
|
44
|
+
padding: 0.5rem 0.75rem;
|
|
45
|
+
border-radius: 0.375rem;
|
|
46
|
+
font-size: 0.8125rem;
|
|
47
|
+
color: var(--ink-500, #666);
|
|
48
|
+
background: var(--surface-alt, #f5f5f5);
|
|
49
|
+
border: 1px dashed var(--ink-200, #ccc);
|
|
50
|
+
}
|
|
51
|
+
.nv-fallback--error {
|
|
52
|
+
color: #b91c1c;
|
|
53
|
+
background: #fef2f2;
|
|
54
|
+
border-color: #fecaca;
|
|
55
|
+
}
|
|
56
|
+
.nv-fallback__spinner {
|
|
57
|
+
width: 0.75rem;
|
|
58
|
+
height: 0.75rem;
|
|
59
|
+
border: 2px solid var(--ink-200, #ccc);
|
|
60
|
+
border-top-color: transparent;
|
|
61
|
+
border-radius: 50%;
|
|
62
|
+
animation: nv-spin 0.8s linear infinite;
|
|
63
|
+
}
|
|
64
|
+
@keyframes nv-spin { to { transform: rotate(360deg); } }
|
|
65
|
+
|
|
66
|
+
@media (prefers-reduced-motion: reduce) {
|
|
67
|
+
.nv-fallback__spinner { animation: none; }
|
|
68
|
+
}
|
|
69
|
+
</style>
|