@museumwnf/viewer-core 1.15.1 → 1.16.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/README.md +111 -2
- package/package.json +3 -2
- package/src/createStandardViewer.js +61 -0
- package/src/dxa/exhibition/useExhibitionCollection.js +176 -0
- package/src/dxa/exhibition/useExhibitionData.js +246 -0
- package/src/dxa/exhibition/useExhibitionPartner.js +62 -0
- package/src/dxa/exhibition/useExhibitionSheet.js +126 -0
- package/src/dxa/exhibition/useExhibitionTimeline.js +310 -0
- package/src/dxa/gallery/useGalleryCollection.js +175 -0
- package/src/dxa/gallery/useGalleryData.js +143 -0
- package/src/dxa/gallery/useGalleryPartner.js +77 -0
- package/src/dxa/gallery/useGallerySheet.js +118 -0
- package/src/dxa/gallery/useGalleryTimeline.js +226 -0
- package/src/dxa/index.js +34 -0
- package/src/dxa/shared.js +53 -0
- package/src/index.js +1 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The partner list and profile, as specs. The grouping, the A–Z toggle, the
|
|
2
|
+
// tab strip's mechanics, the language switch and the media gallery are the
|
|
3
|
+
// platform's (`PartnerListView`/`RecordView`); what stays here is an
|
|
4
|
+
// exhibition's own — the E6 hidden-partner rule (the view reads the raw
|
|
5
|
+
// `partners` entity directly, not `visiblePartners`, so it never sees that
|
|
6
|
+
// filter unless told), the country label, and which route a museum or an
|
|
7
|
+
// institution goes to. This exhibition-shaped `partnerSpecs.js` never
|
|
8
|
+
// carried an objects-page spec (no `PartnerObjects.vue` counterpart built
|
|
9
|
+
// on it), unlike the gallery family's `partner.js`.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The exhibition's partner specs, over its data layer's `data`.
|
|
13
|
+
*
|
|
14
|
+
* Reads `data.defaultLang`, `data.isHiddenPartner`, `data.labelOf`,
|
|
15
|
+
* `data.partnerRoute`. `config` carries nothing today.
|
|
16
|
+
*/
|
|
17
|
+
export function useExhibitionPartner(data, config = {}) {
|
|
18
|
+
void config
|
|
19
|
+
const { defaultLang, isHiddenPartner, labelOf, partnerRoute } = data
|
|
20
|
+
|
|
21
|
+
// ── The list (Partners.vue) ─────────────────────────────────────────
|
|
22
|
+
//
|
|
23
|
+
// The DXA shape: no tier column at all — legacy's museum/institution
|
|
24
|
+
// split is a route, not a group here — an A–Z / Z–A toggle over the
|
|
25
|
+
// country groups, and every partner gets a row whether or not it holds
|
|
26
|
+
// anything (legacy's MWNF-384 branch). `variant: 'open'` keeps every
|
|
27
|
+
// group expanded, as legacy's page did with no accordion at all.
|
|
28
|
+
const partnerListSpec = {
|
|
29
|
+
scope: (partner) => !isHiddenPartner(partner),
|
|
30
|
+
group: { tier: false, order: 'country' },
|
|
31
|
+
variant: 'open',
|
|
32
|
+
orderToggle: true,
|
|
33
|
+
label: (countryId) => labelOf('countries', countryId),
|
|
34
|
+
route: (partner) => partnerRoute(partner),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── The profile (PartnerProfile.vue) ─────────────────────────────────
|
|
38
|
+
//
|
|
39
|
+
// No `fields`: legacy's Description/Contact/Logo tabs are three different
|
|
40
|
+
// shapes (Markdown, an address block, a bare image), not three rows of
|
|
41
|
+
// one sheet, so the panel is built in the view's slots over
|
|
42
|
+
// `text`/`record` directly rather than forced through `sheetRows`. No
|
|
43
|
+
// `citation`/`related` either — legacy's partner page has neither.
|
|
44
|
+
const partnerSheetSpec = {
|
|
45
|
+
entity: 'partners',
|
|
46
|
+
route: (partner) => partnerRoute(partner),
|
|
47
|
+
citation: false,
|
|
48
|
+
related: false,
|
|
49
|
+
media: (record, { language }) =>
|
|
50
|
+
[...(record.images ?? [])]
|
|
51
|
+
.sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
|
|
52
|
+
.map((p) => ({
|
|
53
|
+
url: p.url,
|
|
54
|
+
alt: labelOf('partners', record.id),
|
|
55
|
+
caption: p.captions?.[language] ?? p.captions?.[defaultLang] ?? '',
|
|
56
|
+
photographer: p.photographer ?? '',
|
|
57
|
+
copyright: p.copyright ?? '',
|
|
58
|
+
})),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { partnerListSpec, partnerSheetSpec }
|
|
62
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The item sheet, as a spec: what viewer-layout's `RecordView` renders on
|
|
2
|
+
// `/item/:id`. The mechanics — which language the record is read in, what
|
|
3
|
+
// is loaded for it, the glossary terms it reaches, how a field becomes a
|
|
4
|
+
// row, the credits, the citation, the related records — are the platform's.
|
|
5
|
+
// What is declared here is only what is an exhibition's: the field
|
|
6
|
+
// specification (legacy DatabaseItem.vue's `objectData`, field for field),
|
|
7
|
+
// the photos in their order, the related tile, and the citation whose
|
|
8
|
+
// project name follows an exhibition's own rule. Every label is an entry
|
|
9
|
+
// name, written out so the check that every name resolves can read it.
|
|
10
|
+
//
|
|
11
|
+
// Order and labels are the legacy sheet's. Empty values are dropped by the
|
|
12
|
+
// engine, as legacy's `filterData` did. `notice` and `notice_c` were never
|
|
13
|
+
// imported; `notice_b` is the image rights statement, imported as
|
|
14
|
+
// `extra.copyright` and rendered as the last row, where legacy put its own
|
|
15
|
+
// block. The labels are in the language the visitor reads the website in —
|
|
16
|
+
// where it differs from the record's (a deep link into a borrowed record in
|
|
17
|
+
// a language an exhibition does not offer) they fall back to English,
|
|
18
|
+
// which is where legacy pinned them anyway.
|
|
19
|
+
//
|
|
20
|
+
// Legacy shows both descriptions when both exist, with the short one
|
|
21
|
+
// collapsed behind a toggle (the view's, folded after the description);
|
|
22
|
+
// when only one exists it is relabelled plain "Description". EPM records
|
|
23
|
+
// are the common case of the latter.
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The exhibition's item-sheet spec, over its data layer's `data`.
|
|
27
|
+
*
|
|
28
|
+
* Reads `data.defaultLang`, `data.itemRoute`, `data.labelOf`,
|
|
29
|
+
* `data.mdInline`, `data.mdStrip`, `data.partnerById`, `data.projectName`,
|
|
30
|
+
* `data.tr`, `data.translations`. `config` carries nothing today.
|
|
31
|
+
*/
|
|
32
|
+
export function useExhibitionSheet(data, config = {}) {
|
|
33
|
+
void config
|
|
34
|
+
const { defaultLang, itemRoute, labelOf, mdInline, mdStrip, partnerById, projectName, tr, translations } = data
|
|
35
|
+
|
|
36
|
+
const bothDescriptions = (c) => Boolean(c.text.description) && Boolean(c.text.short_description)
|
|
37
|
+
|
|
38
|
+
const dynastyNames = (c) =>
|
|
39
|
+
(c.record.dynasty_ids ?? [])
|
|
40
|
+
.map((id) => translations('dynasties', c.language)[id]?.name ?? translations('dynasties', defaultLang)[id]?.name ?? '')
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
.join(', ')
|
|
43
|
+
|
|
44
|
+
const fields = [
|
|
45
|
+
{ key: 'name', label: 'sheet.field.name', value: 'name' },
|
|
46
|
+
{ key: 'aka', label: 'sheet.field.alsoKnownAs', value: 'alternate_name' },
|
|
47
|
+
{ key: 'location', label: 'sheet.field.location', value: (c) => [c.text.location, labelOf('countries', c.record.country_id)].filter(Boolean).join(', ') },
|
|
48
|
+
// A link to the partner's page, rendered by the wrapper's `museum` slot.
|
|
49
|
+
{ key: 'museum', label: 'sheet.field.holdingMuseum', value: (c) => (partnerById.value.get(c.record.partner_id) ? c.record.partner_id : ''), render: 'custom' },
|
|
50
|
+
{ key: 'originalOwner', label: 'sheet.field.originalOwner', value: 'initial_owner' },
|
|
51
|
+
{ key: 'currentOwner', label: 'sheet.field.currentOwner', value: 'owner' },
|
|
52
|
+
{ key: 'date', label: 'sheet.field.date', value: 'dates' },
|
|
53
|
+
{ key: 'artist', label: 'sheet.field.artists', value: (c) => c.record.artist_names, join: ', ' },
|
|
54
|
+
{ key: 'scribe', label: 'sheet.field.scribe', value: 'scriber' },
|
|
55
|
+
{ key: 'workshop', label: 'sheet.field.workshop', value: 'workshop' },
|
|
56
|
+
{ key: 'type', label: 'sheet.field.type', value: 'type' },
|
|
57
|
+
{ key: 'inventoryNumber', label: 'sheet.field.inventoryNumber', value: (c) => c.record.owner_reference },
|
|
58
|
+
{ key: 'materials', label: 'sheet.field.materials', value: (c) => c.text.materials, join: '; ' },
|
|
59
|
+
{ key: 'dimensions', label: 'sheet.field.dimensions', value: 'dimensions' },
|
|
60
|
+
{ key: 'dynasty', label: 'sheet.field.periodDynasty', value: dynastyNames },
|
|
61
|
+
{ key: 'production', label: 'sheet.field.placeOfProduction', value: 'place_of_production' },
|
|
62
|
+
{ key: 'provenance', label: 'sheet.field.provenance', value: 'provenance' },
|
|
63
|
+
{ key: 'binding', label: 'sheet.field.binding', value: 'binding_desc' },
|
|
64
|
+
{ key: 'description', label: 'sheet.field.description', value: 'description', render: 'block' },
|
|
65
|
+
// The only description there is, under the plain label; the toggled one
|
|
66
|
+
// is the view's, folded after `description` when both exist.
|
|
67
|
+
{ key: 'shortDescription', label: 'sheet.field.description', value: 'short_description', render: 'block', when: (c) => !bothDescriptions(c) },
|
|
68
|
+
{ key: 'catalogue', label: 'sheet.field.catalogueLink', value: 'linkcatalogs', render: 'link' },
|
|
69
|
+
{ key: 'obtention', label: 'sheet.field.obtentionMethod', value: 'obtention' },
|
|
70
|
+
{ key: 'datation', label: 'sheet.field.datationMethod', value: 'method_for_datation' },
|
|
71
|
+
{ key: 'provenanceMethod', label: 'sheet.field.provenanceMethod', value: 'method_for_provenance' },
|
|
72
|
+
{ key: 'bibliography', label: 'sheet.field.bibliography', value: 'bibliography', render: 'block' },
|
|
73
|
+
{ key: 'copyright', label: 'sheet.field.copyrightInformation', value: 'copyright' },
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
const itemSheet = {
|
|
77
|
+
entity: 'items',
|
|
78
|
+
translations: ['glossary', 'dynasties', 'partners'],
|
|
79
|
+
// Attribution names are language-independent, but the importer files
|
|
80
|
+
// `author` / `copy_editor` for EPM records on the Arabic row only (a
|
|
81
|
+
// known gap, recorded in the exporter's README). Legacy printed them on
|
|
82
|
+
// every sheet, so the platform reads them off another row when the
|
|
83
|
+
// active one has neither — the one place a page reads across
|
|
84
|
+
// languages, and only for proper names.
|
|
85
|
+
attribution: ['author', 'copy_editor'],
|
|
86
|
+
fields,
|
|
87
|
+
layout: 'list',
|
|
88
|
+
shortDescription: 'short_description',
|
|
89
|
+
shortDescriptionAfter: 'description',
|
|
90
|
+
media: (record, { language }) =>
|
|
91
|
+
[...(record.images ?? [])]
|
|
92
|
+
.sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
|
|
93
|
+
.map((p) => ({
|
|
94
|
+
url: p.url,
|
|
95
|
+
alt: labelOf('items', record.id),
|
|
96
|
+
caption: p.captions?.[language] ?? p.captions?.[defaultLang] ?? '',
|
|
97
|
+
photographer: p.photographer ?? '',
|
|
98
|
+
copyright: p.copyright ?? '',
|
|
99
|
+
})),
|
|
100
|
+
citation: {
|
|
101
|
+
permalink: false,
|
|
102
|
+
heading: 'record.citation.ofThisPage',
|
|
103
|
+
// The manifest's own project name for this record, read in the
|
|
104
|
+
// record's own display language, not the site's active one — a
|
|
105
|
+
// record legacy left nameless (the Explore monuments database) still
|
|
106
|
+
// cites nothing.
|
|
107
|
+
project: (record, { language }) => projectName(record, language),
|
|
108
|
+
},
|
|
109
|
+
related: {
|
|
110
|
+
variant: 'grid',
|
|
111
|
+
heading: 'exhibition.related.objects',
|
|
112
|
+
actionLabel: 'exhibition.action.seeDatabaseEntry',
|
|
113
|
+
record: ({ record: other, justification }) => ({
|
|
114
|
+
id: other.id,
|
|
115
|
+
image: other.images?.[0]?.url ?? '',
|
|
116
|
+
imageAlt: labelOf('items', other.id),
|
|
117
|
+
name: mdInline(tr('items', other.id, defaultLang).name ?? other.internal_name ?? ''),
|
|
118
|
+
meta: [labelOf('countries', other.country_id), justification ? mdStrip(justification) : ''].filter(Boolean),
|
|
119
|
+
to: itemRoute(other),
|
|
120
|
+
}),
|
|
121
|
+
},
|
|
122
|
+
route: 'item',
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { itemSheet }
|
|
126
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { computed } from 'vue'
|
|
2
|
+
import { eraLabel, roundOutward, yearBucketsFromRange } from '../../catalogue/dates.js'
|
|
3
|
+
import { PAGE_SIZE } from '../shared.js'
|
|
4
|
+
|
|
5
|
+
export { eraLabel, roundOutward }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The exhibition's timeline spec, over its data layer's `data` and the
|
|
9
|
+
* `PAGE_SIZE`/`tile` its {@link import('./useExhibitionCollection.js').useExhibitionCollection}
|
|
10
|
+
* already declared.
|
|
11
|
+
*
|
|
12
|
+
* Reads `data.exhibition`, `data.timelines`, `data.timelineEvents`,
|
|
13
|
+
* `data.countries`, `data.countryById`, `data.items`, `data.labelOf`,
|
|
14
|
+
* `data.tr`, `data.defaultLang`. `config` carries nothing today.
|
|
15
|
+
*/
|
|
16
|
+
export function useExhibitionTimeline(data, collection, config = {}) {
|
|
17
|
+
void config
|
|
18
|
+
const { exhibition, timelines, timelineEvents, countries, countryById, items, labelOf, tr, defaultLang } = data
|
|
19
|
+
const { tile } = collection
|
|
20
|
+
|
|
21
|
+
// ── Which chronology this site's Timeline section shows ─────────────────
|
|
22
|
+
//
|
|
23
|
+
// An exhibition package carries BOTH chronologies, because the live
|
|
24
|
+
// instance serves both: `/events` answers the worldwide country merge on
|
|
25
|
+
// every DXA site, and `/thg/timeline` answers the exhibition's own
|
|
26
|
+
// narrative one. What decides which the *Timeline pages* show is
|
|
27
|
+
// `hasCountryBasedTimeline`: legacy's TimelinePage sets
|
|
28
|
+
// `countriesAvailable` from it and TimelineResults switches its endpoint
|
|
29
|
+
// on it (`timelineURL = "/thg/timeline?hash="` in the false branch).
|
|
30
|
+
//
|
|
31
|
+
// Both flags may be false for an exhibition, and that does NOT mean "no
|
|
32
|
+
// timeline data". `timelines.json` still ships the worldwide merge in
|
|
33
|
+
// full — every DXA site gets the worldwide merge whatever its flags say —
|
|
34
|
+
// possibly with no `thg_local` row at all. The flags gate NAVIGATION, not
|
|
35
|
+
// data.
|
|
36
|
+
//
|
|
37
|
+
// The `thg_local` row has no `country_id`, which is exactly why it must
|
|
38
|
+
// be separated by `source` rather than left to the country filter: on
|
|
39
|
+
// "All Countries" it would otherwise be interleaved into the worldwide
|
|
40
|
+
// list.
|
|
41
|
+
const localTimeline = computed(
|
|
42
|
+
() => timelines.value.find((t) => t.source === 'thg_local') ?? null,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
/** True when the Timeline section is the exhibition's own chronology. */
|
|
46
|
+
const usesLocalTimeline = computed(
|
|
47
|
+
() => !exhibition.value?.has_country_timeline && Boolean(localTimeline.value),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether this site offers a Timeline at all — the one flag every piece
|
|
52
|
+
* of timeline chrome is gated on.
|
|
53
|
+
*
|
|
54
|
+
* Legacy hides more than the nav entry when both chronology flags are
|
|
55
|
+
* false, which is only visible on a site that has them both false. The
|
|
56
|
+
* ROUTES stay reachable, because legacy's do: typing /timeline on a live
|
|
57
|
+
* instance with both flags false still renders the page and its
|
|
58
|
+
* introduction. Only the links into it are withheld.
|
|
59
|
+
*/
|
|
60
|
+
const hasTimeline = computed(
|
|
61
|
+
() => Boolean(exhibition.value?.has_timeline || exhibition.value?.has_country_timeline),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
/** The events this site's manual Timeline reads (ItemSheet's own popout). */
|
|
65
|
+
const eventPool = computed(() => {
|
|
66
|
+
const local = localTimeline.value
|
|
67
|
+
if (usesLocalTimeline.value) {
|
|
68
|
+
return timelineEvents.value.filter((e) => e.timeline_id === local.id)
|
|
69
|
+
}
|
|
70
|
+
if (!local) return timelineEvents.value
|
|
71
|
+
return timelineEvents.value.filter((e) => e.timeline_id !== local.id)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// The global country timeline, served by legacy `/v2/events`. It is
|
|
75
|
+
// country-scoped and project-independent, and also a MERGE of two
|
|
76
|
+
// chronologies rather than one table: legacy's `App\MWNF\DAO\v2\Events`
|
|
77
|
+
// unions `mwnf3.hcr` (the Discover Islamic Art country chronologies) with
|
|
78
|
+
// `mwnf3_sharing_history.sh_hcr` restricted to exhibition 2, "Political
|
|
79
|
+
// Context", and sorts the result by year. The package mirrors that: each
|
|
80
|
+
// row of `timelines.json` is tagged `source: 'mwnf3' | 'sharing_history'`,
|
|
81
|
+
// and `timeline_events.json` keys every event by `country_id`.
|
|
82
|
+
//
|
|
83
|
+
// A country can be served by BOTH sources, so anything user-facing must
|
|
84
|
+
// key on the country and never on the timeline row: `findEvents` filters
|
|
85
|
+
// on `country_id`, which is what merges the two chronologies into one
|
|
86
|
+
// year-ordered list the way legacy did.
|
|
87
|
+
//
|
|
88
|
+
// Names and legacy codes both come from countries.json. The exporter
|
|
89
|
+
// scopes that file to "member item countries ∪ their holders' countries ∪
|
|
90
|
+
// the global timeline's countries", so it covers every timeline country —
|
|
91
|
+
// the `Intl.DisplayNames` fallback below is therefore dead code for them,
|
|
92
|
+
// and is kept only so a regressed package degrades to a rendered ISO code
|
|
93
|
+
// rather than a raw id. The `code` countries.json ships is the country's
|
|
94
|
+
// `backward_compatibility`, which is exactly the code legacy's own
|
|
95
|
+
// timeline URLs used, including the ones that are not ISO 3166-1
|
|
96
|
+
// alpha-2 (`uk`, `pa`, `qt`, `rm`, `sb`, `ua`, …). Those are the reason
|
|
97
|
+
// the fallback must stay unreachable rather than merely rare: read as
|
|
98
|
+
// ISO, `ua` is Ukraine and `sb` is the Solomon Islands, where legacy
|
|
99
|
+
// means the UAE and Serbia. Only countries.json can name them correctly.
|
|
100
|
+
const regionNames = (() => {
|
|
101
|
+
try {
|
|
102
|
+
return new Intl.DisplayNames(['en'], { type: 'region' })
|
|
103
|
+
} catch {
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
})()
|
|
107
|
+
|
|
108
|
+
// Two legacy codes are not ISO 3166-1 alpha-2.
|
|
109
|
+
const LEGACY_TO_ISO = { uk: 'GB', pa: 'PS' }
|
|
110
|
+
|
|
111
|
+
// The set of inventory country ids present in countries.json. Ids and
|
|
112
|
+
// legacy codes are distinct — legacy codes are backward_compatibility
|
|
113
|
+
// values, while ids are the canonical keys in the inventory system.
|
|
114
|
+
const countryIdSet = computed(
|
|
115
|
+
() => new Set(countries.value.map((c) => c.id)),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
// A lookup, not a parse. The fallback exists only for the
|
|
119
|
+
// regressed-package case described above, and it must agree with
|
|
120
|
+
// GLOBAL_TIMELINE_LIKE_PATTERNS in the exporter's timeline exporter: the
|
|
121
|
+
// country sits after the literal `country` segment in BOTH keyspaces
|
|
122
|
+
// (`mwnf3:hcr:country:<cc>` and
|
|
123
|
+
// `mwnf3_sharing_history:sh_hcr:country:<cc>:exhibition:2`), and it is
|
|
124
|
+
// the last segment in only the first — taking the last one yields `2` on
|
|
125
|
+
// the second.
|
|
126
|
+
function legacyCodeOf(timeline) {
|
|
127
|
+
const fromPackage = countryById.value.get(timeline.country_id)?.code
|
|
128
|
+
if (fromPackage) return fromPackage
|
|
129
|
+
const parts = (timeline.backward_compatibility ?? '').split(':')
|
|
130
|
+
const at = parts.indexOf('country')
|
|
131
|
+
return (at >= 0 ? parts[at + 1] : null) || timeline.country_id
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function nameFor(timeline) {
|
|
135
|
+
const fromPackage = countries.value.some((c) => c.id === timeline.country_id)
|
|
136
|
+
? labelOf('countries', timeline.country_id)
|
|
137
|
+
: null
|
|
138
|
+
if (fromPackage) return fromPackage
|
|
139
|
+
const legacy = legacyCodeOf(timeline)
|
|
140
|
+
const iso = LEGACY_TO_ISO[legacy] ?? String(legacy).toUpperCase()
|
|
141
|
+
try {
|
|
142
|
+
return regionNames?.of(iso) ?? iso
|
|
143
|
+
} catch {
|
|
144
|
+
return iso
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Countries that actually have a chronology, alphabetized, "All" first.
|
|
150
|
+
*
|
|
151
|
+
* One entry per COUNTRY, not per timeline row: a country served by BOTH
|
|
152
|
+
* `mwnf3` and `sharing_history` has two rows, and would otherwise appear
|
|
153
|
+
* twice in every country picker on the site. Only the item sheet's own
|
|
154
|
+
* manual "Timeline for this item" picker reads this now — `timelineSpec`
|
|
155
|
+
* below leaves the picker to viewer-core's `useTimelineEvents`.
|
|
156
|
+
*/
|
|
157
|
+
const timelineCountries = computed(() => {
|
|
158
|
+
// No picker at all when the section is the exhibition's own chronology
|
|
159
|
+
// — legacy hides the select on `countriesAvailable === false`.
|
|
160
|
+
if (usesLocalTimeline.value) return []
|
|
161
|
+
const byCountry = new Map()
|
|
162
|
+
for (const timeline of timelines.value) {
|
|
163
|
+
if (!timeline.country_id || byCountry.has(timeline.country_id)) continue
|
|
164
|
+
byCountry.set(timeline.country_id, [legacyCodeOf(timeline), nameFor(timeline)])
|
|
165
|
+
}
|
|
166
|
+
const rows = [...byCountry.values()].sort((a, b) => a[1].localeCompare(b[1]))
|
|
167
|
+
return [['all', 'All Countries'], ...rows]
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
/** Display name for an event's country — also `timelineSpec`'s `countryLabel`. */
|
|
171
|
+
function timelineCountryName(countryId) {
|
|
172
|
+
if (countries.value.some((c) => c.id === countryId)) return labelOf('countries', countryId)
|
|
173
|
+
const timeline = timelines.value.find((t) => t.country_id === countryId)
|
|
174
|
+
return timeline ? nameFor(timeline) : countryId
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Legacy 2-letter code → the inventory country id the events are keyed
|
|
179
|
+
* by. A country served by both chronologies has two rows carrying the
|
|
180
|
+
* same code and the same `country_id`, so either row answers. Idempotent
|
|
181
|
+
* on an id already: `TimelineResultsView`'s own country picker offers
|
|
182
|
+
* ids, so this must resolve BOTH the legacy code a bookmarked or
|
|
183
|
+
* cross-page link carries and the id the picker itself round-trips.
|
|
184
|
+
*/
|
|
185
|
+
function countryIdForCode(code) {
|
|
186
|
+
if (!code || code === 'all') return null
|
|
187
|
+
if (countryIdSet.value.has(code)) return code
|
|
188
|
+
if (countryById.value.has(code)) return code
|
|
189
|
+
const timeline = timelines.value.find((t) => legacyCodeOf(t) === code)
|
|
190
|
+
if (timeline) return timeline.country_id
|
|
191
|
+
// Countries with no chronology still reach here from the collection
|
|
192
|
+
// page's "Timeline for this Search" link, which uses countries.json's
|
|
193
|
+
// own codes.
|
|
194
|
+
return countries.value.find((c) => c.code === code)?.id ?? null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const eventsTr = (id) => tr('timeline_events', id, defaultLang)
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Legacy's `/events?ic[]=&ya=&yo=` — events for a country within a year
|
|
201
|
+
* range, ordered chronologically. Kept for the item sheet's own "Timeline
|
|
202
|
+
* for this item" popout, which reads a single item's country and dates
|
|
203
|
+
* rather than a URL's filters and has no results page of its own to hand
|
|
204
|
+
* to `TimelineResultsView`.
|
|
205
|
+
*/
|
|
206
|
+
function findEvents({ countryCode, start, end }) {
|
|
207
|
+
const countryId = countryIdForCode(countryCode)
|
|
208
|
+
const from = start === '' || start == null ? null : Number(start)
|
|
209
|
+
const to = end === '' || end == null ? null : Number(end)
|
|
210
|
+
|
|
211
|
+
return eventPool.value
|
|
212
|
+
.filter((e) => {
|
|
213
|
+
if (countryId && e.country_id !== countryId) return false
|
|
214
|
+
const year = e.year_from
|
|
215
|
+
if (!Number.isFinite(year)) return false
|
|
216
|
+
if (from != null && year < from) return false
|
|
217
|
+
if (to != null && year > to) return false
|
|
218
|
+
return true
|
|
219
|
+
})
|
|
220
|
+
.map((e) => ({
|
|
221
|
+
...e,
|
|
222
|
+
countryName: timelineCountryName(e.country_id),
|
|
223
|
+
text: eventsTr(e.id),
|
|
224
|
+
}))
|
|
225
|
+
.sort((a, b) => (a.year_from - b.year_from) || (a.display_order ?? 0) - (b.display_order ?? 0))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── The Timeline entrance/results spec ───────────────────────────────
|
|
229
|
+
//
|
|
230
|
+
// Feeds `TimelineResultsView`: the engine (viewer-core's
|
|
231
|
+
// `useTimelineEvents`) owns the country/local axis, the year buckets and
|
|
232
|
+
// the pagination; an exhibition supplies only what is its own — the
|
|
233
|
+
// legacy country-code table, the local/country scope switch and the
|
|
234
|
+
// controls it drives (no country picker when the section is the
|
|
235
|
+
// exhibition's own chronology), and the "See Gallery" join against
|
|
236
|
+
// member items, which stays here because an item's own shape (what
|
|
237
|
+
// counts as "in this period") is the site's, not the package's.
|
|
238
|
+
|
|
239
|
+
function yearOptions({ years, t }) {
|
|
240
|
+
return yearBucketsFromRange(years.min, years.max, t)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The member items overlapping a country and period, for the gallery link. */
|
|
244
|
+
function galleryItems({ filters }) {
|
|
245
|
+
const countryId = countryIdForCode(filters.country)
|
|
246
|
+
const from = filters.begin ? Number(filters.begin) : null
|
|
247
|
+
const to = filters.end ? Number(filters.end) : null
|
|
248
|
+
return items.value.filter((i) => {
|
|
249
|
+
if (countryId && i.country_id !== countryId) return false
|
|
250
|
+
const itemStart = i.start_date
|
|
251
|
+
const itemEnd = i.end_date ?? i.start_date
|
|
252
|
+
if (!Number.isFinite(itemStart)) return false
|
|
253
|
+
if (from != null && itemEnd < from) return false
|
|
254
|
+
if (to != null && itemStart > to) return false
|
|
255
|
+
return true
|
|
256
|
+
}).length
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const timelineSpec = computed(() => ({
|
|
260
|
+
scope: usesLocalTimeline.value ? 'local' : 'country',
|
|
261
|
+
countryLabel: timelineCountryName,
|
|
262
|
+
countryIdForCode,
|
|
263
|
+
tr: eventsTr,
|
|
264
|
+
controls: usesLocalTimeline.value
|
|
265
|
+
? [{ key: 'begin', options: yearOptions }, { key: 'end', options: yearOptions }]
|
|
266
|
+
: [{ key: 'country' }, { key: 'begin', options: yearOptions }, { key: 'end', options: yearOptions }],
|
|
267
|
+
route: 'timeline-results',
|
|
268
|
+
gallery: { route: 'timeline-gallery', items: galleryItems },
|
|
269
|
+
}))
|
|
270
|
+
|
|
271
|
+
// ── The gallery spec ────────────────────────────────────────────────
|
|
272
|
+
//
|
|
273
|
+
// `CatalogueResultsView` over the same country/period join `galleryItems`
|
|
274
|
+
// counts for the "See Gallery" link above — the query keys are the
|
|
275
|
+
// results spec's own `begin`/`end` (viewer-layout's `TimelineResultsView`
|
|
276
|
+
// renders those controls by that name, not `start`/`end`), so the legacy
|
|
277
|
+
// `/timeline-gallery/:country/:start/:end/:page` redirect is renamed to
|
|
278
|
+
// match on the way in (the site's own `dataset.config.js`).
|
|
279
|
+
const timelineGallerySpec = {
|
|
280
|
+
entity: 'items',
|
|
281
|
+
keys: ['country', 'begin', 'end'],
|
|
282
|
+
scope: (item, filters) => {
|
|
283
|
+
if (!Number.isFinite(item.start_date)) return false
|
|
284
|
+
const countryId = countryIdForCode(filters.country)
|
|
285
|
+
return !countryId || item.country_id === countryId
|
|
286
|
+
},
|
|
287
|
+
dates: { mode: 'overlap', begin: 'begin', end: 'end' },
|
|
288
|
+
sort: { undated: 'first' },
|
|
289
|
+
pageSize: PAGE_SIZE,
|
|
290
|
+
variant: 'grid',
|
|
291
|
+
recordRoute: 'item',
|
|
292
|
+
actionLabel: 'exhibition.action.seeDatabaseEntry',
|
|
293
|
+
empty: 'exhibition.results.noObjectsInPeriod',
|
|
294
|
+
record: (item, { t }) => tile(item, t),
|
|
295
|
+
summary: ({ filters, pageInfo, t }) => [
|
|
296
|
+
{
|
|
297
|
+
label: t('timeline.results.galleryHeading'),
|
|
298
|
+
value: filters.country
|
|
299
|
+
? timelineCountryName(countryIdForCode(filters.country))
|
|
300
|
+
: t('timeline.form.allCountries'),
|
|
301
|
+
},
|
|
302
|
+
{ count: pageInfo.total, value: t('catalogue.results.objects') },
|
|
303
|
+
],
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
usesLocalTimeline, hasTimeline, timelineCountries, timelineCountryName, countryIdForCode,
|
|
308
|
+
findEvents, timelineSpec, timelineGallerySpec,
|
|
309
|
+
}
|
|
310
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { projectLabel } from '../../conventions.js'
|
|
2
|
+
import { DATE_MODE, FACET_CATEGORIES, PAGE_SIZE } from '../shared.js'
|
|
3
|
+
|
|
4
|
+
export { DATE_MODE, FACET_CATEGORIES, FACET_LABEL_KEYS, PAGE_SIZE, useFacetLabels } from '../shared.js'
|
|
5
|
+
|
|
6
|
+
// The catalogue spec: what a DXA gallery's lists filter and search on. The
|
|
7
|
+
// engine — query state, options, dates, pages, the boolean grammar — is
|
|
8
|
+
// viewer-core's own (`useFacets`, `useListQuery`, …); what is declared here
|
|
9
|
+
// is only what is a gallery's: the facet *predicates* over its own data,
|
|
10
|
+
// the haystack the search bar reads, and the tile a record becomes.
|
|
11
|
+
// `PAGE_SIZE`/`DATE_MODE`/`FACET_CATEGORIES`/`FACET_LABEL_KEYS`/
|
|
12
|
+
// `useFacetLabels` are shared verbatim with the exhibition family — see
|
|
13
|
+
// `../shared.js`.
|
|
14
|
+
//
|
|
15
|
+
// Legacy asked the API for everything: `/items?ic[]=…&id[]=…&na=…&nz=…` for
|
|
16
|
+
// the results, and `/items/countries`, `/items/tags`, `/items/years` *with
|
|
17
|
+
// the same filters applied* for the dropdowns — which is what made the
|
|
18
|
+
// facets dependent (pick a country, and the type list shrinks to the types
|
|
19
|
+
// still reachable). The results page hands the engine the matching subset
|
|
20
|
+
// for that reason; the entrance hands it everything.
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The gallery's collection spec, over its own data layer's `data` (the
|
|
24
|
+
* return value of {@link import('./useGalleryData.js').useGalleryData}).
|
|
25
|
+
*
|
|
26
|
+
* Reads `data.countries`, `data.tags`, `data.countryById`, `data.tagById`,
|
|
27
|
+
* `data.labelOf`, `data.itemRoute`, `data.tr`, `data.defaultLang`,
|
|
28
|
+
* `data.mdInline` and `data.manifest`. `config` carries nothing today.
|
|
29
|
+
*/
|
|
30
|
+
export function useGalleryCollection(data, config = {}) {
|
|
31
|
+
void config
|
|
32
|
+
const { countries, tags, countryById, tagById, labelOf, itemRoute, tr, defaultLang, mdInline, manifest } = data
|
|
33
|
+
|
|
34
|
+
// ── What the URL carries ─────────────────────────────────────────────
|
|
35
|
+
//
|
|
36
|
+
// The legacy 2-letter country code for `country`, the legacy tag id for a
|
|
37
|
+
// facet: the values legacy's URLs carried, so a link shared then still
|
|
38
|
+
// resolves now.
|
|
39
|
+
|
|
40
|
+
function countryIdForCode(code) {
|
|
41
|
+
return code ? (countries.value ?? []).find((c) => c.code === code)?.id ?? null : null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function tagIdForLegacy(legacyId) {
|
|
45
|
+
return legacyId ? (tags.value ?? []).find((t) => t.legacy_tag_id === legacyId)?.id ?? null : null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function tagLabelForLegacy(legacyId) {
|
|
49
|
+
return (tags.value ?? []).find((t) => t.legacy_tag_id === legacyId)?.label ?? legacyId
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The facet spec for viewer-core's `useFacets`: the country by code, each
|
|
54
|
+
* tag category by legacy id, labels upper-cased on the first letter as
|
|
55
|
+
* legacy printed them.
|
|
56
|
+
*/
|
|
57
|
+
const FACETS = {
|
|
58
|
+
country: {
|
|
59
|
+
values: (item) => countryById.value.get(item.country_id)?.code ?? null,
|
|
60
|
+
label: (code) => labelOf('countries', countryIdForCode(code)),
|
|
61
|
+
},
|
|
62
|
+
...Object.fromEntries(
|
|
63
|
+
FACET_CATEGORIES.map((category) => [
|
|
64
|
+
category,
|
|
65
|
+
{
|
|
66
|
+
values: (item) =>
|
|
67
|
+
(item.tag_ids ?? [])
|
|
68
|
+
.map((id) => tagById.value.get(id))
|
|
69
|
+
.filter((tag) => tag && tag.category === category)
|
|
70
|
+
.map((tag) => tag.legacy_tag_id),
|
|
71
|
+
label: tagLabelForLegacy,
|
|
72
|
+
capitalize: true,
|
|
73
|
+
},
|
|
74
|
+
]),
|
|
75
|
+
),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The legacy `/items` predicate's tag half: every requested tag, ANDed. */
|
|
79
|
+
function hasEveryTag(item, tagIds) {
|
|
80
|
+
return tagIds.every((id) => item.tag_ids?.includes(id))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── The search bar ───────────────────────────────────────────────────
|
|
84
|
+
//
|
|
85
|
+
// Legacy ran MySQL boolean full-text search over the English sheet; the
|
|
86
|
+
// haystack is the same set of fields, and the grammar is viewer-core's.
|
|
87
|
+
|
|
88
|
+
function haystack(item, text) {
|
|
89
|
+
return [
|
|
90
|
+
text.name, text.description, text.short_description, text.type, text.holder, text.dates,
|
|
91
|
+
text.location, text.provenance, text.alternate_name, text.place_of_production,
|
|
92
|
+
...(text.keywords ?? []), ...(text.materials ?? []),
|
|
93
|
+
item.internal_name, item.owner_reference, item.mwnf_reference,
|
|
94
|
+
labelOf('partners', item.partner_id), labelOf('countries', item.country_id),
|
|
95
|
+
]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── The tile ──────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A record as viewer-layout's grid contract: the thumbnail, the name, the
|
|
102
|
+
* lines legacy's hover card carried (date, holder, place, source
|
|
103
|
+
* project).
|
|
104
|
+
*/
|
|
105
|
+
function tile(item, t) {
|
|
106
|
+
const text = tr('items', item.id, defaultLang)
|
|
107
|
+
return {
|
|
108
|
+
id: item.id,
|
|
109
|
+
image: item.images?.[0]?.url ?? '',
|
|
110
|
+
imageAlt: labelOf('items', item.id),
|
|
111
|
+
name: mdInline(text.name ?? item.internal_name ?? ''),
|
|
112
|
+
meta: [
|
|
113
|
+
text.dates ?? '',
|
|
114
|
+
labelOf('partners', item.partner_id),
|
|
115
|
+
[text.location, labelOf('countries', item.country_id)].filter(Boolean).join(', '),
|
|
116
|
+
`${t('catalogue.results.forProject')} ${projectLabel(manifest, item.project_id, defaultLang) ?? ''}`,
|
|
117
|
+
].filter(Boolean),
|
|
118
|
+
to: itemRoute(item),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── The results page, as a spec ──────────────────────────────────────
|
|
123
|
+
//
|
|
124
|
+
// What viewer-layout's `CatalogueResultsView` renders on
|
|
125
|
+
// `/collection-results`: the facets over the *matching* records (the
|
|
126
|
+
// dependent dropdowns above), the containment date rule, undated first,
|
|
127
|
+
// nine tiles a page, legacy's "Collection | <selections>" summary line.
|
|
128
|
+
// The panel itself is composed by the view's wrapper, in the aside where
|
|
129
|
+
// legacy put it, so no controls are declared here. Every text is an entry
|
|
130
|
+
// name.
|
|
131
|
+
|
|
132
|
+
// `from`/`to`, not `start`/`end`: the entrance's `SearchFormView` spec
|
|
133
|
+
// (`mode: 'facets'`, `dates: 'buckets'`) writes the date bounds under
|
|
134
|
+
// those two fixed keys — the view's own, not something a spec can rename
|
|
135
|
+
// — so the results side has to read the same ones to stay linked to it.
|
|
136
|
+
const KEYS = ['country', ...FACET_CATEGORIES, 'from', 'to']
|
|
137
|
+
|
|
138
|
+
/** The filter summary line legacy printed as "Collection | <selections>". */
|
|
139
|
+
function filterSummary(filters, t) {
|
|
140
|
+
const parts = []
|
|
141
|
+
if (filters.country) parts.push(labelOf('countries', countryIdForCode(filters.country)))
|
|
142
|
+
for (const key of FACET_CATEGORIES) if (filters[key]) parts.push(tagLabelForLegacy(filters[key]))
|
|
143
|
+
if (filters.from) parts.push(`${t('catalogue.filter.from')} ${filters.from}`)
|
|
144
|
+
if (filters.to) parts.push(`${t('catalogue.filter.to')} ${filters.to}`)
|
|
145
|
+
return parts.filter(Boolean).join(' | ')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const collectionResults = {
|
|
149
|
+
entity: 'items',
|
|
150
|
+
keys: KEYS,
|
|
151
|
+
facets: FACETS,
|
|
152
|
+
facetScope: 'matching',
|
|
153
|
+
filterMode: 'immediate',
|
|
154
|
+
dates: { mode: DATE_MODE, begin: 'from', end: 'to' },
|
|
155
|
+
sort: { undated: 'first' },
|
|
156
|
+
pageSize: PAGE_SIZE,
|
|
157
|
+
variant: 'grid',
|
|
158
|
+
recordRoute: 'item',
|
|
159
|
+
actionLabel: 'gallery.action.seeDatabaseEntry',
|
|
160
|
+
empty: 'catalogue.results.noResults',
|
|
161
|
+
pagination: { jump: true },
|
|
162
|
+
|
|
163
|
+
record: (item, { t }) => tile(item, t),
|
|
164
|
+
|
|
165
|
+
summary: ({ filters, pageInfo, t, total }) => [
|
|
166
|
+
{ label: t('gallery.section.collection'), value: filterSummary(filters, t) },
|
|
167
|
+
{ count: pageInfo.total, value: `${t('catalogue.results.outOf')} ${total} ${t('catalogue.results.objects')}` },
|
|
168
|
+
],
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
FACETS, hasEveryTag, haystack, tile, collectionResults,
|
|
173
|
+
countryIdForCode, tagIdForLegacy, tagLabelForLegacy,
|
|
174
|
+
}
|
|
175
|
+
}
|