The grouped view flooded: buckets had no visual containment (a tiny floating pill over cards identical to the ungrouped view) and the >12-letter density collapse was gone, so "Weitere Briefe · 325" / "Sonstiges · 10" dumped every card. LetterBucket now binds each cluster with a coloured left rail (tag colour in Thema, mint for an Ereignis cluster, neutral for the fallback), renders compact cards, and — above BUCKET_DENSE_THRESHOLD (6) — collapses to the existing month-density YearLetterStrip instead of a flood. Adds a `nested` mode (no header) for letters that sit under their event pill, and shares the tag-colour token allow-list via tagColorVar. Refs #827
162 lines
5.7 KiB
TypeScript
162 lines
5.7 KiB
TypeScript
import type { components } from '$lib/generated/api';
|
|
|
|
type TimelineDTO = components['schemas']['TimelineDTO'];
|
|
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
|
|
|
/**
|
|
* The three ways a reader can bundle the loose letters on `/zeitstrahl` (#827). The
|
|
* axis-fixed layers (life-events, event pills, world-bands) are identical in every mode
|
|
* — only loose-letter bundling changes. Grouping runs over the *already layer-filtered*
|
|
* timeline (#780): filter-then-group.
|
|
*/
|
|
export type GroupingMode = 'date' | 'event' | 'thema';
|
|
|
|
/** The default mode — chronological, as #779 shipped. */
|
|
export const DEFAULT_GROUPING: GroupingMode = 'date';
|
|
|
|
/**
|
|
* A bucket larger than this collapses to a month-density strip instead of flooding the
|
|
* timeline with individual cards (#827) — the catch-all "Weitere Briefe"/"Ohne Thema"
|
|
* buckets are always the biggest, so without this they swamp the grouped view. Lower than
|
|
* Datum mode's `DENSE_THRESHOLD` (12) because a bucket is a narrower context than a year.
|
|
*/
|
|
export const BUCKET_DENSE_THRESHOLD = 6;
|
|
|
|
export function isBucketDense(letterCount: number): boolean {
|
|
return letterCount > BUCKET_DENSE_THRESHOLD;
|
|
}
|
|
|
|
/** The `--c-tag-*` colour-name tokens (sage/sienna/…); shared by the chip and the rail. */
|
|
const TAG_COLOR_TOKENS = new Set([
|
|
'sage',
|
|
'sienna',
|
|
'amber',
|
|
'slate',
|
|
'violet',
|
|
'rose',
|
|
'cobalt',
|
|
'moss',
|
|
'sand',
|
|
'coral'
|
|
]);
|
|
|
|
/**
|
|
* Maps a root-tag colour-name token to its CSS variable reference, or `null` for an absent
|
|
* or unknown token (so a colourless/unrecognised tag falls back to a neutral rail, never a
|
|
* broken `var(--c-tag-undefined)`).
|
|
*/
|
|
export function tagColorVar(token: string | null | undefined): string | null {
|
|
return token && TAG_COLOR_TOKENS.has(token) ? `var(--c-tag-${token})` : null;
|
|
}
|
|
|
|
/**
|
|
* One bundle of loose letters under a single header, within a year (Ereignis/Thema modes).
|
|
* `kind` decides the header: a curated-event title, a tinted root-tag chip, or the localized
|
|
* fallback ("Weitere Briefe" / "Ohne Thema") the view supplies for `kind === 'fallback'`.
|
|
*/
|
|
export interface LetterBucket {
|
|
/** Stable `{#each}` key, unique within a year's bucket list. */
|
|
key: string;
|
|
kind: 'event' | 'tag' | 'fallback';
|
|
/** Header label for `event`/`tag` buckets; absent for `fallback` (view supplies a localized label). */
|
|
title?: string;
|
|
/** Root-tag colour token for a `tag` bucket; `null` for `event`/`fallback` (neutral). */
|
|
color: string | null;
|
|
letters: TimelineEntryDTO[];
|
|
}
|
|
|
|
/**
|
|
* Maps each curated event present in the (already-filtered) timeline to its title. These are the
|
|
* only events a letter may cluster under — a letter whose `linkedEventId` is absent here links to
|
|
* an event the layer filter removed, so it falls back to "Weitere Briefe" (filter-then-group,
|
|
* REQ-019). Curated events carry an `eventId`; derived life-events and letters do not.
|
|
*/
|
|
export function buildEventLookup(timeline: TimelineDTO): Map<string, string> {
|
|
const lookup = new Map<string, string>();
|
|
const collect = (entries: TimelineEntryDTO[]) => {
|
|
for (const entry of entries) {
|
|
if (entry.kind === 'EVENT' && entry.eventId) lookup.set(entry.eventId, entry.title ?? '');
|
|
}
|
|
};
|
|
for (const band of timeline.years) collect(band.entries);
|
|
collect(timeline.undated);
|
|
return lookup;
|
|
}
|
|
|
|
/**
|
|
* True when the timeline still holds at least one loose letter. Drives the grouping control's
|
|
* enabled state: with the Letters layer filtered off there is nothing to regroup (REQ-018).
|
|
*/
|
|
export function hasLooseLetters(timeline: TimelineDTO): boolean {
|
|
const holdsLetter = (entries: TimelineEntryDTO[]) => entries.some((e) => e.kind === 'LETTER');
|
|
return timeline.years.some((band) => holdsLetter(band.entries)) || holdsLetter(timeline.undated);
|
|
}
|
|
|
|
/**
|
|
* Buckets one year's loose letters for Ereignis/Thema mode. The caller passes only that year's
|
|
* `LETTER` entries; events stay on the axis untouched (REQ-001). Buckets keep first-seen order and
|
|
* the fallback bucket, if any, always sorts last.
|
|
*
|
|
* - `event`: cluster under `linkedEventId` when it is set AND survives in `eventLookup`; otherwise
|
|
* the fallback "Weitere Briefe" bucket (REQ-003/006/019).
|
|
* - `thema`: bucket under `rootTagId` (header = `rootTagName`, tint = `rootTagColor`); an untagged
|
|
* letter goes to the fallback "Ohne Thema" bucket (REQ-004/007). A letter carries exactly one
|
|
* `rootTagId`, so it lands in exactly one bucket (REQ-008).
|
|
*/
|
|
export function bucketLetters(
|
|
letters: TimelineEntryDTO[],
|
|
mode: Exclude<GroupingMode, 'date'>,
|
|
eventLookup: Map<string, string>
|
|
): LetterBucket[] {
|
|
const byKey = new Map<string, LetterBucket>();
|
|
let fallback: LetterBucket | null = null;
|
|
|
|
const fallbackBucket = (): LetterBucket => {
|
|
if (!fallback) fallback = { key: '__fallback__', kind: 'fallback', color: null, letters: [] };
|
|
return fallback;
|
|
};
|
|
|
|
const namedBucket = (id: string, build: () => LetterBucket): LetterBucket => {
|
|
let bucket = byKey.get(id);
|
|
if (!bucket) {
|
|
bucket = build();
|
|
byKey.set(id, bucket);
|
|
}
|
|
return bucket;
|
|
};
|
|
|
|
for (const letter of letters) {
|
|
if (mode === 'event') {
|
|
const id = letter.linkedEventId;
|
|
if (id && eventLookup.has(id)) {
|
|
namedBucket(id, () => ({
|
|
key: `event:${id}`,
|
|
kind: 'event',
|
|
title: eventLookup.get(id),
|
|
color: null,
|
|
letters: []
|
|
})).letters.push(letter);
|
|
} else {
|
|
fallbackBucket().letters.push(letter);
|
|
}
|
|
} else {
|
|
const id = letter.rootTagId;
|
|
if (id) {
|
|
namedBucket(id, () => ({
|
|
key: `tag:${id}`,
|
|
kind: 'tag',
|
|
title: letter.rootTagName ?? '',
|
|
color: letter.rootTagColor ?? null,
|
|
letters: []
|
|
})).letters.push(letter);
|
|
} else {
|
|
fallbackBucket().letters.push(letter);
|
|
}
|
|
}
|
|
}
|
|
|
|
const buckets = [...byKey.values()];
|
|
if (fallback) buckets.push(fallback);
|
|
return buckets;
|
|
}
|