@museumwnf/viewer-core 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE.md +44 -44
  2. package/README.md +658 -635
  3. package/package.json +50 -50
  4. package/src/AppRoot.vue +58 -58
  5. package/src/catalogue/dates.js +209 -209
  6. package/src/catalogue/facets.js +61 -61
  7. package/src/catalogue/keywordIndex.js +289 -289
  8. package/src/catalogue/listQuery.js +100 -100
  9. package/src/catalogue/pagination.js +57 -57
  10. package/src/catalogue/searchLanguage.js +32 -32
  11. package/src/catalogue/timeline.js +220 -220
  12. package/src/composables/useCatalogueData.js +141 -141
  13. package/src/composables/useDataPackage.js +98 -98
  14. package/src/composables/useEntities.js +100 -100
  15. package/src/composables/useRecordLanguage.js +71 -71
  16. package/src/conventions.js +252 -148
  17. package/src/createViewer.js +29 -29
  18. package/src/i18n/I18nText.vue +17 -17
  19. package/src/i18n/I18nTextInline.vue +17 -17
  20. package/src/i18n/index.js +88 -88
  21. package/src/i18n/language.js +127 -127
  22. package/src/i18n/markdown.js +265 -265
  23. package/src/index.js +51 -50
  24. package/src/injectionKeys.js +1 -1
  25. package/src/languages.js +49 -49
  26. package/src/legacy/index.js +58 -58
  27. package/src/record/collectionTree.js +307 -307
  28. package/src/record/derivations.js +154 -154
  29. package/src/record/partners.js +87 -87
  30. package/src/record/recordSheet.js +231 -231
  31. package/src/rights.js +53 -53
  32. package/src/router/index.js +138 -138
  33. package/src/siteConfig.js +37 -37
  34. package/src/styles/base.css +49 -49
  35. package/src/testing/checkOfferedLanguages.js +67 -67
  36. package/src/testing/index.js +3 -3
  37. package/src/testing/smoke.js +103 -103
  38. package/src/testing/viteConfig.js +90 -90
  39. package/src/views/DetailView.vue +53 -53
  40. package/src/views/HomeView.vue +20 -20
  41. package/src/views/ListView.vue +61 -61
  42. package/src/views/NotFoundView.vue +8 -8
  43. package/src/vite.js +8 -8
