From 2ce0b4730c196ff8c15e3208b1031c3a896792e3 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 2 Mar 2026 15:07:31 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20rCal=20unified=20layout=20=E2=80=94=20n?= =?UTF-8?q?o=20tabs,=20auto-map,=20lunar=20overlay,=20scroll=20zoom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the 3-tab system (Temporal/Spatial/Lunar) in favor of a unified view: - Calendar always primary with floating/docked/minimizable map panel - Lunar phases as compact overlay bar below nav (click to expand synodic detail) - Scroll/trackpad zoom controls temporal granularity with 120ms debounce - 'm' key cycles map panel: floating → docked → minimized → floating - Mobile: floating map full-width, docked stacks vertically - cal-demo.ts cleaned up (dead tab-switching code removed) Co-Authored-By: Claude Opus 4.6 --- modules/rcal/components/cal-demo.ts | 20 -- modules/rcal/components/folk-calendar-view.ts | 326 ++++++++++-------- 2 files changed, 189 insertions(+), 157 deletions(-) diff --git a/modules/rcal/components/cal-demo.ts b/modules/rcal/components/cal-demo.ts index b019461..cb0ff5c 100644 --- a/modules/rcal/components/cal-demo.ts +++ b/modules/rcal/components/cal-demo.ts @@ -1,21 +1 @@ -/** - * rCal demo — tab switching and zoom controls (local state only, no WebSocket). - * - * Highlights the active tab when clicked. All tabs show the same - * calendar grid for now; this is purely visual feedback. - */ - -const tabs = document.querySelectorAll("[data-cal-tab]"); - -tabs.forEach((tab) => { - tab.addEventListener("click", () => { - tabs.forEach((t) => { - t.style.background = "transparent"; - t.style.color = "#94a3b8"; - }); - tab.style.background = "rgba(99,102,241,0.15)"; - tab.style.color = "#818cf8"; - }); -}); - export {}; diff --git a/modules/rcal/components/folk-calendar-view.ts b/modules/rcal/components/folk-calendar-view.ts index bcaddd0..731541d 100644 --- a/modules/rcal/components/folk-calendar-view.ts +++ b/modules/rcal/components/folk-calendar-view.ts @@ -2,7 +2,8 @@ * — spatio-temporal coordination calendar. * * Five views: Day timeline, Week timeline, Month grid, Season (3 mini-months), Year (12 mini-months). - * Three tabs: Temporal (calendar views), Spatial (split calendar + Leaflet map), Lunar (phase display). + * Unified layout: calendar always primary, map auto-shows as a floating/minimizable panel, + * lunar phases overlay directly onto the calendar, scroll/trackpad zoom controls granularity. * Features: temporal-spatial zoom coupling, event markers with transit polylines, * lunar phase overlay, source filtering, and day-detail panels. */ @@ -117,7 +118,11 @@ class FolkCalendarView extends HTMLElement { private temporalGranularity = 4; // MONTH private spatialGranularity: number | null = null; // null = auto-coupled private zoomCoupled = true; - private activeTab: "temporal" | "spatial" | "lunar" = "temporal"; + + // Map panel state (replaces old tab system) + private mapPanelState: "minimized" | "floating" | "docked" = "floating"; + private lunarOverlayExpanded = false; + private _wheelTimer: ReturnType | null = null; // Leaflet map (preserved across re-renders) private leafletMap: any = null; @@ -144,6 +149,7 @@ class FolkCalendarView extends HTMLElement { document.removeEventListener("keydown", this.boundKeyHandler); this.boundKeyHandler = null; } + if (this._wheelTimer) { clearTimeout(this._wheelTimer); this._wheelTimer = null; } if (this.leafletMap) { this.leafletMap.remove(); this.leafletMap = null; @@ -171,11 +177,10 @@ class FolkCalendarView extends HTMLElement { case "+": case "=": e.preventDefault(); this.zoomIn(); break; case "-": case "_": e.preventDefault(); this.zoomOut(); break; case "m": case "M": - if (this.activeTab === "spatial") { - // Already on spatial tab — no toggle needed - } else { - this.activeTab = "spatial"; - } + // Cycle: floating → docked → minimized → floating + if (this.mapPanelState === "floating") this.mapPanelState = "docked"; + else if (this.mapPanelState === "docked") this.mapPanelState = "minimized"; + else this.mapPanelState = "floating"; this.render(); break; case "c": case "C": @@ -432,6 +437,8 @@ class FolkCalendarView extends HTMLElement { this.mapContainer.remove(); } + const isDocked = this.mapPanelState === "docked"; + this.shadow.innerHTML = ` @@ -446,13 +453,18 @@ class FolkCalendarView extends HTMLElement { - ${this.renderTabs()} + ${this.renderLunarOverlay()} - ${this.activeTab !== "lunar" ? this.renderZoomController() : ""} + ${this.renderZoomController()} - ${this.activeTab !== "lunar" ? this.renderSources() : ""} + ${this.renderSources()} - ${this.renderTabContent()} +
+
+ ${this.renderCalendarContent()} +
+ ${this.renderMapPanel()} +
+/- zoom • @@ -461,7 +473,8 @@ class FolkCalendarView extends HTMLElement { 1-5 view • m map • c coupling • - l lunar + l lunar • + scroll zoom
${this.selectedEvent ? this.renderEventModal() : ""} @@ -469,8 +482,8 @@ class FolkCalendarView extends HTMLElement { this.attachListeners(); - // Reattach or initialize map - if (this.activeTab === "spatial") { + // Initialize or update map when not minimized + if (this.mapPanelState !== "minimized") { this.initOrUpdateMap(); } } @@ -498,13 +511,61 @@ class FolkCalendarView extends HTMLElement { } } - // ── Tabs ── + // ── Lunar Overlay (replaces old Lunar tab) ── - private renderTabs(): string { - return `
- - - + private renderLunarOverlay(): string { + if (!this.showLunar) return ""; + + const phase = lunarPhaseForDate(this.currentDate); + const dayNum = Math.floor(phase.age) + 1; + const illumPct = Math.round(phase.illumination * 100); + const phaseName = phase.phase.replace(/_/g, " "); + const chevron = this.lunarOverlayExpanded ? "\u25B2" : "\u25BC"; + + let expandedHtml = ""; + if (this.lunarOverlayExpanded) { + const synodic = getSynodicMonth(this.currentDate); + const elapsed = this.currentDate.getTime() - synodic.startDate.getTime(); + const total = synodic.endDate.getTime() - synodic.startDate.getTime(); + const progress = Math.max(0, Math.min(1, elapsed / total)); + + expandedHtml = ` +
+
+
+ \u{1F311} ${synodic.startDate.toLocaleDateString("default", { month: "short", day: "numeric" })} + \u{1F311} ${synodic.endDate.toLocaleDateString("default", { month: "short", day: "numeric" })} +
+
+
+ ${synodic.phases.map(p => { + const pProg = (p.date.getTime() - synodic.startDate.getTime()) / total; + return `${p.emoji}`; + }).join("")} +
+
+
+ ${synodic.phases.map(p => { + const isCurrent = p.phase === phase.phase; + const isPast = p.date < this.currentDate && !isCurrent; + return ` + ${p.emoji} ${p.phase.replace(/_/g, " ")} + `; + }).join("")} +
+
`; + } + + return `
+
+ ${phase.emoji} ${phaseName} + ${illumPct}% illuminated + Day ${dayNum}/29 + ${chevron} +
+ ${expandedHtml}
`; } @@ -546,17 +607,34 @@ class FolkCalendarView extends HTMLElement {
`; } - // ── Tab Content Router ── + // ── Map Panel (floating / docked / minimized) ── - private renderTabContent(): string { - switch (this.activeTab) { - case "temporal": return this.renderCalendarContent(); - case "spatial": return this.renderSpatialTab(); - case "lunar": return this.renderLunarTab(); - default: return this.renderCalendarContent(); + private renderMapPanel(): string { + if (this.mapPanelState === "minimized") { + return ``; } + + const mode = this.mapPanelState; + const spatialLabel = SPATIAL_LABELS[this.getEffectiveSpatialIndex()]; + + return `
+
+ \u{1F5FA} ${spatialLabel} +
+ ${mode === "floating" + ? `` + : ``} + +
+
+
+
${spatialLabel}
+
+
`; } + // ── Calendar Content Router ── + private renderCalendarContent(): string { switch (this.viewMode) { case "day": return this.renderDay(); @@ -568,71 +646,6 @@ class FolkCalendarView extends HTMLElement { } } - // ── Spatial Tab (Split Layout) ── - - private renderSpatialTab(): string { - return `
-
${this.renderCalendarContent()}
-
-
${SPATIAL_LABELS[this.getEffectiveSpatialIndex()]}
-
-
`; - } - - // ── Lunar Tab ── - - private renderLunarTab(): string { - const phase = lunarPhaseForDate(this.currentDate); - const synodic = getSynodicMonth(this.currentDate); - const elapsed = this.currentDate.getTime() - synodic.startDate.getTime(); - const total = synodic.endDate.getTime() - synodic.startDate.getTime(); - const progress = Math.max(0, Math.min(1, elapsed / total)); - - return `
-
-
${phase.emoji}
-
${phase.phase.replace(/_/g, " ")}
-
- Illumination: ${Math.round(phase.illumination * 100)}% - Age: ${phase.age.toFixed(1)} days - Cycle: ${synodic.durationDays.toFixed(1)} days -
-
- -
-
- \u{1F311} ${synodic.startDate.toLocaleDateString("default", { month: "short", day: "numeric" })} - \u{1F311} ${synodic.endDate.toLocaleDateString("default", { month: "short", day: "numeric" })} -
-
-
- ${synodic.phases.map(p => { - const pProg = (p.date.getTime() - synodic.startDate.getTime()) / total; - return `${p.emoji}`; - }).join("")} -
-
- -
-
Phases this cycle
-
- ${synodic.phases.map(p => { - const isPast = p.date < this.currentDate; - const isCurrent = p.phase === phase.phase; - return `
- ${p.emoji} -
-
${p.phase.replace(/_/g, " ")}
-
${p.date.toLocaleDateString("default", { weekday: "short", month: "short", day: "numeric" })}
-
-
`; - }).join("")} -
-
-
`; - } - // ── Month View ── private renderMonth(): string { @@ -969,12 +982,22 @@ class FolkCalendarView extends HTMLElement { }); $("toggle-lunar")?.addEventListener("click", () => { this.showLunar = !this.showLunar; this.render(); }); - // Tabs - $$("[data-tab]").forEach(el => { - el.addEventListener("click", () => { - this.activeTab = (el as HTMLElement).dataset.tab as any; - this.render(); - }); + // Lunar overlay expand/collapse + $("lunar-summary")?.addEventListener("click", () => { + this.lunarOverlayExpanded = !this.lunarOverlayExpanded; + this.render(); + }); + + // Map panel controls + $("map-fab")?.addEventListener("click", () => { this.mapPanelState = "floating"; this.render(); }); + $("map-minimize")?.addEventListener("click", () => { this.mapPanelState = "minimized"; this.render(); }); + $("map-dock")?.addEventListener("click", () => { + this.mapPanelState = "docked"; + this.render(); + }); + $("map-float")?.addEventListener("click", () => { + this.mapPanelState = "floating"; + this.render(); }); // Zoom controller @@ -1061,6 +1084,29 @@ class FolkCalendarView extends HTMLElement { if ((e.target as HTMLElement).id === "modal-overlay") { this.selectedEvent = null; this.render(); } }); $("modal-close")?.addEventListener("click", () => { this.selectedEvent = null; this.render(); }); + + // Scroll/trackpad zoom on calendar pane + const calPane = $("calendar-pane"); + if (calPane) { + calPane.addEventListener("wheel", (e: WheelEvent) => { + // Skip if target is inside the map panel (Leaflet handles its own zoom) + const mapPanel = this.shadow.getElementById("map-panel"); + if (mapPanel && mapPanel.contains(e.target as Node)) return; + + e.preventDefault(); + + // Debounce: 120ms + if (this._wheelTimer) clearTimeout(this._wheelTimer); + this._wheelTimer = setTimeout(() => { + if (e.deltaY > 0) { + this.zoomOut(); // scroll down → zoom out + } else if (e.deltaY < 0) { + this.zoomIn(); // scroll up → zoom in + } + this._wheelTimer = null; + }, 120); + }, { passive: false }); + } } // ── Leaflet Map ── @@ -1077,7 +1123,7 @@ class FolkCalendarView extends HTMLElement { this.mapContainer = document.createElement("div"); this.mapContainer.style.width = "100%"; this.mapContainer.style.height = "100%"; - this.mapContainer.style.minHeight = "400px"; + this.mapContainer.style.minHeight = "250px"; this.mapContainer.style.borderRadius = "8px"; } @@ -1184,11 +1230,19 @@ class FolkCalendarView extends HTMLElement { .nav-title { font-size: 15px; font-weight: 600; flex: 1; text-align: center; color: #e2e8f0; } .nav-primary { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 12px; } - /* ── Tabs ── */ - .tabs { display: flex; gap: 2px; margin-bottom: 12px; background: #16161e; border-radius: 8px; padding: 3px; border: 1px solid #222; } - .tab-btn { flex: 1; padding: 6px 12px; border-radius: 6px; border: none; background: transparent; color: #64748b; cursor: pointer; font-size: 12px; font-weight: 500; transition: all 0.15s; } - .tab-btn:hover { color: #94a3b8; } - .tab-btn.active { background: #4f46e5; color: #fff; } + /* ── Lunar Overlay ── */ + .lunar-overlay { background: #16161e; border: 1px solid #222; border-radius: 8px; padding: 0; margin-bottom: 12px; overflow: hidden; } + .lunar-summary { display: flex; align-items: center; gap: 12px; padding: 8px 12px; cursor: pointer; user-select: none; transition: background 0.15s; } + .lunar-summary:hover { background: rgba(255,255,255,0.04); } + .lunar-summary-phase { font-size: 13px; font-weight: 600; color: #e2e8f0; text-transform: capitalize; white-space: nowrap; } + .lunar-summary-stats { font-size: 11px; color: #94a3b8; white-space: nowrap; } + .lunar-summary-chevron { margin-left: auto; font-size: 10px; color: #64748b; } + .lunar-expanded { border-top: 1px solid #222; padding: 12px; } + .phase-chips { display: flex; gap: 6px; overflow-x: auto; padding: 8px 0 4px; } + .phase-chip { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 16px; border: 1px solid #333; font-size: 11px; color: #94a3b8; white-space: nowrap; flex-shrink: 0; transition: all 0.15s; } + .phase-chip.current { border-color: #6366f1; color: #818cf8; background: rgba(99,102,241,0.08); } + .phase-chip.past { opacity: 0.4; } + .phase-chip-label { text-transform: capitalize; } /* ── Zoom Controller ── */ .zoom-ctrl { display: flex; align-items: center; gap: 6px; margin-bottom: 12px; padding: 8px 10px; background: #16161e; border: 1px solid #222; border-radius: 8px; flex-wrap: wrap; } @@ -1212,6 +1266,32 @@ class FolkCalendarView extends HTMLElement { .src-badge:hover { filter: brightness(1.2); } .src-badge.filtered { opacity: 0.3; text-decoration: line-through; } + /* ── Main Layout ── */ + .main-layout { position: relative; min-height: 400px; } + .main-layout--docked { display: grid; grid-template-columns: 1fr 400px; gap: 8px; min-height: 500px; } + .calendar-pane { overflow: auto; min-width: 0; } + + /* ── Map Panel ── */ + .map-panel { background: #0d1117; border: 1px solid #333; border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; } + .map-panel--floating { position: absolute; bottom: 8px; right: 8px; width: 380px; height: 320px; resize: both; z-index: 100; box-shadow: 0 8px 32px rgba(0,0,0,0.5); } + .map-panel--docked { min-height: 400px; } + .map-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; background: #16161e; border-bottom: 1px solid #222; cursor: default; flex-shrink: 0; } + .map-panel-title { font-size: 12px; font-weight: 500; color: #94a3b8; } + .map-panel-controls { display: flex; gap: 4px; } + .map-ctrl-btn { width: 24px; height: 24px; border-radius: 4px; border: 1px solid #333; background: transparent; color: #94a3b8; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; } + .map-ctrl-btn:hover { border-color: #6366f1; color: #e2e8f0; } + .map-body { flex: 1; position: relative; min-height: 200px; } + .map-overlay-label { position: absolute; top: 8px; right: 8px; z-index: 500; background: rgba(22,22,30,0.9); border: 1px solid #333; border-radius: 6px; padding: 4px 10px; font-size: 11px; color: #94a3b8; pointer-events: none; } + .map-fab { position: absolute; bottom: 16px; right: 16px; width: 44px; height: 44px; border-radius: 50%; border: 1px solid #333; background: #16161e; color: #e2e8f0; cursor: pointer; font-size: 20px; display: flex; align-items: center; justify-content: center; z-index: 100; box-shadow: 0 4px 16px rgba(0,0,0,0.4); transition: all 0.15s; } + .map-fab:hover { border-color: #6366f1; background: #1e1e2e; transform: scale(1.1); } + + /* ── Synodic (reused in overlay) ── */ + .synodic-section { margin: 0 0 8px; } + .synodic-labels { display: flex; justify-content: space-between; font-size: 11px; color: #64748b; margin-bottom: 6px; } + .synodic-bar { height: 14px; background: #222; border-radius: 7px; overflow: visible; position: relative; } + .synodic-fill { height: 100%; background: linear-gradient(to right, #1a1a2e, #e2e8f0, #1a1a2e); border-radius: 7px; transition: width 0.3s; } + .synodic-marker { position: absolute; top: -2px; font-size: 12px; transform: translateX(-50%); pointer-events: none; } + /* ── Month Grid ── */ .weekdays { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; margin-bottom: 4px; } .wd { text-align: center; font-size: 11px; color: #64748b; padding: 4px; font-weight: 600; } @@ -1282,12 +1362,6 @@ class FolkCalendarView extends HTMLElement { .week-event:hover { opacity: 0.85; } .week-event-title { font-weight: 600; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - /* ── Spatial Split Layout ── */ - .spatial-split { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; min-height: 500px; } - .spatial-calendar { overflow: auto; } - .spatial-map { position: relative; border-radius: 8px; border: 1px solid #222; overflow: hidden; min-height: 400px; background: #0d1117; } - .map-overlay-label { position: absolute; top: 8px; right: 8px; z-index: 500; background: rgba(22,22,30,0.9); border: 1px solid #333; border-radius: 6px; padding: 4px 10px; font-size: 11px; color: #94a3b8; pointer-events: none; } - /* ── Season View ── */ .season-header { text-align: center; margin-bottom: 12px; font-size: 16px; font-weight: 600; color: #e2e8f0; } .season-q { font-size: 12px; color: #64748b; font-weight: 400; margin-left: 4px; } @@ -1311,29 +1385,6 @@ class FolkCalendarView extends HTMLElement { .mini-dot { position: absolute; bottom: 1px; left: 50%; transform: translateX(-50%); width: 3px; height: 3px; border-radius: 50%; } .mini-hint { text-align: center; font-size: 11px; color: #4a5568; margin-top: 8px; } - /* ── Lunar Tab ── */ - .lunar-tab { max-width: 600px; margin: 0 auto; padding: 8px 0; } - .lunar-hero { text-align: center; padding: 20px 0; } - .lunar-emoji { font-size: 72px; line-height: 1; } - .lunar-phase-name { font-size: 20px; font-weight: 700; color: #e2e8f0; margin-top: 8px; text-transform: capitalize; } - .lunar-stats { display: flex; justify-content: center; gap: 16px; margin-top: 10px; font-size: 13px; color: #94a3b8; flex-wrap: wrap; } - .synodic-section { margin: 16px 0; } - .synodic-labels { display: flex; justify-content: space-between; font-size: 11px; color: #64748b; margin-bottom: 6px; } - .synodic-bar { height: 14px; background: #222; border-radius: 7px; overflow: visible; position: relative; } - .synodic-fill { height: 100%; background: linear-gradient(to right, #1a1a2e, #e2e8f0, #1a1a2e); border-radius: 7px; transition: width 0.3s; } - .synodic-marker { position: absolute; top: -2px; font-size: 12px; transform: translateX(-50%); pointer-events: none; } - .phase-section { margin-top: 20px; } - .phase-section-title { font-size: 14px; font-weight: 600; color: #e2e8f0; margin-bottom: 10px; } - .phase-timeline { display: flex; flex-direction: column; gap: 6px; } - .phase-row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; border: 1px solid #222; transition: all 0.15s; } - .phase-row:hover { border-color: #333; } - .phase-row.current { border-color: #6366f1; background: rgba(99,102,241,0.08); } - .phase-row.past { opacity: 0.5; } - .phase-row-emoji { font-size: 24px; flex-shrink: 0; } - .phase-row-info { flex: 1; } - .phase-row-name { font-weight: 500; color: #e2e8f0; text-transform: capitalize; font-size: 13px; } - .phase-row-date { font-size: 12px; color: #94a3b8; margin-top: 2px; } - /* ── Keyboard Hint ── */ .kbd-hint { text-align: center; font-size: 10px; color: #333; margin-top: 12px; padding-top: 8px; border-top: 1px solid #1a1a2e; } .kbd-hint kbd { padding: 1px 4px; background: #16161e; border: 1px solid #222; border-radius: 3px; font-family: inherit; font-size: 9px; } @@ -1341,8 +1392,8 @@ class FolkCalendarView extends HTMLElement { /* ── Mobile ── */ @media (max-width: 768px) { :host { padding: 0.25rem; } - .spatial-split { grid-template-columns: 1fr; } - .spatial-map { min-height: 300px; } + .main-layout--docked { grid-template-columns: 1fr; } + .map-panel--floating { width: 100%; left: 0; right: 0; bottom: 0; border-radius: 12px 12px 0 0; } .year-grid { grid-template-columns: repeat(3, 1fr); } .season-grid { grid-template-columns: 1fr; } .day { min-height: 52px; padding: 4px; } @@ -1359,8 +1410,9 @@ class FolkCalendarView extends HTMLElement { .zoom-track { min-width: 150px; } .zoom-ctrl { gap: 4px; padding: 6px 8px; } .coupling-btn { font-size: 10px; padding: 3px 8px; } - .lunar-emoji { font-size: 56px; } - .lunar-stats { gap: 10px; font-size: 12px; } + .lunar-summary { gap: 8px; flex-wrap: wrap; } + .phase-chips { gap: 4px; } + .phase-chip { padding: 3px 8px; font-size: 10px; } } @media (max-width: 480px) { .day { min-height: 44px; padding: 3px; }