From 8e0b1d7b966aed130e489f13e126781c0b7e1413 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Mon, 20 Apr 2026 13:14:55 +0200 Subject: [PATCH] feat(faith): enrich ring with next-year wedge, feast pill, and LY-aware navigation - Reserve an angular gap in the ring and render a clickable green triangle that jumps to Advent I of the following liturgical year; on hover the center numeral previews the upcoming LY in a muted color. - Show the liturgical year (not civil) in the ring center so Advent 2025 already reads 2026, and promote the selected arc to the top of the SVG paint order so its highlighted border is never clipped by neighbors. - Lift the selected-season glow by mixing the season tint with the foreground color so purple stays visible on dark backgrounds and white on light backgrounds. - On feast-dot hover, pop a colored SVG pill with the short date and feast name, tracked by ISO so it auto-clears when the dot is unmounted by navigation. - Server now derives the liturgical year from the selected date, shifting yearMap forward when the URL civil date lies past Advent I of that year so clicks on Dec 25 no longer select the prior post-Pentecost cycle. - Add data-sveltekit-keepfocus on ring anchors to avoid the focus-scroll jump after client-side navigation. --- .../[[dd=calendarDay]]/+page.server.ts | 135 ++++++-- .../[[dd=calendarDay]]/+page.svelte | 10 + .../[[dd=calendarDay]]/RingView.svelte | 315 +++++++++++++----- 3 files changed, 349 insertions(+), 111 deletions(-) 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 @@