diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.server.ts b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.server.ts index 3dd300c7..d3c2e653 100644 --- a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.server.ts +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.server.ts @@ -63,11 +63,53 @@ export const load: PageServerLoad = async ({ params, url, locals }) => { const year = Number.isFinite(yParam) && yParam >= minYear && yParam <= 2100 ? yParam : today.getFullYear(); const month = Number.isFinite(mParam) && mParam >= 0 && mParam <= 11 ? mParam : today.getMonth(); - const yearMap = + const fetchLy = async (y: number) => rite === '1962' - ? await getYear1962(lang, diocese1962, year) - : await getYear(lang, diocese1969, year); + ? await getYear1962(lang, diocese1962, y) + : await getYear(lang, diocese1969, y); + + // Initial candidate LY maps for the URL year. We may swap to LY(year+1) + // below if the selected date falls past AdventI of civil year `year` + // (dates in Advent..Dec 31 belong to the NEXT liturgical year, not the one + // ending at AdventI(year)). Without this shift, clicking e.g. Dec 25 2025 + // on the LY-2026 ring would route to `/2025/12/25` → LY 2025 ring, which + // shows the previous post-Pentecost cycle instead of Christmastide. + let yearMap = await fetchLy(year); + let lyNextMap = await fetchLy(year + 1); + + const isAdventI1 = (d: CalendarDay) => + d.id === 'advent_1_sunday' || d.id === 'first_sunday_of_advent'; + const findFirstIso = ( + m: Map, + predicate: (d: CalendarDay) => boolean + ): string | null => { + for (const [iso, day] of m) if (predicate(day)) return iso; + return null; + }; + // AdventI in civil year `year` = start of LY(year+1); rollover point. + const adventIOfUrlYear = findFirstIso(lyNextMap, isAdventI1); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + if (params.dd) { + const ddNum = Number(params.dd); + if (ddNum < 1 || ddNum > daysInMonth) throw error(404, 'Not found'); + } + // Tentative selectedIso used only for the LY rollover decision. The real + // selectedIso is recomputed after monthDays below (same logic, now on the + // possibly-shifted yearMap). + const tentativeSelectedIso = params.dd + ? isoFor(year, month, Number(params.dd)) + : isoFor(year, month, 1); + let liturgicalYear = year; + if ( + adventIOfUrlYear != null && + tentativeSelectedIso >= adventIOfUrlYear + ) { + liturgicalYear = year + 1; + yearMap = lyNextMap; + lyNextMap = await fetchLy(year + 2); + } + const monthDays: CalendarDay[] = []; for (let d = 1; d <= daysInMonth; d++) { const iso = isoFor(year, month, d); @@ -99,9 +141,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => { let selectedIso: string; if (params.dd) { - const dayNum = Number(params.dd); - if (dayNum < 1 || dayNum > daysInMonth) throw error(404, 'Not found'); - selectedIso = isoFor(year, month, dayNum); + selectedIso = isoFor(year, month, Number(params.dd)); } else if (todayEntry && today.getFullYear() === year && today.getMonth() === month) { selectedIso = todayIso; } else { @@ -118,23 +158,69 @@ export const load: PageServerLoad = async ({ params, url, locals }) => { : await getYear(lang, diocese1969, selectedYear); const selectedEntry = selectedYearMap.get(selectedIso) ?? monthDays[0]; - // --- Year overview data for the ring / month-grid views --- - const sortedYear = [...yearMap.values()].sort((a, b) => a.iso.localeCompare(b.iso)); + // --- Year overview data for the ring --- + // Romcal (scope: liturgical) emits LY N = Advent I Sunday (year N-1) through + // the Saturday before Advent I Sunday of year N+1 — the tail overlaps one + // week into LY N+1, so we also fetch LY N+1 and use its Advent I Sunday as + // the exclusive cutoff. The ring's window is chosen to avoid splitting the + // currently-displayed season: Advent-cut by default ([AdventI N-1, AdventI N)); + // once today is in TimeAfterPentecost of the current LY we swap to a + // Pentecost-cut ([Pentecost N, Pentecost N+1)) so the ongoing post-Pentecost + // arc isn't sliced in half by the year boundary. Either way, Advent I Sunday + // (= start of a liturgical year) is passed to the ring as an explicit + // marker. + const isPentecost = (d: CalendarDay) => + d.id === 'pentecost_sunday' || d.id === 'pentecost'; + const pentecostN = findFirstIso(yearMap, isPentecost); + const adventINext = findFirstIso(lyNextMap, isAdventI1); + const pentecostNext = findFirstIso(lyNextMap, isPentecost); - // Romcal leaves `season` undefined on sanctoral-principal days (e.g. Christmas, - // Epiphany, Circumcision) even when they fall inside a temporal season, which - // would otherwise break the ring arcs with gaps. Fill nulls from the next - // non-null day (Christmas Vigil → ChristmasTide, Epiphany → EpiphanyTide, …), - // then forward-fill any trailing nulls at end-of-year. + const inPostPentecost = + pentecostN != null && adventINext != null && todayIso >= pentecostN && todayIso < adventINext; + + let windowStart: string; + let windowEnd: string; + let liturgicalYearStart: string; + const windowMap = new Map(); + if (inPostPentecost && pentecostNext != null) { + windowStart = pentecostN!; + windowEnd = pentecostNext; + liturgicalYearStart = adventINext!; + for (const [iso, day] of yearMap) { + if (iso >= windowStart && iso < adventINext!) windowMap.set(iso, day); + } + for (const [iso, day] of lyNextMap) { + if (iso >= adventINext! && iso < windowEnd) windowMap.set(iso, day); + } + } else { + const cutoff = adventINext ?? null; + for (const [iso, day] of yearMap) { + if (cutoff == null || iso < cutoff) windowMap.set(iso, day); + } + const sortedIsos = [...windowMap.keys()].sort(); + windowStart = sortedIsos[0] ?? todayIso; + windowEnd = cutoff ?? sortedIsos[sortedIsos.length - 1] ?? todayIso; + liturgicalYearStart = windowStart; + } + + const sortedYear = [...windowMap.values()].sort((a, b) => a.iso.localeCompare(b.iso)); + + // Romcal leaves `season` undefined on sanctoral-principal days (e.g. a saint + // winning over its underlying ferial) even when they fall inside a temporal + // season, which would otherwise break the ring arcs with gaps. A sanctoral + // day inherits the *preceding* season, not the next one — otherwise a saint + // on the Saturday before Septuagesima pulls the Septuagesima arc backward + // one day. Forward-fill from previous day first; only backward-fill any + // still-null leading days at year start (before Advent I Sunday). const filledSeasons: (string | null)[] = sortedYear.map((d) => d.seasonKey); + for (let i = 1; i < filledSeasons.length; i++) { + if (filledSeasons[i] == null) filledSeasons[i] = filledSeasons[i - 1]; + } for (let i = filledSeasons.length - 1; i >= 0; i--) { if (filledSeasons[i] == null && i + 1 < filledSeasons.length) { filledSeasons[i] = filledSeasons[i + 1]; } } - for (let i = 1; i < filledSeasons.length; i++) { - if (filledSeasons[i] == null) filledSeasons[i] = filledSeasons[i - 1]; - } const yearDays: YearDay[] = sortedYear.map((d, i) => ({ iso: d.iso, @@ -149,10 +235,16 @@ export const load: PageServerLoad = async ({ params, url, locals }) => { for (let i = 0; i < sortedYear.length; i++) { const d = sortedYear[i]; const key = filledSeasons[i]; + // 1962: seasonKey is a 1962-specific label ('Septuagesima', 'TimeAfterPentecost', + // etc.) derived from the day id in `adaptDay1962`; the engine's `seasonNames` + // are unresolved i18n paths like 'lent.season', so always go through + // `season1962Label`. 1969: prefer the engine-resolved localized name. const name = - key && key !== d.seasonKey - ? (rite === '1962' ? season1962Label(key, lang) : key) - : d.seasonNames[0] ?? key ?? ''; + rite === '1962' + ? (key ? season1962Label(key, lang) : '') + : key && key !== d.seasonKey + ? key + : d.seasonNames[0] ?? key ?? ''; if (!key) { if (cur) { seasonArcs.push(cur); @@ -174,10 +266,15 @@ export const load: PageServerLoad = async ({ params, url, locals }) => { diocese: rite === '1962' ? diocese1962 : diocese1969, wip: false, year, + liturgicalYear, month, monthDays, yearDays, seasonArcs, + windowStart, + windowEnd, + liturgicalYearStart, + inPostPentecost, today: todayEntry, todayIso, selected: selectedEntry, diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.svelte b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.svelte index b89e611f..bfd48e3d 100644 --- a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.svelte +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.svelte @@ -28,6 +28,7 @@ const lang = $derived(data.lang as CalendarLang); const year = $derived(data.year); + const liturgicalYear = $derived(data.liturgicalYear); const month = $derived(data.month); const monthDays = $derived(data.monthDays); const yearDays = $derived(data.yearDays); @@ -37,6 +38,10 @@ const selected = $derived(data.selected); const selectedIso = $derived(data.selectedIso); const diocese = $derived(data.diocese); + const windowStart = $derived(data.windowStart); + const windowEnd = $derived(data.windowEnd); + const liturgicalYearStart = $derived(data.liturgicalYearStart); + const inPostPentecost = $derived(data.inPostPentecost); type CalView = 'ring' | 'grid'; let view = $state('ring'); @@ -301,12 +306,17 @@
{:else} diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/RingView.svelte b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/RingView.svelte index 3a6f84c8..835c06e6 100644 --- a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/RingView.svelte +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/RingView.svelte @@ -5,18 +5,25 @@ import { Tween, prefersReducedMotion } from 'svelte/motion'; import { cubicOut } from 'svelte/easing'; import { untrack } from 'svelte'; + import { goto } from '$app/navigation'; let { year, + liturgicalYear, yearDays, seasonArcs, todayIso, selectedIso = null, highlightToday = true, lang, - dayHref + dayHref, + windowStart, + windowEnd, + liturgicalYearStart, + inPostPentecost }: { year: number; + liturgicalYear: number; yearDays: YearDay[]; seasonArcs: SeasonArc[]; todayIso: string; @@ -24,6 +31,10 @@ highlightToday?: boolean; lang: CalendarLang; dayHref: (iso: string) => string; + windowStart: string; + windowEnd: string; + liturgicalYearStart: string; + inPostPentecost: boolean; } = $props(); const size = 560; @@ -33,27 +44,26 @@ const rSeason = 200; const rSeasonInner = 140; const rFeasts = 250; + // Gap reserved at the end-of-post-Pentecost seam for the next-year wedge. + // Arcs are squeezed into (2π - ringGap) so the wedge gets its own slot. + const ringGap = 0.09; - function isLeap(y: number) { - return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0; - } - function daysInYear(y: number) { - return isLeap(y) ? 366 : 365; - } - function dayOfYear(iso: string): number { + function isoToUTC(iso: string): number { const [yy, mm, dd] = iso.split('-').map(Number); - const start = Date.UTC(yy, 0, 1); - const cur = Date.UTC(yy, mm - 1, dd); - return Math.floor((cur - start) / 86400000); + return Date.UTC(yy, mm - 1, dd); + } + function dayOfWindow(iso: string): number { + return Math.floor((isoToUTC(iso) - isoToUTC(windowStart)) / 86400000); + } + function isoInWindow(iso: string | null | undefined): boolean { + return !!iso && iso >= windowStart && iso < windowEnd; } - const totalDays = $derived(daysInYear(year)); - const todayDoy = $derived( - todayIso && todayIso.startsWith(String(year)) ? dayOfYear(todayIso) : null - ); - const selectedDoy = $derived( - selectedIso && selectedIso.startsWith(String(year)) ? dayOfYear(selectedIso) : null + const totalDays = $derived( + Math.floor((isoToUTC(windowEnd) - isoToUTC(windowStart)) / 86400000) ); + const todayDoy = $derived(isoInWindow(todayIso) ? dayOfWindow(todayIso) : null); + const selectedDoy = $derived(isoInWindow(selectedIso) ? dayOfWindow(selectedIso!) : null); // The pivot doy lands at -90° (top). Prefer the selected day; fall back to // today; fall back to Jan 1 for off-year views. const targetPivot = $derived(selectedDoy ?? todayDoy ?? 0); @@ -78,16 +88,16 @@ }); const pivot = $derived(pivotTween.current); function angleFromDoy(doy: number): number { - return -Math.PI / 2 + ((doy - pivot) / totalDays) * Math.PI * 2; + return -Math.PI / 2 + ((doy - pivot) / totalDays) * (Math.PI * 2 - ringGap); } // Build one season-in-view from a raw SeasonArc. type ResolvedArc = SeasonArc & { a0: number; a1: number }; const resolvedArcs = $derived( seasonArcs.map((s) => { - const a0 = angleFromDoy(dayOfYear(s.start)); + const a0 = angleFromDoy(dayOfWindow(s.start)); // include the end day, so add one day before closing the arc - const a1 = angleFromDoy(dayOfYear(s.end) + 1); + const a1 = angleFromDoy(dayOfWindow(s.end) + 1); return { ...s, a0, a1 }; }) ); @@ -120,21 +130,36 @@ return `M ${ax + r * Math.cos(a1)} ${ay + r * Math.sin(a1)} A ${r} ${r} 0 ${large} 0 ${ax + r * Math.cos(a0)} ${ay + r * Math.sin(a0)}`; } - const monthLabels = $derived( - Array.from({ length: 12 }, (_, i) => - new Date(2000, i, 1).toLocaleDateString( - lang === 'de' ? 'de-DE' : 'en-GB', - { month: 'short' } - ) - ) - ); - const monthDoys = $derived( - Array.from({ length: 12 }, (_, i) => { - const start = Date.UTC(year, 0, 1); - const cur = Date.UTC(year, i, 1); - return Math.floor((cur - start) / 86400000); - }) - ); + // Enumerate every first-of-month that falls within the window. The window + // can span 13 civil-month boundaries (e.g. Advent-cut Dec(Y-1)..Nov(Y) shows + // Jan..Nov of year Y + Dec of Y-1). Returns each with its label + day-offset + // so month ticks land at the correct ring angle regardless of which year + // the month belongs to. + type MonthMark = { label: string; doy: number }; + const monthMarks = $derived.by(() => { + const out: MonthMark[] = []; + const [wsY, wsM, wsD] = windowStart.split('-').map(Number); + let y = wsY; + let m = wsM - 1; + if (wsD !== 1) { + m += 1; + if (m > 11) { m = 0; y += 1; } + } + for (let guard = 0; guard < 14; guard += 1) { + const first = `${y}-${String(m + 1).padStart(2, '0')}-01`; + if (first >= windowEnd) break; + if (first >= windowStart) { + const label = new Date(y, m, 1).toLocaleDateString( + lang === 'de' ? 'de-DE' : 'en-GB', + { month: 'short' } + ); + out.push({ label, doy: dayOfWindow(first) }); + } + m += 1; + if (m > 11) { m = 0; y += 1; } + } + return out; + }); // Feast dots: keep only the highest-ranking feast per ISO date, skip ferias. // The currently-selected feast is omitted because the static needle pin at @@ -151,22 +176,33 @@ return [...byDate.values()]; }); - const currentSeasonKey = $derived( - todayIso - ? seasonArcs.find((s) => todayIso >= s.start && todayIso <= s.end)?.key ?? null - : null + // A season can split into multiple arcs within one gregorian year (e.g. + // ChristmasTide spans both Dec 25–31 and Jan 1–13 of the civil year). Each + // arc is identified uniquely by its start ISO; the panel tracks whichever + // arc contains `selectedIso` so it stays in sync across SvelteKit + // navigation (including the next-year wedge click, which updates the URL + // but would otherwise leave stale internal state). + const currentArc = $derived( + todayIso ? seasonArcs.find((s) => todayIso >= s.start && todayIso <= s.end) ?? null : null ); + const active = $derived.by(() => { + if (selectedIso) { + const hit = seasonArcs.find((s) => selectedIso >= s.start && selectedIso <= s.end); + if (hit) return hit; + } + return currentArc ?? seasonArcs[0] ?? null; + }); - let activeKey = $state(null); - const active = $derived( - seasonArcs.find((s) => s.key === (activeKey ?? currentSeasonKey ?? seasonArcs[0]?.key)) ?? - null - ); - - function pickSeason(key: string) { - activeKey = key; + function pickSeason(arc: SeasonArc) { + goto(dayHref(arc.start), { noScroll: true, replaceState: true, keepFocus: true }); } + let nextYearHovered = $state(false); + let hoveredFeastIso = $state(null); + const hoveredFeast = $derived( + hoveredFeastIso ? feastDots.find((f) => f.iso === hoveredFeastIso) ?? null : null + ); + const activeFeasts = $derived.by(() => { if (!active) return [] as YearDay[]; return yearDays.filter( @@ -192,11 +228,7 @@ // gold when the selection is today). The selected feast's dot is hidden from // the ring since the pin now represents it. const needleIso = $derived( - selectedIso && selectedIso.startsWith(String(year)) - ? selectedIso - : todayIso && todayIso.startsWith(String(year)) - ? todayIso - : null + isoInWindow(selectedIso) ? selectedIso : isoInWindow(todayIso) ? todayIso : null ); const needleIsToday = $derived(needleIso !== null && needleIso === todayIso); const needleDay = $derived( @@ -208,9 +240,27 @@ const T = $derived( { - en: { now: 'Now', feastsIn: 'Feasts in this season', centerSub: 'Roman Rite', anno: 'Anno Domini' }, - de: { now: 'Jetzt', feastsIn: 'Feste in dieser Zeit', centerSub: 'Römischer Ritus', anno: 'Anno Domini' }, - la: { now: 'Nunc', feastsIn: 'Festa in hoc tempore', centerSub: 'Ritus Romanus', anno: 'Anno Domini' } + en: { + now: 'Now', + feastsIn: 'Feasts in this season', + centerSub: 'Roman Rite', + anno: 'Anno Domini', + nextYear: 'Next liturgical year' + }, + de: { + now: 'Jetzt', + feastsIn: 'Feste in dieser Zeit', + centerSub: 'Römischer Ritus', + anno: 'Anno Domini', + nextYear: 'Nächstes Kirchenjahr' + }, + la: { + now: 'Nunc', + feastsIn: 'Festa in hoc tempore', + centerSub: 'Ritus Romanus', + anno: 'Anno Domini', + nextYear: 'Annus liturgicus sequens' + } }[lang] ); @@ -230,59 +280,76 @@ return { path, text }; } - function toRoman(n: number): string { - const map: [number, string][] = [ - [1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'], - [100, 'C'], [90, 'XC'], [50, 'L'], [40, 'XL'], - [10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I'] - ]; - let out = ''; - let num = n; - for (const [v, s] of map) { - while (num >= v) { - out += s; - num -= v; - } - } - return out; - } - const yearRoman = $derived(toRoman(year));
- {#each resolvedArcs as s (`${s.key}:${s.start}`)} + {#if totalDays > 0} + {@const aPostEnd = + inPostPentecost && isoInWindow(liturgicalYearStart) + ? angleFromDoy(dayOfWindow(liturgicalYearStart)) + : angleFromDoy(totalDays)} + {@const wedgeSpan = ringGap} + {@const rMidWedge = (rSeason + rSeasonInner) / 2} + {@const baseOx = cx + rSeason * Math.cos(aPostEnd)} + {@const baseOy = cy + rSeason * Math.sin(aPostEnd)} + {@const baseIx = cx + rSeasonInner * Math.cos(aPostEnd)} + {@const baseIy = cy + rSeasonInner * Math.sin(aPostEnd)} + {@const wedgeTipX = cx + rMidWedge * Math.cos(aPostEnd + wedgeSpan)} + {@const wedgeTipY = cy + rMidWedge * Math.sin(aPostEnd + wedgeSpan)} + {@const nextAdventIso = + inPostPentecost ? liturgicalYearStart : windowEnd} + (nextYearHovered = true)} + onmouseleave={() => (nextYearHovered = false)} + onfocus={() => (nextYearHovered = true)} + onblur={() => (nextYearHovered = false)} + > + {T.nextYear} + + + {/if} + + {#each [...resolvedArcs].sort((a, b) => Number(active?.start === a.start) - Number(active?.start === b.start)) as s (`${s.key}:${s.start}`)} {@const lbl = labelFor(s)} - {@const isCurrent = s.key === currentSeasonKey && highlightToday} - {@const isSelected = (activeKey ?? currentSeasonKey) === s.key} + {@const isCurrent = s.start === currentArc?.start && highlightToday} + {@const isSelected = active?.start === s.start} pickSeason(s.key)} + onclick={() => pickSeason(s)} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - pickSeason(s.key); + pickSeason(s); } }} > - {#if isCurrent} + {#if isSelected} {/if} {#if lbl.text} @@ -296,8 +363,8 @@ {/each} - {#each monthDoys as doy, i (i)} - {@const a = angleFromDoy(doy)} + {#each monthMarks as mk, i (i)} + {@const a = angleFromDoy(mk.doy)} {@const x1 = cx + (rOuter + 4) * Math.cos(a)} {@const y1 = cy + (rOuter + 4) * Math.sin(a)} {@const x2 = cx + (rOuter + 14) * Math.cos(a)} @@ -307,13 +374,13 @@ - {monthLabels[i]} + {mk.label} {/each} {#each feastDots as f (f.iso + f.name)} - {@const a = angleFromDoy(dayOfYear(f.iso))} + {@const a = angleFromDoy(dayOfWindow(f.iso))} {@const x = cx + rFeasts * Math.cos(a)} {@const y = cy + rFeasts * Math.sin(a)} (hoveredFeastIso = f.iso)} + onmouseleave={() => { + if (hoveredFeastIso === f.iso) hoveredFeastIso = null; + }} + onfocus={() => (hoveredFeastIso = f.iso)} + onblur={() => { + if (hoveredFeastIso === f.iso) hoveredFeastIso = null; + }} > {/if} - {T.anno} - {yearRoman} - {year} · {T.centerSub} + {T.anno} + + {nextYearHovered ? liturgicalYear + 1 : liturgicalYear} + + {T.centerSub} + + {#if hoveredFeast} + {@const hf = hoveredFeast} + {@const aH = angleFromDoy(dayOfWindow(hf.iso))} + {@const pillLabel = `${fmtShort(hf.iso)} · ${hf.name}`} + {@const pillR = rFeasts + 22} + {@const pillW = Math.max(60, pillLabel.length * 7 + 20)} + {@const pillH = 22} + {@const pcx = cx + pillR * Math.cos(aH)} + {@const pcy = cy + pillR * Math.sin(aH)} + + + + {pillLabel} + + + {/if}
@@ -367,7 +471,7 @@