@@ -1,154 +1,154 @@
1
- import { computed, ref, toValue } from 'vue'
2
- import { useDataPackage } from '../composables/useDataPackage.js'
3
- import { byId, entityRef } from '../composables/useEntities.js'
4
- import { renderPlain } from '../i18n/markdown.js'
5
- import { roundOutward } from '../catalogue/dates.js'
6
-
7
- const BASE_LANGUAGE = 'en'
8
-
9
- // Four derivations every record page makes, written seven times.
10
-
11
- // ── The glossary popup ─────────────────────────────────────────────────────
12
- //
13
- // The renderer marks every glossary spelling as
14
- // `<span class="gloss-term" data-gid="…">` while it parses. Those spans are
15
- // inside `v-html`, so nothing binds to them one by one: one listener on the
16
- // container reads the click and answers with the term.
17
-
18
- /**
19
- * `{ active, onClick, close }`: attach `onClick` to the element the sheet
20
- * renders into; `active` is `{ id, word, spelling, definition }` for the
21
- * term clicked, null otherwise. `entries` is the `glossary` list of
22
- * `useRecordSheet` (a ref, getter or array).
23
- */
24
- export function useGlossaryPopup(entries) {
25
- const active = ref(null)
26
- function onClick(event) {
27
- const element = event?.target?.closest?.('.gloss-term')
28
- if (!element) return
29
- event.preventDefault()
30
- const id = element.dataset.gid
31
- const entry = (toValue(entries) ?? []).find((e) => e.id === id)
32
- active.value = {
33
- id,
34
- spelling: element.textContent,
35
- word: entry?.word ?? element.textContent,
36
- definition: entry?.definition ?? '',
37
- }
38
- }
39
- function close() {
40
- active.value = null
41
- }
42
- return { active, onClick, close }
43
- }
44
-
45
- /**
46
- * The glossary terms whose spelling starts with `input`, in `language`
47
- * (falling back to English), for a glossary search box: `[{ id, word,
48
- * spelling, definition }]`, one per term, sorted by spelling.
49
- */
50
- export function searchGlossary(input, language, { entity = 'glossary' } = {}) {
51
- const needle = String(input ?? '').trim().toLowerCase()
52
- if (!needle) return []
53
- const { translations } = useDataPackage()
54
- const rows = translations(entity, language)
55
- const fallback = translations(entity, BASE_LANGUAGE)
56
- const out = []
57
- for (const term of entityRef(entity).value ?? []) {
58
- const t = rows[term.id] ?? fallback[term.id] ?? {}
59
- const spellings = t.spellings?.length ? t.spellings : [term.word]
60
- for (const spelling of spellings) {
61
- if (String(spelling ?? '').toLowerCase().startsWith(needle)) {
62
- out.push({ id: term.id, word: term.word, spelling: String(spelling).trim(), definition: t.definition ?? '' })
63
- break
64
- }
65
- }
66
- }
67
- return out.sort((a, b) => a.spelling.localeCompare(b.spelling))
68
- }
69
-
70
- // ── Citation ───────────────────────────────────────────────────────────────
71
-
72
- /**
73
- * The sentence both families print under a sheet:
74
- * `Author "Name" in Project, Publisher, Year. Permalink` — assembled here in
75
- * that order, from parts, rather than written into a text with holes in it.
76
- * `inWord` is the site's `t('record.citation.in')`. Parts that are missing
77
- * are left out with their punctuation.
78
- */
79
- export function citation({ author, name, project, publisher = 'Museum With No Frontiers', year, permalink, inWord = 'in' } = {}) {
80
- const title = renderPlain(name ?? '')
81
- if (!title) return ''
82
- const when = year ?? new Date().getFullYear()
83
- const where = [project, publisher].filter(Boolean).join(', ')
84
- const parts = []
85
- if (author) parts.push(String(author).trim())
86
- parts.push(`"${title}"`)
87
- if (where) parts.push(`${inWord} ${where},`)
88
- let sentence = `${parts.join(' ')} ${when}.`
89
- if (permalink) sentence += ` ${permalink}`
90
- return sentence
91
- }
92
-
93
- // ── Related records ────────────────────────────────────────────────────────
94
-
95
- /**
96
- * A record's `related_items`, split by whether the package carries them:
97
- * `inPackage` is `[{ reference, record, justification }]` with the
98
- * justification in `language` (falling back to English); `outside` is the
99
- * references the package does not hold, kept as the references they are — a
100
- * link is never dropped for being unresolvable, and never invented either.
101
- */
102
- export function relatedRecords(record, { entity = 'items', language = BASE_LANGUAGE, field = 'related_items' } = {}) {
103
- const index = byId(entity).value
104
- const inPackage = []
105
- const outside = []
106
- const seen = new Set()
107
- for (const reference of record?.[field] ?? []) {
108
- if (!reference || seen.has(reference.id)) continue
109
- seen.add(reference.id)
110
- const found = reference.in_package === false ? null : index.get(reference.id)
111
- if (!found) {
112
- outside.push(reference)
113
- continue
114
- }
115
- const justifications = reference.justifications ?? {}
116
- inPackage.push({
117
- reference,
118
- record: found,
119
- justification: justifications[language] ?? justifications[BASE_LANGUAGE] ?? '',
120
- })
121
- }
122
- return { inPackage, outside }
123
- }
124
-
125
- /** `relatedRecords` over reactive sources. */
126
- export function useRelatedRecords(record, options = {}) {
127
- return computed(() => relatedRecords(toValue(record), { ...options, language: toValue(options.language) ?? BASE_LANGUAGE }))
128
- }
129
-
130
- // ── The timeline link ──────────────────────────────────────────────────────
131
-
132
- /**
133
- * The route location of the timeline results for a record's country and
134
- * dates, or null when the record has neither a country nor a date. `name`
135
- * is the site's timeline-results route; `keys` the query keys it reads
136
- * (`country`, `begin`, `end` by default); `country(record)` the value the
137
- * route wants for the country (the id by default — a site whose timeline
138
- * reads legacy codes passes its own); `round` rounds the dates outward to
139
- * the century, as the DXA "Timeline for this item" does.
140
- */
141
- export function timelineLinkFor(record, { name = 'timeline-results', keys = {}, country, round = false } = {}) {
142
- if (!record) return null
143
- const countryValue = country ? country(record) : record.country_id
144
- const start = typeof record.start_date === 'number' ? record.start_date : null
145
- const end = typeof record.end_date === 'number' ? record.end_date : null
146
- if (!countryValue && start == null && end == null) return null
147
- let [from, to] = [start, end]
148
- if (round) [from, to] = roundOutward(start ?? undefined, end ?? undefined)
149
- const query = {}
150
- if (countryValue) query[keys.country ?? 'country'] = String(countryValue)
151
- if (from != null) query[keys.begin ?? 'begin'] = String(from)
152
- if (to != null) query[keys.end ?? 'end'] = String(to)
153
- return { name, query }
154
- }
1
+ import { computed, ref, toValue } from 'vue'
2
+ import { useDataPackage } from '../composables/useDataPackage.js'
3
+ import { byId, entityRef } from '../composables/useEntities.js'
4
+ import { renderPlain } from '../i18n/markdown.js'
5
+ import { roundOutward } from '../catalogue/dates.js'
6
+
7
+ const BASE_LANGUAGE = 'en'
8
+
9
+ // Four derivations every record page makes, written seven times.
10
+
11
+ // ── The glossary popup ─────────────────────────────────────────────────────
12
+ //
13
+ // The renderer marks every glossary spelling as
14
+ // `<span class="gloss-term" data-gid="…">` while it parses. Those spans are
15
+ // inside `v-html`, so nothing binds to them one by one: one listener on the
16
+ // container reads the click and answers with the term.
17
+
18
+ /**
19
+ * `{ active, onClick, close }`: attach `onClick` to the element the sheet
20
+ * renders into; `active` is `{ id, word, spelling, definition }` for the
21
+ * term clicked, null otherwise. `entries` is the `glossary` list of
22
+ * `useRecordSheet` (a ref, getter or array).
23
+ */
24
+ export function useGlossaryPopup(entries) {
25
+ const active = ref(null)
26
+ function onClick(event) {
27
+ const element = event?.target?.closest?.('.gloss-term')
28
+ if (!element) return
29
+ event.preventDefault()
30
+ const id = element.dataset.gid
31
+ const entry = (toValue(entries) ?? []).find((e) => e.id === id)
32
+ active.value = {
33
+ id,
34
+ spelling: element.textContent,
35
+ word: entry?.word ?? element.textContent,
36
+ definition: entry?.definition ?? '',
37
+ }
38
+ }
39
+ function close() {
40
+ active.value = null
41
+ }
42
+ return { active, onClick, close }
43
+ }
44
+
45
+ /**
46
+ * The glossary terms whose spelling starts with `input`, in `language`
47
+ * (falling back to English), for a glossary search box: `[{ id, word,
48
+ * spelling, definition }]`, one per term, sorted by spelling.
49
+ */
50
+ export function searchGlossary(input, language, { entity = 'glossary' } = {}) {
51
+ const needle = String(input ?? '').trim().toLowerCase()
52
+ if (!needle) return []
53
+ const { translations } = useDataPackage()
54
+ const rows = translations(entity, language)
55
+ const fallback = translations(entity, BASE_LANGUAGE)
56
+ const out = []
57
+ for (const term of entityRef(entity).value ?? []) {
58
+ const t = rows[term.id] ?? fallback[term.id] ?? {}
59
+ const spellings = t.spellings?.length ? t.spellings : [term.word]
60
+ for (const spelling of spellings) {
61
+ if (String(spelling ?? '').toLowerCase().startsWith(needle)) {
62
+ out.push({ id: term.id, word: term.word, spelling: String(spelling).trim(), definition: t.definition ?? '' })
63
+ break
64
+ }
65
+ }
66
+ }
67
+ return out.sort((a, b) => a.spelling.localeCompare(b.spelling))
68
+ }
69
+
70
+ // ── Citation ───────────────────────────────────────────────────────────────
71
+
72
+ /**
73
+ * The sentence both families print under a sheet:
74
+ * `Author "Name" in Project, Publisher, Year. Permalink` — assembled here in
75
+ * that order, from parts, rather than written into a text with holes in it.
76
+ * `inWord` is the site's `t('record.citation.in')`. Parts that are missing
77
+ * are left out with their punctuation.
78
+ */
79
+ export function citation({ author, name, project, publisher = 'Museum With No Frontiers', year, permalink, inWord = 'in' } = {}) {
80
+ const title = renderPlain(name ?? '')
81
+ if (!title) return ''
82
+ const when = year ?? new Date().getFullYear()
83
+ const where = [project, publisher].filter(Boolean).join(', ')
84
+ const parts = []
85
+ if (author) parts.push(String(author).trim())
86
+ parts.push(`"${title}"`)
87
+ if (where) parts.push(`${inWord} ${where},`)
88
+ let sentence = `${parts.join(' ')} ${when}.`
89
+ if (permalink) sentence += ` ${permalink}`
90
+ return sentence
91
+ }
92
+
93
+ // ── Related records ────────────────────────────────────────────────────────
94
+
95
+ /**
96
+ * A record's `related_items`, split by whether the package carries them:
97
+ * `inPackage` is `[{ reference, record, justification }]` with the
98
+ * justification in `language` (falling back to English); `outside` is the
99
+ * references the package does not hold, kept as the references they are — a
100
+ * link is never dropped for being unresolvable, and never invented either.
101
+ */
102
+ export function relatedRecords(record, { entity = 'items', language = BASE_LANGUAGE, field = 'related_items' } = {}) {
103
+ const index = byId(entity).value
104
+ const inPackage = []
105
+ const outside = []
106
+ const seen = new Set()
107
+ for (const reference of record?.[field] ?? []) {
108
+ if (!reference || seen.has(reference.id)) continue
109
+ seen.add(reference.id)
110
+ const found = reference.in_package === false ? null : index.get(reference.id)
111
+ if (!found) {
112
+ outside.push(reference)
113
+ continue
114
+ }
115
+ const justifications = reference.justifications ?? {}
116
+ inPackage.push({
117
+ reference,
118
+ record: found,
119
+ justification: justifications[language] ?? justifications[BASE_LANGUAGE] ?? '',
120
+ })
121
+ }
122
+ return { inPackage, outside }
123
+ }
124
+
125
+ /** `relatedRecords` over reactive sources. */
126
+ export function useRelatedRecords(record, options = {}) {
127
+ return computed(() => relatedRecords(toValue(record), { ...options, language: toValue(options.language) ?? BASE_LANGUAGE }))
128
+ }
129
+
130
+ // ── The timeline link ──────────────────────────────────────────────────────
131
+
132
+ /**
133
+ * The route location of the timeline results for a record's country and
134
+ * dates, or null when the record has neither a country nor a date. `name`
135
+ * is the site's timeline-results route; `keys` the query keys it reads
136
+ * (`country`, `begin`, `end` by default); `country(record)` the value the
137
+ * route wants for the country (the id by default — a site whose timeline
138
+ * reads legacy codes passes its own); `round` rounds the dates outward to
139
+ * the century, as the DXA "Timeline for this item" does.
140
+ */
141
+ export function timelineLinkFor(record, { name = 'timeline-results', keys = {}, country, round = false } = {}) {
142
+ if (!record) return null
143
+ const countryValue = country ? country(record) : record.country_id
144
+ const start = typeof record.start_date === 'number' ? record.start_date : null
145
+ const end = typeof record.end_date === 'number' ? record.end_date : null
146
+ if (!countryValue && start == null && end == null) return null
147
+ let [from, to] = [start, end]
148
+ if (round) [from, to] = roundOutward(start ?? undefined, end ?? undefined)
149
+ const query = {}
150
+ if (countryValue) query[keys.country ?? 'country'] = String(countryValue)
151
+ if (from != null) query[keys.begin ?? 'begin'] = String(from)
152
+ if (to != null) query[keys.end ?? 'end'] = String(to)
153
+ return { name, query }
154
+ }
@@ -1,87 +1,87 @@
1
- // Seven partner lists group by country seven ways. The standalone sites
2
- // (islamicart, baroqueart, sharinghistory) bucket each country's partners
3
- // into "main" and "associated" on a `level` field and render an accordion;
4
- // the DXA sites (carpets and its siblings) group the same way but add an
5
- // A-Z / Z-A toggle and no tiers at all. Both are this one derivation with a
6
- // couple of options — pure, so the site's own view supplies the toggle and
7
- // the label lookup.
8
- //
9
- // `parent_id` is carried on a third of the standalone partners and read
10
- // nowhere: legacy nested an associated partner under the main partner it
11
- // belongs to, and the port flattened that into the country column instead.
12
- // `partnerHierarchy` is that relationship, read once.
13
-
14
- /**
15
- * Whether `record` counts as a main (rather than associated) partner.
16
- * `tier` names the field the converged partner shape carries (`level`).
17
- * A record is associated only when its tier value is a non-empty value
18
- * other than `'partner'` (legacy values: `'associated_partner'`,
19
- * `'minor_contributor'`); `null`, `undefined`, `''`, or `'partner'` mean
20
- * main. This is necessary because exporters emit `level: null` for every
21
- * partner without curated hierarchy. Without a `tier` at all there is nothing
22
- * to divide records on, so every record is main, which is what a caller with
23
- * no tiers (the DXA lists) wants.
24
- */
25
- function isMainTier(record, tier) {
26
- if (!tier) return true
27
- const tierValue = record?.[tier]
28
- // Treat null, undefined, empty string, or 'partner' as main
29
- return tierValue == null || tierValue === '' || tierValue === 'partner'
30
- }
31
-
32
- /**
33
- * Partner records grouped by country: `[{ country, label, main, associated
34
- * }]`, one entry per distinct `country_id` (partners with none share a
35
- * `null` group), sorted by `label`, ascending unless `order: 'desc'`.
36
- * `label(country)` names a group's country — the caller's own lookup, the
37
- * same shape as a facet's `label(value)` — since a partner record carries
38
- * only the id. `tier` names the field that marks a main partner (see
39
- * `isMainTier`); without it every record lands in `main` and `associated`
40
- * is always empty, which covers the DXA lists' plain grouping. Partners
41
- * keep the relative order `records` was given in within each tier — sorting
42
- * them by their own label is the view's concern, not this derivation's.
43
- */
44
- export function groupByCountry(records, { label, tier, order = 'asc' } = {}) {
45
- const groups = new Map()
46
- for (const record of records ?? []) {
47
- const country = record?.country_id ?? null
48
- if (!groups.has(country)) groups.set(country, { country, main: [], associated: [] })
49
- const group = groups.get(country)
50
- ;(isMainTier(record, tier) ? group.main : group.associated).push(record)
51
- }
52
- const rows = [...groups.values()].map((group) => ({ ...group, label: label ? label(group.country) : group.country }))
53
- rows.sort((a, b) => String(a.label).localeCompare(String(b.label)))
54
- return order === 'desc' ? rows.reverse() : rows
55
- }
56
-
57
- /**
58
- * The partner hierarchy `parent_id` encodes: `children(id)` (a partner's
59
- * associated partners), `parentOf(id)` (the main partner it belongs to, or
60
- * null), `roots` (every partner with no parent in the list, or whose
61
- * `parent_id` points outside it). A partner referencing a `parent_id` this
62
- * list does not carry is treated as a root rather than dropped — the same
63
- * "keep the reference, don't invent or discard" rule `relatedRecords` uses.
64
- */
65
- export function partnerHierarchy(partners) {
66
- const list = partners ?? []
67
- const byId = new Map(list.map((partner) => [partner.id, partner]))
68
- const childrenOf = new Map()
69
- const roots = []
70
- for (const partner of list) {
71
- const parent = partner?.parent_id != null ? byId.get(partner.parent_id) : null
72
- if (parent) {
73
- if (!childrenOf.has(parent.id)) childrenOf.set(parent.id, [])
74
- childrenOf.get(parent.id).push(partner)
75
- } else {
76
- roots.push(partner)
77
- }
78
- }
79
- return {
80
- children: (id) => childrenOf.get(id) ?? [],
81
- parentOf: (id) => {
82
- const partner = byId.get(id)
83
- return partner?.parent_id != null ? (byId.get(partner.parent_id) ?? null) : null
84
- },
85
- roots,
86
- }
87
- }
1
+ // Seven partner lists group by country seven ways. The standalone sites
2
+ // (islamicart, baroqueart, sharinghistory) bucket each country's partners
3
+ // into "main" and "associated" on a `level` field and render an accordion;
4
+ // the DXA sites (carpets and its siblings) group the same way but add an
5
+ // A-Z / Z-A toggle and no tiers at all. Both are this one derivation with a
6
+ // couple of options — pure, so the site's own view supplies the toggle and
7
+ // the label lookup.
8
+ //
9
+ // `parent_id` is carried on a third of the standalone partners and read
10
+ // nowhere: legacy nested an associated partner under the main partner it
11
+ // belongs to, and the port flattened that into the country column instead.
12
+ // `partnerHierarchy` is that relationship, read once.
13
+
14
+ /**
15
+ * Whether `record` counts as a main (rather than associated) partner.
16
+ * `tier` names the field the converged partner shape carries (`level`).
17
+ * A record is associated only when its tier value is a non-empty value
18
+ * other than `'partner'` (legacy values: `'associated_partner'`,
19
+ * `'minor_contributor'`); `null`, `undefined`, `''`, or `'partner'` mean
20
+ * main. This is necessary because exporters emit `level: null` for every
21
+ * partner without curated hierarchy. Without a `tier` at all there is nothing
22
+ * to divide records on, so every record is main, which is what a caller with
23
+ * no tiers (the DXA lists) wants.
24
+ */
25
+ function isMainTier(record, tier) {
26
+ if (!tier) return true
27
+ const tierValue = record?.[tier]
28
+ // Treat null, undefined, empty string, or 'partner' as main
29
+ return tierValue == null || tierValue === '' || tierValue === 'partner'
30
+ }
31
+
32
+ /**
33
+ * Partner records grouped by country: `[{ country, label, main, associated
34
+ * }]`, one entry per distinct `country_id` (partners with none share a
35
+ * `null` group), sorted by `label`, ascending unless `order: 'desc'`.
36
+ * `label(country)` names a group's country — the caller's own lookup, the
37
+ * same shape as a facet's `label(value)` — since a partner record carries
38
+ * only the id. `tier` names the field that marks a main partner (see
39
+ * `isMainTier`); without it every record lands in `main` and `associated`
40
+ * is always empty, which covers the DXA lists' plain grouping. Partners
41
+ * keep the relative order `records` was given in within each tier — sorting
42
+ * them by their own label is the view's concern, not this derivation's.
43
+ */
44
+ export function groupByCountry(records, { label, tier, order = 'asc' } = {}) {
45
+ const groups = new Map()
46
+ for (const record of records ?? []) {
47
+ const country = record?.country_id ?? null
48
+ if (!groups.has(country)) groups.set(country, { country, main: [], associated: [] })
49
+ const group = groups.get(country)
50
+ ;(isMainTier(record, tier) ? group.main : group.associated).push(record)
51
+ }
52
+ const rows = [...groups.values()].map((group) => ({ ...group, label: label ? label(group.country) : group.country }))
53
+ rows.sort((a, b) => String(a.label).localeCompare(String(b.label)))
54
+ return order === 'desc' ? rows.reverse() : rows
55
+ }
56
+
57
+ /**
58
+ * The partner hierarchy `parent_id` encodes: `children(id)` (a partner's
59
+ * associated partners), `parentOf(id)` (the main partner it belongs to, or
60
+ * null), `roots` (every partner with no parent in the list, or whose
61
+ * `parent_id` points outside it). A partner referencing a `parent_id` this
62
+ * list does not carry is treated as a root rather than dropped — the same
63
+ * "keep the reference, don't invent or discard" rule `relatedRecords` uses.
64
+ */
65
+ export function partnerHierarchy(partners) {
66
+ const list = partners ?? []
67
+ const byId = new Map(list.map((partner) => [partner.id, partner]))
68
+ const childrenOf = new Map()
69
+ const roots = []
70
+ for (const partner of list) {
71
+ const parent = partner?.parent_id != null ? byId.get(partner.parent_id) : null
72
+ if (parent) {
73
+ if (!childrenOf.has(parent.id)) childrenOf.set(parent.id, [])
74
+ childrenOf.get(parent.id).push(partner)
75
+ } else {
76
+ roots.push(partner)
77
+ }
78
+ }
79
+ return {
80
+ children: (id) => childrenOf.get(id) ?? [],
81
+ parentOf: (id) => {
82
+ const partner = byId.get(id)
83
+ return partner?.parent_id != null ? (byId.get(partner.parent_id) ?? null) : null
84
+ },
85
+ roots,
86
+ }
87
+ }