@museumwnf/viewer-core 1.14.0 → 1.15.1
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/LICENSE.md +44 -44
- package/README.md +658 -635
- package/package.json +50 -50
- package/src/AppRoot.vue +58 -58
- package/src/catalogue/dates.js +209 -209
- package/src/catalogue/facets.js +61 -61
- package/src/catalogue/keywordIndex.js +289 -289
- package/src/catalogue/listQuery.js +100 -100
- package/src/catalogue/pagination.js +57 -57
- package/src/catalogue/searchLanguage.js +32 -32
- package/src/catalogue/timeline.js +220 -220
- package/src/composables/useCatalogueData.js +141 -141
- package/src/composables/useDataPackage.js +98 -98
- package/src/composables/useEntities.js +100 -100
- package/src/composables/useRecordLanguage.js +71 -71
- package/src/conventions.js +252 -148
- package/src/createViewer.js +29 -29
- package/src/i18n/I18nText.vue +17 -17
- package/src/i18n/I18nTextInline.vue +17 -17
- package/src/i18n/index.js +88 -88
- package/src/i18n/language.js +127 -127
- package/src/i18n/markdown.js +265 -265
- package/src/index.js +51 -50
- package/src/injectionKeys.js +1 -1
- package/src/languages.js +49 -49
- package/src/legacy/index.js +58 -58
- package/src/record/collectionTree.js +307 -307
- package/src/record/derivations.js +154 -154
- package/src/record/partners.js +87 -87
- package/src/record/recordSheet.js +231 -231
- package/src/rights.js +53 -53
- package/src/router/index.js +138 -138
- package/src/siteConfig.js +37 -37
- package/src/styles/base.css +49 -49
- package/src/testing/checkOfferedLanguages.js +67 -67
- package/src/testing/index.js +3 -3
- package/src/testing/smoke.js +103 -103
- package/src/testing/viteConfig.js +72 -90
- package/src/views/DetailView.vue +53 -53
- package/src/views/HomeView.vue +20 -20
- package/src/views/ListView.vue +61 -61
- package/src/views/NotFoundView.vue +8 -8
- package/src/vite.js +8 -8
package/package.json
CHANGED
|
@@ -1,50 +1,50 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@museumwnf/viewer-core",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Application engine for MWNF websites: turns a dataset.config.js into a mounted Vue application.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"license": "SEE LICENSE IN LICENSE.md",
|
|
7
|
-
"repository": {
|
|
8
|
-
"type": "git",
|
|
9
|
-
"url": "git+https://github.com/metanull/viewer-core.git"
|
|
10
|
-
},
|
|
11
|
-
"publishConfig": {
|
|
12
|
-
"access": "public",
|
|
13
|
-
"registry": "https://registry.npmjs.org/"
|
|
14
|
-
},
|
|
15
|
-
"files": [
|
|
16
|
-
"src"
|
|
17
|
-
],
|
|
18
|
-
"main": "./src/index.js",
|
|
19
|
-
"module": "./src/index.js",
|
|
20
|
-
"exports": {
|
|
21
|
-
".": "./src/index.js",
|
|
22
|
-
"./i18n": "./src/i18n/index.js",
|
|
23
|
-
"./legacy": "./src/legacy/index.js",
|
|
24
|
-
"./testing": "./src/testing/index.js",
|
|
25
|
-
"./vite": "./src/vite.js",
|
|
26
|
-
"./styles/base.css": "./src/styles/base.css"
|
|
27
|
-
},
|
|
28
|
-
"sideEffects": [
|
|
29
|
-
"**/*.css"
|
|
30
|
-
],
|
|
31
|
-
"scripts": {
|
|
32
|
-
"test": "vitest run"
|
|
33
|
-
},
|
|
34
|
-
"dependencies": {
|
|
35
|
-
"marked": "^18.0.11"
|
|
36
|
-
},
|
|
37
|
-
"peerDependencies": {
|
|
38
|
-
"vue": "^3.5.0",
|
|
39
|
-
"vue-router": "^4.5.0 || ^5.0.0"
|
|
40
|
-
},
|
|
41
|
-
"devDependencies": {
|
|
42
|
-
"@vitejs/plugin-vue": "^6.0.8",
|
|
43
|
-
"@vue/test-utils": "^2.4.11",
|
|
44
|
-
"jsdom": "^30.0.1",
|
|
45
|
-
"vite": "^8.2.2",
|
|
46
|
-
"vitest": "^4.1.11",
|
|
47
|
-
"vue": "^3.5.0",
|
|
48
|
-
"vue-router": "^5.0.0"
|
|
49
|
-
}
|
|
50
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@museumwnf/viewer-core",
|
|
3
|
+
"version": "1.15.1",
|
|
4
|
+
"description": "Application engine for MWNF websites: turns a dataset.config.js into a mounted Vue application.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/metanull/viewer-core.git"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public",
|
|
13
|
+
"registry": "https://registry.npmjs.org/"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"main": "./src/index.js",
|
|
19
|
+
"module": "./src/index.js",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./src/index.js",
|
|
22
|
+
"./i18n": "./src/i18n/index.js",
|
|
23
|
+
"./legacy": "./src/legacy/index.js",
|
|
24
|
+
"./testing": "./src/testing/index.js",
|
|
25
|
+
"./vite": "./src/vite.js",
|
|
26
|
+
"./styles/base.css": "./src/styles/base.css"
|
|
27
|
+
},
|
|
28
|
+
"sideEffects": [
|
|
29
|
+
"**/*.css"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "vitest run"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"marked": "^18.0.11"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"vue": "^3.5.0",
|
|
39
|
+
"vue-router": "^4.5.0 || ^5.0.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@vitejs/plugin-vue": "^6.0.8",
|
|
43
|
+
"@vue/test-utils": "^2.4.11",
|
|
44
|
+
"jsdom": "^30.0.1",
|
|
45
|
+
"vite": "^8.2.2",
|
|
46
|
+
"vitest": "^4.1.11",
|
|
47
|
+
"vue": "^3.5.0",
|
|
48
|
+
"vue-router": "^5.0.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/AppRoot.vue
CHANGED
|
@@ -1,58 +1,58 @@
|
|
|
1
|
-
<script setup>
|
|
2
|
-
import { computed, inject, ref } from 'vue'
|
|
3
|
-
import { useRoute, useRouter } from 'vue-router'
|
|
4
|
-
import { useI18n } from './i18n/index.js'
|
|
5
|
-
import { negotiateLanguage } from './i18n/language.js'
|
|
6
|
-
import { VIEWER_CONFIG } from './injectionKeys.js'
|
|
7
|
-
|
|
8
|
-
const config = inject(VIEWER_CONFIG, {})
|
|
9
|
-
const { locale } = useI18n()
|
|
10
|
-
const route = useRoute()
|
|
11
|
-
const router = useRouter()
|
|
12
|
-
|
|
13
|
-
// The shell receives the resolved language list, everything the website put in
|
|
14
|
-
// config.navigation, and the current locale — in that order, so `language`
|
|
15
|
-
// always reflects the live locale.
|
|
16
|
-
const shellProps = computed(() => ({
|
|
17
|
-
languages: config.languages,
|
|
18
|
-
...(config.navigation ?? {}),
|
|
19
|
-
language: locale.value,
|
|
20
|
-
}))
|
|
21
|
-
|
|
22
|
-
// The shell is the website's own component, so what it emits is checked here
|
|
23
|
-
// rather than trusted: the active language is always one the website offers.
|
|
24
|
-
function setLanguage(language) {
|
|
25
|
-
locale.value = negotiateLanguage(config.languages, { requested: language })
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// Until the first navigation has resolved — which includes loading the
|
|
29
|
-
// entities the route declares — there is no view to show, and the page says
|
|
30
|
-
// so rather than staying blank.
|
|
31
|
-
const navigating = ref(true)
|
|
32
|
-
router.beforeEach(() => {
|
|
33
|
-
navigating.value = true
|
|
34
|
-
})
|
|
35
|
-
router.afterEach(() => {
|
|
36
|
-
navigating.value = false
|
|
37
|
-
})
|
|
38
|
-
router.onError(() => {
|
|
39
|
-
navigating.value = false
|
|
40
|
-
})
|
|
41
|
-
const loading = computed(() => navigating.value && route.matched.length === 0)
|
|
42
|
-
</script>
|
|
43
|
-
|
|
44
|
-
<template>
|
|
45
|
-
<component
|
|
46
|
-
:is="config.shell"
|
|
47
|
-
v-if="config.shell"
|
|
48
|
-
v-bind="shellProps"
|
|
49
|
-
@update:language="setLanguage"
|
|
50
|
-
>
|
|
51
|
-
<p v-if="loading" class="vc-loading">{{ $t('core.status.loading') }}</p>
|
|
52
|
-
<router-view v-else />
|
|
53
|
-
</component>
|
|
54
|
-
<template v-else>
|
|
55
|
-
<p v-if="loading" class="vc-loading">{{ $t('core.status.loading') }}</p>
|
|
56
|
-
<router-view v-else />
|
|
57
|
-
</template>
|
|
58
|
-
</template>
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, inject, ref } from 'vue'
|
|
3
|
+
import { useRoute, useRouter } from 'vue-router'
|
|
4
|
+
import { useI18n } from './i18n/index.js'
|
|
5
|
+
import { negotiateLanguage } from './i18n/language.js'
|
|
6
|
+
import { VIEWER_CONFIG } from './injectionKeys.js'
|
|
7
|
+
|
|
8
|
+
const config = inject(VIEWER_CONFIG, {})
|
|
9
|
+
const { locale } = useI18n()
|
|
10
|
+
const route = useRoute()
|
|
11
|
+
const router = useRouter()
|
|
12
|
+
|
|
13
|
+
// The shell receives the resolved language list, everything the website put in
|
|
14
|
+
// config.navigation, and the current locale — in that order, so `language`
|
|
15
|
+
// always reflects the live locale.
|
|
16
|
+
const shellProps = computed(() => ({
|
|
17
|
+
languages: config.languages,
|
|
18
|
+
...(config.navigation ?? {}),
|
|
19
|
+
language: locale.value,
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
// The shell is the website's own component, so what it emits is checked here
|
|
23
|
+
// rather than trusted: the active language is always one the website offers.
|
|
24
|
+
function setLanguage(language) {
|
|
25
|
+
locale.value = negotiateLanguage(config.languages, { requested: language })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Until the first navigation has resolved — which includes loading the
|
|
29
|
+
// entities the route declares — there is no view to show, and the page says
|
|
30
|
+
// so rather than staying blank.
|
|
31
|
+
const navigating = ref(true)
|
|
32
|
+
router.beforeEach(() => {
|
|
33
|
+
navigating.value = true
|
|
34
|
+
})
|
|
35
|
+
router.afterEach(() => {
|
|
36
|
+
navigating.value = false
|
|
37
|
+
})
|
|
38
|
+
router.onError(() => {
|
|
39
|
+
navigating.value = false
|
|
40
|
+
})
|
|
41
|
+
const loading = computed(() => navigating.value && route.matched.length === 0)
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<template>
|
|
45
|
+
<component
|
|
46
|
+
:is="config.shell"
|
|
47
|
+
v-if="config.shell"
|
|
48
|
+
v-bind="shellProps"
|
|
49
|
+
@update:language="setLanguage"
|
|
50
|
+
>
|
|
51
|
+
<p v-if="loading" class="vc-loading">{{ $t('core.status.loading') }}</p>
|
|
52
|
+
<router-view v-else />
|
|
53
|
+
</component>
|
|
54
|
+
<template v-else>
|
|
55
|
+
<p v-if="loading" class="vc-loading">{{ $t('core.status.loading') }}</p>
|
|
56
|
+
<router-view v-else />
|
|
57
|
+
</template>
|
|
58
|
+
</template>
|
package/src/catalogue/dates.js
CHANGED
|
@@ -1,209 +1,209 @@
|
|
|
1
|
-
// Two date rules, both legacy, never merged.
|
|
2
|
-
//
|
|
3
|
-
// The standalone sites (Islamic Art, Baroque Art, Sharing History) test
|
|
4
|
-
// overlap, tolerating a record that carries one date only: "from 800" keeps
|
|
5
|
-
// a record whose end is at or after 800, or — with no end — whose start is.
|
|
6
|
-
// The DXA sites (the galleries and exhibitions) test containment, the
|
|
7
|
-
// predicate of legacy's `Objects.blade.php`: `na <= start_date` and
|
|
8
|
-
// `nz >= coalesce(end_date, start_date)`, so an undated record is out the
|
|
9
|
-
// moment a bound is set. They give different answers on the same data, and
|
|
10
|
-
// each website has published its answer for years. A site names its rule
|
|
11
|
-
// once, in its catalogue spec; this file only makes sure the two cannot be
|
|
12
|
-
// confused for one.
|
|
13
|
-
|
|
14
|
-
const number = (value) => {
|
|
15
|
-
if (value === '' || value == null) return null
|
|
16
|
-
const n = Number(value)
|
|
17
|
-
return Number.isFinite(n) ? n : null
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const year = (record, field) => {
|
|
21
|
-
const value = record?.[field]
|
|
22
|
-
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Whether `record` falls in `[begin, end]` under `mode`: `'overlap'` (the
|
|
27
|
-
* standalone rule) or `'contain'` (the DXA rule). A bound that is empty or
|
|
28
|
-
* absent is not applied. `fields` names the record's start and end properties.
|
|
29
|
-
*/
|
|
30
|
-
export function inDateRange(record, { begin, end, mode = 'overlap', fields = {} } = {}) {
|
|
31
|
-
const from = number(begin)
|
|
32
|
-
const to = number(end)
|
|
33
|
-
const start = year(record, fields.start ?? 'start_date')
|
|
34
|
-
const last = year(record, fields.end ?? 'end_date')
|
|
35
|
-
|
|
36
|
-
if (mode === 'contain') {
|
|
37
|
-
if (from != null && (start == null || from > start)) return false
|
|
38
|
-
if (to != null) {
|
|
39
|
-
const final = last ?? start
|
|
40
|
-
if (final == null || to < final) return false
|
|
41
|
-
}
|
|
42
|
-
return true
|
|
43
|
-
}
|
|
44
|
-
if (mode !== 'overlap') throw new Error(`Unknown date mode "${mode}": use "overlap" or "contain"`)
|
|
45
|
-
|
|
46
|
-
if (from != null) {
|
|
47
|
-
if (last != null) {
|
|
48
|
-
if (last < from) return false
|
|
49
|
-
} else if (start != null && start < from) return false
|
|
50
|
-
}
|
|
51
|
-
if (to != null) {
|
|
52
|
-
if (start != null) {
|
|
53
|
-
if (start > to) return false
|
|
54
|
-
} else if (last != null && last > to) return false
|
|
55
|
-
}
|
|
56
|
-
return true
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** The records of `list` that `inDateRange` keeps. */
|
|
60
|
-
export function dateRange(list, options = {}) {
|
|
61
|
-
return (list ?? []).filter((record) => inDateRange(record, options))
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// ── Year buckets ───────────────────────────────────────────────────────────
|
|
65
|
-
//
|
|
66
|
-
// A verbatim port of `loadYears` in the legacy DXA client's collection.js
|
|
67
|
-
// mixin, carried here from four identical copies. The API returned only a
|
|
68
|
-
// min and a max; the client built the era buckets itself, with coarser steps
|
|
69
|
-
// the further back you go (500 → 250 → 100 → 50), an explicit "Before 1000
|
|
70
|
-
// BC" bucket, and an "After …" label when the final step is short. The
|
|
71
|
-
// algorithm is reproduced rather than improved because its values end up in
|
|
72
|
-
// shareable URLs, and a nicer bucket is a broken link.
|
|
73
|
-
|
|
74
|
-
const nextMultiple = (n, m) => Math.ceil(n / m) * m
|
|
75
|
-
const previousMultiple = (n, m) => Math.floor(n / m) * m
|
|
76
|
-
|
|
77
|
-
function pushIncrements(from, maxRounded, incrementMax, increment, array) {
|
|
78
|
-
for (let i = from; i <= incrementMax; i += increment) {
|
|
79
|
-
if (i > maxRounded) return
|
|
80
|
-
array.push(i)
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* The era buckets between `min` and `max`, as `[{ value, label }]`. The era
|
|
86
|
-
* words on the labels are texts (`catalogue.era.bc`, `.ad`, `.before`,
|
|
87
|
-
* `.after`) and the years are not, which is why `t` comes in from the caller
|
|
88
|
-
* and the two are joined here rather than written into an entry with a hole
|
|
89
|
-
* in it.
|
|
90
|
-
*/
|
|
91
|
-
export function yearBucketsFromRange(min, max, t) {
|
|
92
|
-
if (!Number.isFinite(min) || !Number.isFinite(max)) return []
|
|
93
|
-
|
|
94
|
-
const increments = [500, 250, 100, 50]
|
|
95
|
-
let smallestIncrement
|
|
96
|
-
const array = [min]
|
|
97
|
-
let minRounded, maxRounded
|
|
98
|
-
|
|
99
|
-
if (max >= 1800) maxRounded = previousMultiple(max, increments[3])
|
|
100
|
-
else if (max >= 1000) maxRounded = previousMultiple(max, increments[2])
|
|
101
|
-
else if (max >= 0) maxRounded = previousMultiple(max, increments[1])
|
|
102
|
-
else maxRounded = previousMultiple(max, increments[0])
|
|
103
|
-
|
|
104
|
-
if (min <= 0) minRounded = nextMultiple(min, increments[0])
|
|
105
|
-
else if (min <= 1000) minRounded = nextMultiple(min, increments[1])
|
|
106
|
-
else if (min <= 1800) minRounded = nextMultiple(min, increments[2])
|
|
107
|
-
else minRounded = nextMultiple(min, increments[3])
|
|
108
|
-
|
|
109
|
-
let guard = 0
|
|
110
|
-
do {
|
|
111
|
-
if (minRounded < 0) {
|
|
112
|
-
pushIncrements(minRounded, maxRounded, 0, increments[0], array)
|
|
113
|
-
smallestIncrement = increments[0]
|
|
114
|
-
} else if (minRounded < 1000) {
|
|
115
|
-
pushIncrements(minRounded, maxRounded, 1000, increments[1], array)
|
|
116
|
-
smallestIncrement = increments[1]
|
|
117
|
-
} else if (minRounded < 1800) {
|
|
118
|
-
pushIncrements(minRounded, maxRounded, 1800, increments[2], array)
|
|
119
|
-
smallestIncrement = increments[2]
|
|
120
|
-
} else {
|
|
121
|
-
pushIncrements(minRounded, maxRounded, maxRounded, increments[3], array)
|
|
122
|
-
smallestIncrement = increments[3]
|
|
123
|
-
}
|
|
124
|
-
const last = array[array.length - 1]
|
|
125
|
-
// The legacy loop relies on the array growing; if a step adds nothing it
|
|
126
|
-
// would spin forever. Legacy never hit that because its data always did.
|
|
127
|
-
if (last === minRounded && guard++ > 0) break
|
|
128
|
-
minRounded = last
|
|
129
|
-
} while (array[array.length - 1] < maxRounded && guard++ < 64)
|
|
130
|
-
|
|
131
|
-
array.push(max)
|
|
132
|
-
const unique = [...new Set(array)]
|
|
133
|
-
|
|
134
|
-
const labelled = []
|
|
135
|
-
for (const value of unique) {
|
|
136
|
-
if (value < 0) labelled.push({ value, label: `${Math.abs(value)} ${t('catalogue.era.bc')}` })
|
|
137
|
-
else if (value > 0) labelled.push({ value, label: `${value} ${t('catalogue.era.ad')}` })
|
|
138
|
-
}
|
|
139
|
-
if (!labelled.length) return []
|
|
140
|
-
|
|
141
|
-
let display = labelled
|
|
142
|
-
if (labelled[0].value < -1000) {
|
|
143
|
-
display = labelled.filter(({ value }) => value >= -1000)
|
|
144
|
-
display.unshift({ value: min, label: `${t('catalogue.era.before')} 1000 ${t('catalogue.era.bc')}` })
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
if (display.length > 1) {
|
|
148
|
-
const lastGap = display[display.length - 1].value - display[display.length - 2].value
|
|
149
|
-
if (lastGap < smallestIncrement) {
|
|
150
|
-
const previous = display[display.length - 2]
|
|
151
|
-
display[display.length - 1] = {
|
|
152
|
-
value: display[display.length - 1].value,
|
|
153
|
-
label: `${t('catalogue.era.after')} ${previous.label}`,
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
return display
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** The buckets spanning the dates of `records` (`start_date` to `end_date ?? start_date`). */
|
|
161
|
-
export function yearBuckets(records, t, { fields = {} } = {}) {
|
|
162
|
-
const startField = fields.start ?? 'start_date'
|
|
163
|
-
const endField = fields.end ?? 'end_date'
|
|
164
|
-
const starts = []
|
|
165
|
-
const ends = []
|
|
166
|
-
for (const record of records ?? []) {
|
|
167
|
-
const start = year(record, startField)
|
|
168
|
-
const end = year(record, endField) ?? start
|
|
169
|
-
if (start != null) starts.push(start)
|
|
170
|
-
if (end != null) ends.push(end)
|
|
171
|
-
}
|
|
172
|
-
if (!starts.length || !ends.length) return []
|
|
173
|
-
return yearBucketsFromRange(Math.min(...starts), Math.max(...ends), t)
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* "1193 AD" / "502 BC" — legacy's era suffix rule, for a year on its own.
|
|
178
|
-
* Nothing for a year that is missing or zero.
|
|
179
|
-
*/
|
|
180
|
-
export function eraLabel(year, t) {
|
|
181
|
-
if (!Number.isFinite(year) || year === 0) return ''
|
|
182
|
-
return year < 0 ? `${Math.abs(year)} ${t('catalogue.era.bc')}` : `${year} ${t('catalogue.era.ad')}`
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* A record's dates rounded outward to the century, the window legacy's
|
|
187
|
-
* "Timeline for this item" opens, so a single-year object still lands on a
|
|
188
|
-
* readable stretch of chronology. `[from, to]`, either null when unknown.
|
|
189
|
-
*/
|
|
190
|
-
export function roundOutward(start, end) {
|
|
191
|
-
const from = Number.isFinite(start) ? Math.floor(start / 100) * 100 : null
|
|
192
|
-
const last = Number.isFinite(end) ? end : start
|
|
193
|
-
const to = Number.isFinite(last) ? Math.ceil(last / 100) * 100 : null
|
|
194
|
-
return [from, to]
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* The century boundaries that three Database forms build locally, reproducing
|
|
199
|
-
* legacy `database.php:88–124`. The asymmetry between the two lists is preserved.
|
|
200
|
-
* The default range (501–2000) is the same as the legacy form's initial state.
|
|
201
|
-
*/
|
|
202
|
-
export function centuryPresets({ from = 501, to = 2000 } = {}) {
|
|
203
|
-
const fromOptions = Array.from({ length: 16 }, (_, i) => 501 + i * 100)
|
|
204
|
-
const toOptions = Array.from({ length: 15 }, (_, i) => 600 + i * 100)
|
|
205
|
-
return {
|
|
206
|
-
from: fromOptions,
|
|
207
|
-
to: toOptions,
|
|
208
|
-
}
|
|
209
|
-
}
|
|
1
|
+
// Two date rules, both legacy, never merged.
|
|
2
|
+
//
|
|
3
|
+
// The standalone sites (Islamic Art, Baroque Art, Sharing History) test
|
|
4
|
+
// overlap, tolerating a record that carries one date only: "from 800" keeps
|
|
5
|
+
// a record whose end is at or after 800, or — with no end — whose start is.
|
|
6
|
+
// The DXA sites (the galleries and exhibitions) test containment, the
|
|
7
|
+
// predicate of legacy's `Objects.blade.php`: `na <= start_date` and
|
|
8
|
+
// `nz >= coalesce(end_date, start_date)`, so an undated record is out the
|
|
9
|
+
// moment a bound is set. They give different answers on the same data, and
|
|
10
|
+
// each website has published its answer for years. A site names its rule
|
|
11
|
+
// once, in its catalogue spec; this file only makes sure the two cannot be
|
|
12
|
+
// confused for one.
|
|
13
|
+
|
|
14
|
+
const number = (value) => {
|
|
15
|
+
if (value === '' || value == null) return null
|
|
16
|
+
const n = Number(value)
|
|
17
|
+
return Number.isFinite(n) ? n : null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const year = (record, field) => {
|
|
21
|
+
const value = record?.[field]
|
|
22
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether `record` falls in `[begin, end]` under `mode`: `'overlap'` (the
|
|
27
|
+
* standalone rule) or `'contain'` (the DXA rule). A bound that is empty or
|
|
28
|
+
* absent is not applied. `fields` names the record's start and end properties.
|
|
29
|
+
*/
|
|
30
|
+
export function inDateRange(record, { begin, end, mode = 'overlap', fields = {} } = {}) {
|
|
31
|
+
const from = number(begin)
|
|
32
|
+
const to = number(end)
|
|
33
|
+
const start = year(record, fields.start ?? 'start_date')
|
|
34
|
+
const last = year(record, fields.end ?? 'end_date')
|
|
35
|
+
|
|
36
|
+
if (mode === 'contain') {
|
|
37
|
+
if (from != null && (start == null || from > start)) return false
|
|
38
|
+
if (to != null) {
|
|
39
|
+
const final = last ?? start
|
|
40
|
+
if (final == null || to < final) return false
|
|
41
|
+
}
|
|
42
|
+
return true
|
|
43
|
+
}
|
|
44
|
+
if (mode !== 'overlap') throw new Error(`Unknown date mode "${mode}": use "overlap" or "contain"`)
|
|
45
|
+
|
|
46
|
+
if (from != null) {
|
|
47
|
+
if (last != null) {
|
|
48
|
+
if (last < from) return false
|
|
49
|
+
} else if (start != null && start < from) return false
|
|
50
|
+
}
|
|
51
|
+
if (to != null) {
|
|
52
|
+
if (start != null) {
|
|
53
|
+
if (start > to) return false
|
|
54
|
+
} else if (last != null && last > to) return false
|
|
55
|
+
}
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The records of `list` that `inDateRange` keeps. */
|
|
60
|
+
export function dateRange(list, options = {}) {
|
|
61
|
+
return (list ?? []).filter((record) => inDateRange(record, options))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Year buckets ───────────────────────────────────────────────────────────
|
|
65
|
+
//
|
|
66
|
+
// A verbatim port of `loadYears` in the legacy DXA client's collection.js
|
|
67
|
+
// mixin, carried here from four identical copies. The API returned only a
|
|
68
|
+
// min and a max; the client built the era buckets itself, with coarser steps
|
|
69
|
+
// the further back you go (500 → 250 → 100 → 50), an explicit "Before 1000
|
|
70
|
+
// BC" bucket, and an "After …" label when the final step is short. The
|
|
71
|
+
// algorithm is reproduced rather than improved because its values end up in
|
|
72
|
+
// shareable URLs, and a nicer bucket is a broken link.
|
|
73
|
+
|
|
74
|
+
const nextMultiple = (n, m) => Math.ceil(n / m) * m
|
|
75
|
+
const previousMultiple = (n, m) => Math.floor(n / m) * m
|
|
76
|
+
|
|
77
|
+
function pushIncrements(from, maxRounded, incrementMax, increment, array) {
|
|
78
|
+
for (let i = from; i <= incrementMax; i += increment) {
|
|
79
|
+
if (i > maxRounded) return
|
|
80
|
+
array.push(i)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The era buckets between `min` and `max`, as `[{ value, label }]`. The era
|
|
86
|
+
* words on the labels are texts (`catalogue.era.bc`, `.ad`, `.before`,
|
|
87
|
+
* `.after`) and the years are not, which is why `t` comes in from the caller
|
|
88
|
+
* and the two are joined here rather than written into an entry with a hole
|
|
89
|
+
* in it.
|
|
90
|
+
*/
|
|
91
|
+
export function yearBucketsFromRange(min, max, t) {
|
|
92
|
+
if (!Number.isFinite(min) || !Number.isFinite(max)) return []
|
|
93
|
+
|
|
94
|
+
const increments = [500, 250, 100, 50]
|
|
95
|
+
let smallestIncrement
|
|
96
|
+
const array = [min]
|
|
97
|
+
let minRounded, maxRounded
|
|
98
|
+
|
|
99
|
+
if (max >= 1800) maxRounded = previousMultiple(max, increments[3])
|
|
100
|
+
else if (max >= 1000) maxRounded = previousMultiple(max, increments[2])
|
|
101
|
+
else if (max >= 0) maxRounded = previousMultiple(max, increments[1])
|
|
102
|
+
else maxRounded = previousMultiple(max, increments[0])
|
|
103
|
+
|
|
104
|
+
if (min <= 0) minRounded = nextMultiple(min, increments[0])
|
|
105
|
+
else if (min <= 1000) minRounded = nextMultiple(min, increments[1])
|
|
106
|
+
else if (min <= 1800) minRounded = nextMultiple(min, increments[2])
|
|
107
|
+
else minRounded = nextMultiple(min, increments[3])
|
|
108
|
+
|
|
109
|
+
let guard = 0
|
|
110
|
+
do {
|
|
111
|
+
if (minRounded < 0) {
|
|
112
|
+
pushIncrements(minRounded, maxRounded, 0, increments[0], array)
|
|
113
|
+
smallestIncrement = increments[0]
|
|
114
|
+
} else if (minRounded < 1000) {
|
|
115
|
+
pushIncrements(minRounded, maxRounded, 1000, increments[1], array)
|
|
116
|
+
smallestIncrement = increments[1]
|
|
117
|
+
} else if (minRounded < 1800) {
|
|
118
|
+
pushIncrements(minRounded, maxRounded, 1800, increments[2], array)
|
|
119
|
+
smallestIncrement = increments[2]
|
|
120
|
+
} else {
|
|
121
|
+
pushIncrements(minRounded, maxRounded, maxRounded, increments[3], array)
|
|
122
|
+
smallestIncrement = increments[3]
|
|
123
|
+
}
|
|
124
|
+
const last = array[array.length - 1]
|
|
125
|
+
// The legacy loop relies on the array growing; if a step adds nothing it
|
|
126
|
+
// would spin forever. Legacy never hit that because its data always did.
|
|
127
|
+
if (last === minRounded && guard++ > 0) break
|
|
128
|
+
minRounded = last
|
|
129
|
+
} while (array[array.length - 1] < maxRounded && guard++ < 64)
|
|
130
|
+
|
|
131
|
+
array.push(max)
|
|
132
|
+
const unique = [...new Set(array)]
|
|
133
|
+
|
|
134
|
+
const labelled = []
|
|
135
|
+
for (const value of unique) {
|
|
136
|
+
if (value < 0) labelled.push({ value, label: `${Math.abs(value)} ${t('catalogue.era.bc')}` })
|
|
137
|
+
else if (value > 0) labelled.push({ value, label: `${value} ${t('catalogue.era.ad')}` })
|
|
138
|
+
}
|
|
139
|
+
if (!labelled.length) return []
|
|
140
|
+
|
|
141
|
+
let display = labelled
|
|
142
|
+
if (labelled[0].value < -1000) {
|
|
143
|
+
display = labelled.filter(({ value }) => value >= -1000)
|
|
144
|
+
display.unshift({ value: min, label: `${t('catalogue.era.before')} 1000 ${t('catalogue.era.bc')}` })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (display.length > 1) {
|
|
148
|
+
const lastGap = display[display.length - 1].value - display[display.length - 2].value
|
|
149
|
+
if (lastGap < smallestIncrement) {
|
|
150
|
+
const previous = display[display.length - 2]
|
|
151
|
+
display[display.length - 1] = {
|
|
152
|
+
value: display[display.length - 1].value,
|
|
153
|
+
label: `${t('catalogue.era.after')} ${previous.label}`,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return display
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The buckets spanning the dates of `records` (`start_date` to `end_date ?? start_date`). */
|
|
161
|
+
export function yearBuckets(records, t, { fields = {} } = {}) {
|
|
162
|
+
const startField = fields.start ?? 'start_date'
|
|
163
|
+
const endField = fields.end ?? 'end_date'
|
|
164
|
+
const starts = []
|
|
165
|
+
const ends = []
|
|
166
|
+
for (const record of records ?? []) {
|
|
167
|
+
const start = year(record, startField)
|
|
168
|
+
const end = year(record, endField) ?? start
|
|
169
|
+
if (start != null) starts.push(start)
|
|
170
|
+
if (end != null) ends.push(end)
|
|
171
|
+
}
|
|
172
|
+
if (!starts.length || !ends.length) return []
|
|
173
|
+
return yearBucketsFromRange(Math.min(...starts), Math.max(...ends), t)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* "1193 AD" / "502 BC" — legacy's era suffix rule, for a year on its own.
|
|
178
|
+
* Nothing for a year that is missing or zero.
|
|
179
|
+
*/
|
|
180
|
+
export function eraLabel(year, t) {
|
|
181
|
+
if (!Number.isFinite(year) || year === 0) return ''
|
|
182
|
+
return year < 0 ? `${Math.abs(year)} ${t('catalogue.era.bc')}` : `${year} ${t('catalogue.era.ad')}`
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A record's dates rounded outward to the century, the window legacy's
|
|
187
|
+
* "Timeline for this item" opens, so a single-year object still lands on a
|
|
188
|
+
* readable stretch of chronology. `[from, to]`, either null when unknown.
|
|
189
|
+
*/
|
|
190
|
+
export function roundOutward(start, end) {
|
|
191
|
+
const from = Number.isFinite(start) ? Math.floor(start / 100) * 100 : null
|
|
192
|
+
const last = Number.isFinite(end) ? end : start
|
|
193
|
+
const to = Number.isFinite(last) ? Math.ceil(last / 100) * 100 : null
|
|
194
|
+
return [from, to]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The century boundaries that three Database forms build locally, reproducing
|
|
199
|
+
* legacy `database.php:88–124`. The asymmetry between the two lists is preserved.
|
|
200
|
+
* The default range (501–2000) is the same as the legacy form's initial state.
|
|
201
|
+
*/
|
|
202
|
+
export function centuryPresets({ from = 501, to = 2000 } = {}) {
|
|
203
|
+
const fromOptions = Array.from({ length: 16 }, (_, i) => 501 + i * 100)
|
|
204
|
+
const toOptions = Array.from({ length: 15 }, (_, i) => 600 + i * 100)
|
|
205
|
+
return {
|
|
206
|
+
from: fromOptions,
|
|
207
|
+
to: toOptions,
|
|
208
|
+
}
|
|
209
|
+
}
|