/** * — spatio-temporal coordination calendar. * * Five views: Day timeline, Week timeline, Month grid, Season (3 mini-months), Year (12 mini-months). * 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. */ // ── Granularity Constants ── const TEMPORAL_LABELS = ["Moment","Hour","Day","Week","Month","Season","Year","Decade","Century","Cosmic"]; const SPATIAL_LABELS = ["Planet","Continent","Bioregion","Country","Region","City","Neighborhood","Address","Coordinates"]; const T_TO_S = [8, 7, 7, 5, 3, 3, 1, 1, 0, 0]; // temporal → spatial coupling const S_TO_ZOOM = [2, 4, 5, 6, 8, 11, 14, 16, 18]; // spatial → Leaflet zoom const T_TO_VIEW: Record = { 2: "day", 3: "week", 4: "month", 5: "season", 6: "year" }; // ── Leaflet CDN Loader ── let _leafletReady = false; let _leafletPromise: Promise | null = null; function ensureLeaflet(): Promise { if (_leafletReady && typeof (window as any).L !== "undefined") return Promise.resolve(); if (_leafletPromise) return _leafletPromise; _leafletPromise = new Promise((resolve, reject) => { const s = document.createElement("script"); s.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"; s.onload = () => { _leafletReady = true; resolve(); }; s.onerror = () => reject(new Error("Leaflet load failed")); document.head.appendChild(s); }); return _leafletPromise; } // ── Lunar Phase Computation (no external deps) ── const SYNODIC_CYCLE = 29.53059; const KNOWN_NEW_MOON_MS = new Date(2024, 0, 11, 11, 57).getTime(); const PHASE_THRESHOLDS: [string, number][] = [ ["new_moon", 1.85], ["waxing_crescent", 7.38], ["first_quarter", 11.07], ["waxing_gibbous", 14.76], ["full_moon", 16.62], ["waning_gibbous", 22.15], ["last_quarter", 25.84], ["waning_crescent", 29.53], ]; const MOON_EMOJI: Record = { new_moon: "\u{1F311}", waxing_crescent: "\u{1F312}", first_quarter: "\u{1F313}", waxing_gibbous: "\u{1F314}", full_moon: "\u{1F315}", waning_gibbous: "\u{1F316}", last_quarter: "\u{1F317}", waning_crescent: "\u{1F318}", }; function lunarPhaseForDate(d: Date): { phase: string; illumination: number; age: number; emoji: string } { const raw = ((d.getTime() - KNOWN_NEW_MOON_MS) / 86400000) % SYNODIC_CYCLE; const age = raw < 0 ? raw + SYNODIC_CYCLE : raw; let phase = "new_moon"; for (const [name, threshold] of PHASE_THRESHOLDS) { if (age < threshold) { phase = name; break; } } const illumination = Math.round((1 - Math.cos(2 * Math.PI * age / SYNODIC_CYCLE)) / 2 * 100) / 100; return { phase, illumination, age, emoji: MOON_EMOJI[phase] || "" }; } function findNewMoon(fromDate: Date, direction: -1 | 1): Date { let minIllum = 1; let minDate = new Date(fromDate); for (let i = 0; i <= 32; i++) { const check = new Date(fromDate.getTime() + direction * i * 86400000); const info = lunarPhaseForDate(check); if (info.illumination < minIllum) { minIllum = info.illumination; minDate = check; } if (info.illumination > minIllum + 0.05 && minIllum < 0.05) break; } return minDate; } function getSynodicMonth(date: Date) { const startDate = findNewMoon(date, -1); const endDate = findNewMoon(new Date(startDate.getTime() + 86400000), 1); const durationDays = (endDate.getTime() - startDate.getTime()) / 86400000; const offsets: [number, string][] = [ [0, "new_moon"], [0.125, "waxing_crescent"], [0.25, "first_quarter"], [0.375, "waxing_gibbous"], [0.5, "full_moon"], [0.625, "waning_gibbous"], [0.75, "last_quarter"], [0.875, "waning_crescent"], ]; const phases = offsets.map(([frac, phase]) => ({ date: new Date(startDate.getTime() + frac * durationDays * 86400000), phase, emoji: MOON_EMOJI[phase] || "", })); return { startDate, endDate, durationDays, phases }; } function leafletZoomToSpatial(zoom: number): number { if (zoom <= 2) return 0; if (zoom <= 4) return 1; if (zoom <= 5) return 2; if (zoom <= 7) return 3; if (zoom <= 9) return 4; if (zoom <= 12) return 5; if (zoom <= 15) return 6; if (zoom <= 17) return 7; return 8; } // ── Component ── class FolkCalendarView extends HTMLElement { private shadow: ShadowRoot; private space = ""; private currentDate = new Date(); private viewMode: "month" | "week" | "day" | "season" | "year" = "month"; private events: any[] = []; private sources: any[] = []; private lunarData: Record = {}; private showLunar = true; private selectedDate = ""; private selectedEvent: any = null; private expandedDay = ""; private error = ""; private filteredSources = new Set(); private boundKeyHandler: ((e: KeyboardEvent) => void) | null = null; // Spatio-temporal state private temporalGranularity = 4; // MONTH private spatialGranularity: number | null = null; // null = auto-coupled private zoomCoupled = true; // 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; private mapContainer: HTMLDivElement | null = null; private mapMarkerLayer: any = null; private transitLineLayer: any = null; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.boundKeyHandler = (e: KeyboardEvent) => this.handleKeydown(e); document.addEventListener("keydown", this.boundKeyHandler); if (this.space === "demo") { this.loadDemoData(); return; } this.loadMonth(); this.render(); } disconnectedCallback() { if (this.boundKeyHandler) { 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; this.mapContainer = null; } } // ── Keyboard ── private handleKeydown(e: KeyboardEvent) { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; switch (e.key) { case "ArrowLeft": e.preventDefault(); this.navigate(-1); break; case "ArrowRight": e.preventDefault(); this.navigate(1); break; case "1": this.setTemporalGranularity(2); break; case "2": this.setTemporalGranularity(3); break; case "3": this.setTemporalGranularity(4); break; case "4": this.setTemporalGranularity(5); break; case "5": this.setTemporalGranularity(6); break; case "t": case "T": this.currentDate = new Date(); this.expandedDay = ""; if (this.space === "demo") { this.loadDemoData(); } else { this.loadMonth(); } break; case "l": case "L": this.showLunar = !this.showLunar; this.render(); break; case "+": case "=": e.preventDefault(); this.zoomIn(); break; case "-": case "_": e.preventDefault(); this.zoomOut(); break; case "m": case "M": // 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": this.zoomCoupled = !this.zoomCoupled; if (this.zoomCoupled) { this.spatialGranularity = null; this.syncMapToSpatial(); } this.render(); break; } } // ── Zoom & Coupling ── private setTemporalGranularity(n: number) { n = Math.max(2, Math.min(6, n)); this.temporalGranularity = n; const view = T_TO_VIEW[n]; if (view) this.viewMode = view; if (this.zoomCoupled) { this.spatialGranularity = null; this.syncMapToSpatial(); } this.expandedDay = ""; this.render(); } private zoomIn() { this.setTemporalGranularity(this.temporalGranularity - 1); } private zoomOut() { this.setTemporalGranularity(this.temporalGranularity + 1); } private getEffectiveSpatialIndex(): number { if (this.zoomCoupled) return T_TO_S[this.temporalGranularity]; return this.spatialGranularity ?? T_TO_S[4]; } private getEffectiveLeafletZoom(): number { return S_TO_ZOOM[this.getEffectiveSpatialIndex()]; } private syncMapToSpatial() { if (!this.leafletMap) return; const zoom = this.getEffectiveLeafletZoom(); const center = this.computeMapCenter(); this.leafletMap.flyTo(center, zoom, { duration: 0.8 }); } private computeMapCenter(): [number, number] { const located = this.events.filter(e => e.latitude != null && e.longitude != null && !this.filteredSources.has(e.source_name)); if (located.length === 0) return [52.52, 13.405]; let sumLat = 0, sumLng = 0; for (const e of located) { sumLat += e.latitude; sumLng += e.longitude; } return [sumLat / located.length, sumLng / located.length]; } // ── Demo Data ── private loadDemoData() { const now = new Date(); const year = now.getFullYear(); const month = now.getMonth(); const sources = [ { name: "Work", color: "#3b82f6" }, { name: "Travel", color: "#f97316" }, { name: "Personal", color: "#10b981" }, { name: "Conferences", color: "#8b5cf6" }, ]; const rel = (monthDelta: number, day: number, hour: number, min: number) => new Date(year, month + monthDelta, day, hour, min); const demoEvents: { start: Date; durationMin: number; title: string; source: number; desc: string; location: string | null; virtual: boolean; lat?: number; lng?: number; breadcrumb?: string; }[] = [ // ── LAST MONTH ── { start: rel(-1, 18, 10, 0), durationMin: 90, title: "Sprint 22 Review", source: 0, desc: "Sprint review with Berlin engineering team", location: "Factory Berlin", virtual: false, lat: 52.5030, lng: 13.3345, breadcrumb: "Earth > Europe > Germany > Berlin" }, { start: rel(-1, 19, 14, 0), durationMin: 60, title: "1:1 with Manager", source: 0, desc: "Quarterly check-in", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" }, { start: rel(-1, 20, 9, 0), durationMin: 480, title: "EthBerlin Hackathon Day 1", source: 3, desc: "Web3 hackathon at Factory Kreuzberg", location: "Factory G\u00f6rlitzer Park", virtual: false, lat: 52.4934, lng: 13.4278, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" }, { start: rel(-1, 21, 9, 0), durationMin: 480, title: "EthBerlin Hackathon Day 2", source: 3, desc: "Judging and demos", location: "Factory G\u00f6rlitzer Park", virtual: false, lat: 52.4934, lng: 13.4278, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" }, { start: rel(-1, 22, 18, 0), durationMin: 120, title: "Birthday Dinner \u2014 Marco", source: 2, desc: "Italian dinner at La Focacceria", location: "La Focacceria, Kreuzberg", virtual: false, lat: 52.4940, lng: 13.4100, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" }, { start: rel(-1, 25, 7, 30), durationMin: 75, title: "Morning Yoga", source: 2, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, lat: 52.4960, lng: 13.4088, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" }, { start: rel(-1, 26, 16, 0), durationMin: 60, title: "Client Call NYC", source: 0, desc: "Q4 API integration sync", location: null, virtual: true }, { start: rel(-1, 28, 8, 0), durationMin: 300, title: "Weekend Hike \u2014 Spreewald", source: 2, desc: "Kayak + hike in biosphere reserve", location: "L\u00fcbbenau, Spreewald", virtual: false, lat: 51.8644, lng: 13.7669, breadcrumb: "Earth > Europe > Germany > Brandenburg > Spreewald" }, // ── THIS MONTH ── { start: rel(0, 1, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Daily sync \u2014 Berlin engineering team", location: "Factory Berlin", virtual: false, lat: 52.5030, lng: 13.3345, breadcrumb: "Earth > Europe > Germany > Berlin" }, { start: rel(0, 1, 14, 0), durationMin: 60, title: "Code Review Session", source: 0, desc: "Review PRs from the weekend batch", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" }, { start: rel(0, 2, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Daily sync", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" }, { start: rel(0, 2, 11, 0), durationMin: 60, title: "Design System Sync", source: 0, desc: "Component library review with design team", location: null, virtual: true }, { start: rel(0, 3, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Daily sync", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" }, { start: rel(0, 3, 15, 0), durationMin: 90, title: "Architecture Deep-Dive", source: 0, desc: "CRDT merge strategy for offline-first mobile", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" }, { start: rel(0, 4, 10, 0), durationMin: 90, title: "Product Review", source: 0, desc: "Quarterly product roadmap review", location: "Factory Berlin, Room 3", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" }, { start: rel(0, 4, 12, 30), durationMin: 60, title: "Lunch with Alex", source: 2, desc: "Catch up over Vietnamese food", location: "District Mot, Berlin", virtual: false, lat: 52.5244, lng: 13.4023, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" }, { start: rel(0, 5, 10, 0), durationMin: 120, title: "Sprint Planning", source: 0, desc: "Plan sprint 24 \u2014 local-first sync", location: "Factory Berlin, Room 1", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" }, { start: rel(0, 7, 8, 0), durationMin: 300, title: "Weekend Hike \u2014 Grunewald", source: 2, desc: "Forest loop trail, ~14 km", location: "S-Bahn Grunewald", virtual: false, lat: 52.4730, lng: 13.2260, breadcrumb: "Earth > Europe > Germany > Berlin > Grunewald" }, { start: rel(0, 8, 16, 0), durationMin: 60, title: "Client Call NYC", source: 0, desc: "Sync with NYC partner team on API integration", location: null, virtual: true }, { start: rel(0, 9, 7, 30), durationMin: 75, title: "Morning Yoga", source: 2, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" }, { start: rel(0, 10, 11, 0), durationMin: 45, title: "1:1 with Manager", source: 0, desc: "Monthly check-in", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" }, { start: rel(0, 10, 15, 30), durationMin: 60, title: "Deploy Prep", source: 0, desc: "Pre-release checklist and staging verification", location: null, virtual: true }, { start: rel(0, 12, 7, 15), durationMin: 390, title: "Train Berlin \u2192 Amsterdam", source: 1, desc: "ICE 643 Berlin Hbf \u2192 Amsterdam Centraal", location: "Berlin Hauptbahnhof", virtual: false, lat: 52.5251, lng: 13.3694, breadcrumb: "Earth > Europe > Germany > Berlin" }, { start: rel(0, 12, 14, 30), durationMin: 30, title: "Hotel Check-in", source: 1, desc: "Hotel V Nesplein, Amsterdam", location: "Hotel V Nesplein", virtual: false, lat: 52.3667, lng: 4.8945, breadcrumb: "Earth > Europe > Netherlands > Amsterdam" }, { start: rel(0, 13, 10, 0), durationMin: 180, title: "Partner Meeting", source: 0, desc: "On-site with Amsterdam design team", location: "WeWork Weteringschans", virtual: false, lat: 52.3603, lng: 4.8880, breadcrumb: "Earth > Europe > Netherlands > Amsterdam" }, { start: rel(0, 13, 15, 0), durationMin: 120, title: "Canal District Walk", source: 2, desc: "Afternoon along Prinsengracht and Jordaan", location: "Prinsengracht, Amsterdam", virtual: false, lat: 52.3738, lng: 4.8820, breadcrumb: "Earth > Europe > Netherlands > Amsterdam" }, { start: rel(0, 14, 9, 30), durationMin: 390, title: "Return Train Amsterdam \u2192 Berlin", source: 1, desc: "ICE 148 Amsterdam \u2192 Berlin", location: "Amsterdam Centraal", virtual: false, lat: 52.3791, lng: 4.9003, breadcrumb: "Earth > Europe > Netherlands > Amsterdam" }, { start: rel(0, 15, 19, 30), durationMin: 120, title: "Dinner with Friends", source: 2, desc: "Birthday dinner for Mia", location: "Il Casolare, Kreuzberg", virtual: false, lat: 52.4900, lng: 13.4200, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" }, { start: rel(0, 16, 7, 30), durationMin: 75, title: "Morning Yoga", source: 2, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" }, { start: rel(0, 17, 10, 0), durationMin: 60, title: "Sprint Retro", source: 0, desc: "Sprint 23 retrospective", location: "Factory Berlin, Room 2", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" }, { start: rel(0, 17, 14, 0), durationMin: 120, title: "Release Deploy", source: 0, desc: "Push v2.4.0 to production", location: null, virtual: true }, { start: rel(0, 18, 14, 0), durationMin: 90, title: "Demo Day", source: 0, desc: "Sprint 23 showcase for stakeholders", location: "Factory Berlin, Main Hall", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" }, { start: rel(0, 19, 9, 0), durationMin: 45, title: "Dentist", source: 2, desc: "Regular checkup, Dr. Weber", location: "Torstr. 140, Berlin", virtual: false, lat: 52.5308, lng: 13.3970, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" }, { start: rel(0, 20, 19, 0), durationMin: 90, title: "Book Club", source: 2, desc: '"The Mushroom at the End of the World"', location: "Shakespeare & Sons, Berlin", virtual: false, lat: 52.4925, lng: 13.4310, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" }, { start: rel(0, 21, 14, 0), durationMin: 60, title: "c-base Open Tuesday", source: 2, desc: "Weekly open hackerspace session", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" }, { start: rel(0, 22, 6, 45), durationMin: 195, title: "Flight \u2192 Lisbon", source: 1, desc: "TAP TP 571 BER \u2192 LIS", location: "BER Airport", virtual: false, lat: 52.3667, lng: 13.5033, breadcrumb: "Earth > Europe > Germany > Berlin" }, { start: rel(0, 23, 9, 0), durationMin: 540, title: "Web Summit Day 1", source: 3, desc: "Opening keynotes, startup pavilion", location: "Altice Arena, Lisbon", virtual: false, lat: 38.7685, lng: -9.0943, breadcrumb: "Earth > Europe > Portugal > Lisbon" }, { start: rel(0, 24, 9, 0), durationMin: 540, title: "Web Summit Day 2", source: 3, desc: "Panel: Local-First Software", location: "Altice Arena, Lisbon", virtual: false, lat: 38.7685, lng: -9.0943, breadcrumb: "Earth > Europe > Portugal > Lisbon" }, { start: rel(0, 25, 10, 0), durationMin: 360, title: "Lisbon City Tour", source: 2, desc: "Alfama, Tram 28, Past\u00e9is de Bel\u00e9m", location: "Alfama, Lisbon", virtual: false, lat: 38.7118, lng: -9.1300, breadcrumb: "Earth > Europe > Portugal > Lisbon" }, { start: rel(0, 25, 19, 30), durationMin: 195, title: "Flight \u2192 Berlin", source: 1, desc: "TAP TP 572 LIS \u2192 BER", location: "Lisbon Airport", virtual: false, lat: 38.7756, lng: -9.1354, breadcrumb: "Earth > Europe > Portugal > Lisbon" }, { start: rel(0, 26, 18, 0), durationMin: 180, title: "Hackathon \u2014 c-base", source: 2, desc: "Local-first data sync hackathon", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" }, { start: rel(0, 27, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Post-travel sync", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" }, { start: rel(0, 28, 15, 0), durationMin: 90, title: "Architecture Review", source: 0, desc: "Review local-first sync architecture", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" }, // ── NEXT MONTH ── { start: rel(1, 2, 10, 0), durationMin: 120, title: "Sprint 25 Planning", source: 0, desc: "Plan next sprint", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" }, { start: rel(1, 3, 6, 0), durationMin: 180, title: "Flight \u2192 Barcelona", source: 1, desc: "VY 1862 BER \u2192 BCN", location: "BER Airport", virtual: false, lat: 52.3667, lng: 13.5033, breadcrumb: "Earth > Europe > Germany > Berlin" }, { start: rel(1, 3, 14, 0), durationMin: 240, title: "Team Retreat Day 1", source: 0, desc: "Strategy offsite at Barcel\u00f3 Sants", location: "Barcel\u00f3 Sants, Barcelona", virtual: false, lat: 41.3795, lng: 2.1405, breadcrumb: "Earth > Europe > Spain > Barcelona" }, { start: rel(1, 4, 9, 0), durationMin: 480, title: "Team Retreat Day 2", source: 0, desc: "Workshops + Sagrada Familia visit", location: "Barcelona", virtual: false, lat: 41.4036, lng: 2.1744, breadcrumb: "Earth > Europe > Spain > Barcelona" }, { start: rel(1, 5, 15, 0), durationMin: 180, title: "Flight \u2192 Berlin", source: 1, desc: "VY 1863 BCN \u2192 BER", location: "BCN Airport", virtual: false, lat: 41.2974, lng: 2.0833, breadcrumb: "Earth > Europe > Spain > Barcelona" }, { start: rel(1, 8, 19, 0), durationMin: 120, title: "Berlin Philharmonic", source: 2, desc: "Brahms Symphony No. 4", location: "Berliner Philharmonie", virtual: false, lat: 52.5103, lng: 13.3699, breadcrumb: "Earth > Europe > Germany > Berlin > Tiergarten" }, { start: rel(1, 12, 10, 0), durationMin: 120, title: "Brussels Workshop", source: 3, desc: "EU Digital Commons Working Group", location: "Brussels", virtual: false, lat: 50.8503, lng: 4.3517, breadcrumb: "Earth > Europe > Belgium > Brussels" }, ]; this.events = demoEvents.map((e, i) => { const endDate = new Date(e.start.getTime() + e.durationMin * 60000); const src = sources[e.source]; return { id: `demo-${i + 1}`, title: e.title, start_time: e.start.toISOString(), end_time: endDate.toISOString(), source_color: src.color, source_name: src.name, description: e.desc, location_name: e.location || undefined, location_breadcrumb: e.breadcrumb || null, is_virtual: e.virtual, virtual_platform: e.virtual ? "Jitsi" : undefined, virtual_url: e.virtual ? "#" : undefined, latitude: e.lat, longitude: e.lng, }; }); this.sources = sources; // Compute lunar phases for visible range (±6 months for season/year views) const lunar: Record = {}; for (let m = month - 6; m <= month + 6; m++) { const actualYear = m < 0 ? year - 1 : (m > 11 ? year + 1 : year); const actualMonth = ((m % 12) + 12) % 12; const dim = new Date(actualYear, actualMonth + 1, 0).getDate(); for (let d = 1; d <= dim; d++) { const ds = `${actualYear}-${String(actualMonth + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; if (lunar[ds]) continue; const info = lunarPhaseForDate(new Date(actualYear, actualMonth, d)); lunar[ds] = { phase: info.phase, illumination: info.illumination }; } } this.lunarData = lunar; this.render(); } // ── API ── private getApiBase(): string { // When on the rcal page directly, extract from URL const match = window.location.pathname.match(/^\/([^/]+)\/rcal/); if (match) return `/${match[1]}/rcal`; // When embedded as a canvas shape, use the space attribute if (this.space) return `/${this.space}/rcal`; return ""; } private async loadMonth() { const year = this.currentDate.getFullYear(); const month = this.currentDate.getMonth(); const start = `${year}-${String(month + 1).padStart(2, "0")}-01`; const lastDay = new Date(year, month + 1, 0).getDate(); const end = `${year}-${String(month + 1).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`; const base = this.getApiBase(); try { const [eventsRes, sourcesRes, lunarRes] = await Promise.all([ fetch(`${base}/api/events?start=${start}&end=${end}`), fetch(`${base}/api/sources`), fetch(`${base}/api/lunar?start=${start}&end=${end}`), ]); if (eventsRes.ok) { const data = await eventsRes.json(); this.events = data.results || []; } if (sourcesRes.ok) { const data = await sourcesRes.json(); this.sources = data.results || []; } if (lunarRes.ok) { this.lunarData = await lunarRes.json(); } } catch { /* offline fallback */ } this.render(); } // ── Navigation ── private navigate(delta: number) { switch (this.viewMode) { case "day": this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), this.currentDate.getDate() + delta); break; case "week": this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), this.currentDate.getDate() + delta * 7); break; case "month": this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + delta, 1); break; case "season": this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + delta * 3, 1); break; case "year": this.currentDate = new Date(this.currentDate.getFullYear() + delta, this.currentDate.getMonth(), 1); break; } this.expandedDay = ""; if (this.space !== "demo") { this.loadMonth(); } else { this.render(); } } // ── Helpers ── private getMoonEmoji(phase: string): string { return MOON_EMOJI[phase] || ""; } private formatTime(iso: string): string { const d = new Date(iso); return `${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`; } private dateStr(d: Date): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } private getEventsForDate(dateStr: string): any[] { return this.events.filter(e => e.start_time && e.start_time.startsWith(dateStr) && !this.filteredSources.has(e.source_name)); } private toggleSource(name: string) { if (this.filteredSources.has(name)) { this.filteredSources.delete(name); } else { this.filteredSources.add(name); } this.render(); } // ── Main Render ── private render() { // Preserve map container across re-renders if (this.mapContainer && this.mapContainer.parentElement) { this.mapContainer.remove(); } const isDocked = this.mapPanelState === "docked"; this.shadow.innerHTML = ` ${this.error ? `
${this.esc(this.error)}
` : ""} ${this.renderLunarOverlay()} ${this.renderZoomController()} ${this.renderSources()}
${this.renderCalendarContent()}
${this.renderMapPanel()}
+/- zoom • \u2190/\u2192 nav • t today • 1-5 view • m map • c coupling • l lunar • scroll zoom
${this.selectedEvent ? this.renderEventModal() : ""} `; this.attachListeners(); // Initialize or update map when not minimized if (this.mapPanelState !== "minimized") { this.initOrUpdateMap(); } } private getViewLabel(): string { const d = this.currentDate; switch (this.viewMode) { case "day": return d.toLocaleDateString("default", { weekday: "long", month: "long", day: "numeric", year: "numeric" }); case "week": { const ws = new Date(d); ws.setDate(d.getDate() - d.getDay()); const we = new Date(ws); we.setDate(ws.getDate() + 6); return `${ws.toLocaleDateString("default", { month: "short", day: "numeric" })} \u2013 ${we.toLocaleDateString("default", { month: "short", day: "numeric", year: "numeric" })}`; } case "month": return d.toLocaleString("default", { month: "long", year: "numeric" }); case "season": { const q = Math.floor(d.getMonth() / 3); return `${["Winter","Spring","Summer","Autumn"][q]} ${d.getFullYear()}`; } case "year": return `${d.getFullYear()}`; default: return d.toLocaleString("default", { month: "long", year: "numeric" }); } } // ── Lunar Overlay (replaces old Lunar tab) ── 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}
`; } // ── Zoom Controller ── private renderZoomController(): string { const levels = [ { idx: 2, label: "Day" }, { idx: 3, label: "Week" }, { idx: 4, label: "Month" }, { idx: 5, label: "Season" }, { idx: 6, label: "Year" }, ]; const canIn = this.temporalGranularity > 2; const canOut = this.temporalGranularity < 6; const spatialLabel = SPATIAL_LABELS[this.getEffectiveSpatialIndex()]; return `
${levels.map(l => `
${l.label}
`).join("")}
`; } // ── Sources ── private renderSources(): string { if (this.sources.length === 0) return ""; return `
${this.sources.map(s => `${this.esc(s.name)}`).join("")}
`; } // ── Map Panel (floating / docked / minimized) ── 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(); case "week": return this.renderWeek(); case "month": return this.renderMonth(); case "season": return this.renderSeason(); case "year": return this.renderYear(); default: return this.renderMonth(); } } // ── Month View ── private renderMonth(): string { const year = this.currentDate.getFullYear(); const month = this.currentDate.getMonth(); return `
${["S", "M", "T", "W", "T", "F", "S"].map(d => `
${d}
`).join("")}
${this.renderDays(year, month)}
`; } private renderDays(year: number, month: number): string { const firstDay = new Date(year, month, 1).getDay(); const daysInMonth = new Date(year, month + 1, 0).getDate(); const today = new Date(); const todayStr = this.dateStr(today); let html = ""; const prevDays = new Date(year, month, 0).getDate(); for (let i = firstDay - 1; i >= 0; i--) { html += `
${prevDays - i}
`; } for (let d = 1; d <= daysInMonth; d++) { const ds = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; const isToday = ds === todayStr; const isExpanded = ds === this.expandedDay; const dayEvents = this.getEventsForDate(ds); const lunar = this.lunarData[ds]; html += `
${d} ${this.showLunar && lunar ? `${this.getMoonEmoji(lunar.phase)}` : ""}
${dayEvents.length > 0 ? `
${dayEvents.slice(0, 4).map(e => ``).join("")} ${dayEvents.length > 4 ? `+${dayEvents.length - 4}` : ""}
${dayEvents.slice(0, 2).map(e => `
${this.formatTime(e.start_time)}${this.esc(e.title)}
` ).join("")} ` : ""}
`; if (isExpanded) { const cellIndex = firstDay + d - 1; const posInRow = cellIndex % 7; if (posInRow === 6 || d === daysInMonth) { html += this.renderDayDetail(ds, dayEvents); } } } const totalCells = firstDay + daysInMonth; const remaining = (7 - (totalCells % 7)) % 7; for (let i = 1; i <= remaining; i++) { html += `
${i}
`; } if (this.expandedDay) { const dayEvents = this.getEventsForDate(this.expandedDay); html += this.renderDayDetail(this.expandedDay, dayEvents); } return html; } // ── Season View (3 mini-months) ── private renderSeason(): string { const year = this.currentDate.getFullYear(); const quarter = Math.floor(this.currentDate.getMonth() / 3); const months = [quarter * 3, quarter * 3 + 1, quarter * 3 + 2]; const seasonName = ["Winter", "Spring", "Summer", "Autumn"][quarter]; return `
${seasonName} ${year} Q${quarter + 1}
${months.map(m => this.renderMiniMonth(year, m)).join("")}
Click any day to zoom in
`; } // ── Year View (12 mini-months) ── private renderYear(): string { const year = this.currentDate.getFullYear(); return `
${Array.from({length: 12}, (_, i) => this.renderMiniMonth(year, i)).join("")}
`; } // ── Mini-Month (shared by Season & Year views) ── private renderMiniMonth(year: number, month: number): string { const monthName = new Date(year, month, 1).toLocaleDateString("default", { month: "short" }); const firstDay = new Date(year, month, 1).getDay(); const daysInMonth = new Date(year, month + 1, 0).getDate(); const today = new Date(); const todayStr = this.dateStr(today); const isCurrentMonth = year === today.getFullYear() && month === today.getMonth(); let daysHtml = ""; for (let i = 0; i < firstDay; i++) { daysHtml += `
`; } for (let d = 1; d <= daysInMonth; d++) { const ds = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; const isToday = ds === todayStr; const dayEvents = this.getEventsForDate(ds); const hasEvents = dayEvents.length > 0; daysHtml += `
${d}${hasEvents ? `` : ""}
`; } return `
${monthName}
${["S","M","T","W","T","F","S"].map(d => `
${d}
`).join("")}
${daysHtml}
`; } // ── Day View ── private renderDay(): string { const ds = this.dateStr(this.currentDate); const dayEvents = this.getEventsForDate(ds); const lunar = this.lunarData[ds]; const now = new Date(); const isToday = this.dateStr(now) === ds; const HOUR_HEIGHT = 48; const START_HOUR = 6; const END_HOUR = 23; const allDay = dayEvents.filter(e => { const s = new Date(e.start_time), en = new Date(e.end_time); return (en.getTime() - s.getTime()) >= 86400000; }); const timed = dayEvents.filter(e => { const s = new Date(e.start_time), en = new Date(e.end_time); return (en.getTime() - s.getTime()) < 86400000; }).sort((a, b) => a.start_time.localeCompare(b.start_time)); let hoursHtml = ""; for (let h = START_HOUR; h <= END_HOUR; h++) { const label = h === 0 ? "12 AM" : h < 12 ? `${h} AM` : h === 12 ? "12 PM" : `${h - 12} PM`; hoursHtml += `
${label}
`; } let eventsHtml = ""; for (const ev of timed) { const start = new Date(ev.start_time), end = new Date(ev.end_time); const startMin = start.getHours() * 60 + start.getMinutes(); const endMin = end.getHours() * 60 + end.getMinutes(); const duration = Math.max(endMin - startMin, 30); const topPx = ((startMin - START_HOUR * 60) / 60) * HOUR_HEIGHT; const heightPx = Math.max((duration / 60) * HOUR_HEIGHT, 24); const bgColor = ev.source_color ? `${ev.source_color}18` : "#6366f118"; eventsHtml += `
${this.esc(ev.title)}
${this.formatTime(ev.start_time)} \u2013 ${this.formatTime(ev.end_time)}
${ev.location_name ? `
${this.esc(ev.location_name)}
` : ""}
`; } let nowHtml = ""; if (isToday) { const nowMin = now.getHours() * 60 + now.getMinutes(); const nowPx = ((nowMin - START_HOUR * 60) / 60) * HOUR_HEIGHT; if (nowPx >= 0 && nowPx <= (END_HOUR - START_HOUR + 1) * HOUR_HEIGHT) { nowHtml = `
`; } } return `
${this.showLunar && lunar ? `${this.getMoonEmoji(lunar.phase)} ${Math.round(lunar.illumination * 100)}% illuminated \u00B7 ` : ""} ${dayEvents.length} event${dayEvents.length !== 1 ? "s" : ""}
${allDay.length > 0 ? `
All Day
${allDay.map(e => `
${this.esc(e.title)}
`).join("")}
` : ""}
${hoursHtml}${eventsHtml}${nowHtml}
`; } // ── Week View ── private renderWeek(): string { const d = this.currentDate; const weekStart = new Date(d.getFullYear(), d.getMonth(), d.getDate() - d.getDay()); const today = new Date(); const todayStr = this.dateStr(today); const HOUR_HEIGHT = 48; const START_HOUR = 7; const END_HOUR = 22; const totalHeight = (END_HOUR - START_HOUR + 1) * HOUR_HEIGHT; let headerHtml = `
`; const days: Date[] = []; for (let i = 0; i < 7; i++) { const day = new Date(weekStart); day.setDate(weekStart.getDate() + i); days.push(day); const ds = this.dateStr(day); headerHtml += `
${day.toLocaleDateString("default", { weekday: "short" })} ${day.getDate()}
`; } let gridHtml = ""; for (let h = START_HOUR; h <= END_HOUR; h++) { const label = h === 0 ? "12a" : h < 12 ? `${h}a` : h === 12 ? "12p" : `${h - 12}p`; gridHtml += `
${label}
`; for (let i = 0; i < 7; i++) { gridHtml += `
`; } } let eventsOverlay = ""; for (let i = 0; i < 7; i++) { const ds = this.dateStr(days[i]); for (const ev of this.getEventsForDate(ds)) { const start = new Date(ev.start_time), end = new Date(ev.end_time); if ((end.getTime() - start.getTime()) >= 86400000) continue; const startMin = start.getHours() * 60 + start.getMinutes(); const endMin = end.getHours() * 60 + end.getMinutes(); const duration = Math.max(endMin - startMin, 20); const topPx = ((startMin - START_HOUR * 60) / 60) * HOUR_HEIGHT; const heightPx = Math.max((duration / 60) * HOUR_HEIGHT, 18); const bgColor = ev.source_color ? `${ev.source_color}20` : "#6366f120"; const colLeft = `calc(44px + ${i} * ((100% - 44px) / 7) + 2px)`; const colWidth = `calc((100% - 44px) / 7 - 4px)`; eventsOverlay += `
${this.esc(ev.title)}
`; } } let nowHtml = ""; const nowDay = days.findIndex(day => this.dateStr(day) === todayStr); if (nowDay >= 0) { const nowMin = today.getHours() * 60 + today.getMinutes(); const nowPx = ((nowMin - START_HOUR * 60) / 60) * HOUR_HEIGHT; if (nowPx >= 0 && nowPx <= totalHeight) { nowHtml = `
`; } } return `
${headerHtml}
${gridHtml}
${eventsOverlay}${nowHtml}
`; } // ── Day Detail Panel ── private renderDayDetail(dateStr: string, dayEvents: any[]): string { const d = new Date(dateStr + "T00:00:00"); const label = d.toLocaleDateString("default", { weekday: "long", month: "long", day: "numeric" }); return `
${label}
${dayEvents.length === 0 ? `
No events
` : dayEvents.sort((a, b) => a.start_time.localeCompare(b.start_time)).map(e => `
${this.esc(e.title)}
${this.formatTime(e.start_time)}${e.end_time ? ` \u2013 ${this.formatTime(e.end_time)}` : ""}${e.location_name ? ` \u00B7 ${this.esc(e.location_name)}` : ""}${e.is_virtual ? " \u00B7 Virtual" : ""}
`).join("")}
`; } // ── Event Modal ── private renderEventModal(): string { const e = this.selectedEvent; return ` `; } // ── Attach Listeners ── private attachListeners() { const $ = (id: string) => this.shadow.getElementById(id); const $$ = (sel: string) => this.shadow.querySelectorAll(sel); // Nav $("prev")?.addEventListener("click", () => this.navigate(-1)); $("next")?.addEventListener("click", () => this.navigate(1)); $("today")?.addEventListener("click", () => { this.currentDate = new Date(); this.expandedDay = ""; if (this.space === "demo") { this.loadDemoData(); } else { this.loadMonth(); } }); $("toggle-lunar")?.addEventListener("click", () => { this.showLunar = !this.showLunar; 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 $("zoom-in")?.addEventListener("click", () => this.zoomIn()); $("zoom-out")?.addEventListener("click", () => this.zoomOut()); $$("[data-zoom]").forEach(el => { el.addEventListener("click", () => { this.setTemporalGranularity(parseInt((el as HTMLElement).dataset.zoom!)); }); }); $("toggle-coupling")?.addEventListener("click", () => { this.zoomCoupled = !this.zoomCoupled; if (this.zoomCoupled) { this.spatialGranularity = null; this.syncMapToSpatial(); } this.render(); }); // Source filters $$("[data-source]").forEach(el => { el.addEventListener("click", (e) => { e.stopPropagation(); this.toggleSource((el as HTMLElement).dataset.source!); }); }); // Month view: day cell tap $$(".day:not(.other)").forEach(el => { el.addEventListener("click", () => { const date = (el as HTMLElement).dataset.date; if (!date) return; this.expandedDay = this.expandedDay === date ? "" : date; this.render(); }); }); // Week day header click → switch to day view $$("[data-day-click]").forEach(el => { el.addEventListener("click", () => { const ds = (el as HTMLElement).dataset.date; if (!ds) return; const parts = ds.split("-"); this.currentDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])); this.setTemporalGranularity(2); // Day }); }); // Mini-month day click → zoom to day $$("[data-mini-date]").forEach(el => { el.addEventListener("click", (e) => { e.stopPropagation(); const ds = (el as HTMLElement).dataset.miniDate; if (!ds) return; const parts = ds.split("-"); this.currentDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])); this.setTemporalGranularity(2); // Day }); }); // Mini-month title click → zoom to month $$("[data-mini-month]").forEach(el => { el.addEventListener("click", () => { const m = parseInt((el as HTMLElement).dataset.miniMonth!); this.currentDate = new Date(this.currentDate.getFullYear(), m, 1); this.setTemporalGranularity(4); // Month }); }); // Event clicks → modal $$("[data-event-id]").forEach(el => { el.addEventListener("click", (e) => { e.stopPropagation(); const id = (el as HTMLElement).dataset.eventId; this.selectedEvent = this.events.find(ev => ev.id === id); this.render(); }); }); // Close day detail $("dd-close")?.addEventListener("click", (e) => { e.stopPropagation(); this.expandedDay = ""; this.render(); }); // Modal close $("modal-overlay")?.addEventListener("click", (e) => { 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 ── private async initOrUpdateMap() { await ensureLeaflet(); const L = (window as any).L; if (!L) return; const host = this.shadow.getElementById("map-host"); if (!host) return; if (!this.mapContainer) { this.mapContainer = document.createElement("div"); this.mapContainer.style.width = "100%"; this.mapContainer.style.height = "100%"; this.mapContainer.style.minHeight = "250px"; this.mapContainer.style.borderRadius = "8px"; } host.appendChild(this.mapContainer); if (!this.leafletMap) { const center = this.computeMapCenter(); const zoom = this.getEffectiveLeafletZoom(); this.leafletMap = L.map(this.mapContainer, { center, zoom, zoomControl: false, }); L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", { attribution: '\u00A9 OSM \u00A9 CARTO', subdomains: "abcd", maxZoom: 19, }).addTo(this.leafletMap); this.mapMarkerLayer = L.layerGroup().addTo(this.leafletMap); this.transitLineLayer = L.layerGroup().addTo(this.leafletMap); // Manual zoom → update spatial granularity (when uncoupled) this.leafletMap.on("zoomend", () => { if (!this.zoomCoupled) { this.spatialGranularity = leafletZoomToSpatial(this.leafletMap.getZoom()); const label = this.shadow.getElementById("map-spatial-label"); if (label) label.textContent = SPATIAL_LABELS[this.spatialGranularity]; } }); } else { setTimeout(() => this.leafletMap?.invalidateSize(), 50); } this.updateMapMarkers(); this.updateTransitLines(); } private updateMapMarkers() { const L = (window as any).L; if (!L || !this.mapMarkerLayer) return; this.mapMarkerLayer.clearLayers(); const located = this.events.filter(e => e.latitude != null && e.longitude != null && !this.filteredSources.has(e.source_name)); for (const ev of located) { const marker = L.circleMarker([ev.latitude, ev.longitude], { radius: 6, color: ev.source_color || "#6366f1", fillColor: ev.source_color || "#6366f1", fillOpacity: 0.7, weight: 2, }); marker.bindPopup(`
${this.esc(ev.title)}
${ev.location_name ? `
${this.esc(ev.location_name)}
` : ""}
${new Date(ev.start_time).toLocaleDateString("default", { month: "short", day: "numeric" })} ${this.formatTime(ev.start_time)}
`); this.mapMarkerLayer.addLayer(marker); } } private updateTransitLines() { const L = (window as any).L; if (!L || !this.transitLineLayer) return; this.transitLineLayer.clearLayers(); const sorted = this.events .filter(e => e.latitude != null && e.longitude != null && !this.filteredSources.has(e.source_name)) .sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime()); if (sorted.length < 2) return; for (let i = 0; i < sorted.length - 1; i++) { const curr = sorted[i], next = sorted[i + 1]; if (Math.abs(curr.latitude - next.latitude) < 0.01 && Math.abs(curr.longitude - next.longitude) < 0.01) continue; const isTravel = curr.source_name === "Travel" || next.source_name === "Travel"; const line = L.polyline( [[curr.latitude, curr.longitude], [next.latitude, next.longitude]], { color: isTravel ? "#f97316" : "#94a3b8", weight: isTravel ? 3 : 2, opacity: isTravel ? 0.8 : 0.4, dashArray: isTravel ? "8, 8" : "4, 8" } ); line.bindTooltip( `${this.esc(curr.location_name || curr.title)} \u2192 ${this.esc(next.location_name || next.title)}`, { sticky: true } ); this.transitLineLayer.addLayer(line); } } // ── Styles ── private getStyles(): string { return ` :host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; padding: 0.5rem; } * { box-sizing: border-box; } .error { color: #ef5350; text-align: center; padding: 8px; } /* ── Nav ── */ .nav { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; min-height: 36px; flex-wrap: wrap; } .nav-btn { padding: 6px 12px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.12); background: transparent; color: #94a3b8; cursor: pointer; font-size: 14px; -webkit-tap-highlight-color: transparent; } .nav-btn:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); } .nav-btn.active { border-color: #6366f1; color: #6366f1; } .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; } /* ── 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; } .zoom-btn { width: 28px; height: 28px; border-radius: 50%; border: 1px solid #333; background: transparent; color: #94a3b8; cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .zoom-btn:hover { border-color: #6366f1; color: #e2e8f0; } .zoom-btn:disabled { opacity: 0.3; cursor: not-allowed; } .zoom-btn:disabled:hover { border-color: #333; color: #94a3b8; } .zoom-track { flex: 1; display: flex; align-items: center; gap: 0; min-width: 200px; } .zoom-tick { flex: 1; text-align: center; cursor: pointer; padding: 4px 0; } .zoom-tick-dot { width: 12px; height: 12px; border-radius: 50%; border: 2px solid #333; background: #16161e; margin: 0 auto; transition: all 0.15s; } .zoom-tick-dot.active { border-color: #6366f1; background: #4f46e5; transform: scale(1.2); } .zoom-tick-label { font-size: 9px; color: #4a5568; margin-top: 3px; transition: color 0.15s; } .zoom-tick-label.active { color: #818cf8; font-weight: 600; } .coupling-btn { padding: 4px 10px; border-radius: 12px; border: 1px solid #333; background: transparent; color: #64748b; cursor: pointer; font-size: 11px; transition: all 0.15s; white-space: nowrap; flex-shrink: 0; } .coupling-btn:hover { border-color: #555; color: #94a3b8; } .coupling-btn.coupled { border-color: #6366f1; color: #818cf8; background: rgba(99,102,241,0.08); } /* ── Sources ── */ .sources { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; } .src-badge { font-size: 10px; padding: 3px 8px; border-radius: 10px; border: 1px solid #333; cursor: pointer; transition: opacity 0.15s; user-select: none; } .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; } .grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; } .day { background: #16161e; border: 1px solid #222; border-radius: 6px; min-height: 80px; padding: 6px; cursor: pointer; position: relative; -webkit-tap-highlight-color: transparent; } .day:hover { border-color: #444; } .day.today { border-color: #6366f1; background: rgba(99,102,241,0.06); } .day.expanded { border-color: #6366f1; background: rgba(99,102,241,0.1); } .day.other { opacity: 0.3; } .day-num { font-size: 12px; font-weight: 600; margin-bottom: 2px; display: flex; justify-content: space-between; } .moon { font-size: 10px; opacity: 0.7; } .dots { display: flex; flex-wrap: wrap; gap: 1px; } .dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; margin: 1px; } .ev-label { font-size: 9px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #aaa; line-height: 1.4; padding: 1px 3px; border-radius: 3px; cursor: pointer; } .ev-label:hover { background: rgba(255,255,255,0.08); } .ev-time { color: #666; font-size: 8px; margin-right: 2px; } /* ── Day Detail Panel ── */ .day-detail { grid-column: 1 / -1; background: #1a1a2e; border: 1px solid #334155; border-radius: 8px; padding: 12px; } .dd-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } .dd-date { font-size: 14px; font-weight: 600; color: #e2e8f0; } .dd-close { background: none; border: none; color: #64748b; font-size: 18px; cursor: pointer; padding: 4px 8px; } .dd-event { display: flex; gap: 8px; align-items: flex-start; padding: 8px; border-radius: 6px; margin-bottom: 4px; cursor: pointer; -webkit-tap-highlight-color: transparent; } .dd-event:hover { background: rgba(255,255,255,0.05); } .dd-color { width: 4px; border-radius: 2px; align-self: stretch; flex-shrink: 0; } .dd-info { flex: 1; min-width: 0; } .dd-title { font-size: 13px; font-weight: 500; color: #e2e8f0; } .dd-meta { font-size: 11px; color: #94a3b8; margin-top: 2px; } .dd-empty { font-size: 12px; color: #64748b; padding: 8px 0; } /* ── Event Modal ── */ .modal-bg { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000; } .modal { background: #1e1e2e; border: 1px solid #333; border-radius: 12px; padding: 20px; max-width: 400px; width: 90%; } .modal-title { font-size: 16px; font-weight: 600; margin-bottom: 12px; } .modal-field { font-size: 13px; color: #aaa; margin-bottom: 6px; } .modal-close { float: right; background: none; border: none; color: #888; font-size: 18px; cursor: pointer; } /* ── Day View ── */ .day-view { position: relative; } .day-view-header { font-size: 13px; color: #94a3b8; margin-bottom: 8px; font-weight: 500; } .day-allday { background: #16161e; border: 1px solid #222; border-radius: 8px; padding: 8px; margin-bottom: 8px; } .day-allday-label { font-size: 10px; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; } .timeline { position: relative; border-left: 1px solid #222; margin-left: 44px; } .hour-row { display: flex; min-height: 48px; border-bottom: 1px solid rgba(255,255,255,0.04); position: relative; } .hour-label { position: absolute; left: -48px; top: -7px; width: 40px; text-align: right; font-size: 10px; color: #4a5568; font-variant-numeric: tabular-nums; } .hour-content { flex: 1; position: relative; padding-left: 8px; } .tl-event { position: absolute; left: 8px; right: 8px; border-radius: 6px; padding: 4px 8px; font-size: 11px; overflow: hidden; cursor: pointer; border-left: 3px solid; z-index: 1; transition: opacity 0.15s; } .tl-event:hover { opacity: 0.85; } .tl-event-title { font-weight: 600; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .tl-event-time { font-size: 10px; color: #94a3b8; } .tl-event-loc { font-size: 10px; color: #64748b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .now-line { position: absolute; left: 0; right: 0; height: 2px; background: #ef4444; z-index: 5; } .now-dot { position: absolute; left: -5px; top: -3px; width: 8px; height: 8px; border-radius: 50%; background: #ef4444; } /* ── Week View ── */ .week-view { overflow-x: auto; } .week-header { display: grid; grid-template-columns: 44px repeat(7, 1fr); gap: 0; margin-bottom: 0; } .week-day-header { text-align: center; padding: 8px 4px; font-size: 11px; color: #64748b; font-weight: 600; border-bottom: 1px solid #222; cursor: pointer; } .week-day-header:hover { color: #e2e8f0; } .week-day-header.today { color: #6366f1; border-bottom-color: #6366f1; } .week-day-num { font-size: 16px; font-weight: 700; display: block; } .week-day-name { font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; } .week-grid { display: grid; grid-template-columns: 44px repeat(7, 1fr); } .week-time-label { font-size: 10px; color: #4a5568; text-align: right; padding-right: 6px; font-variant-numeric: tabular-nums; height: 48px; } .week-cell { border-left: 1px solid rgba(255,255,255,0.04); border-bottom: 1px solid rgba(255,255,255,0.04); min-height: 48px; position: relative; } .week-cell.today { background: rgba(99,102,241,0.04); } .week-event { position: absolute; left: 2px; right: 2px; border-radius: 4px; padding: 2px 4px; font-size: 10px; overflow: hidden; cursor: pointer; border-left: 2px solid; z-index: 1; } .week-event:hover { opacity: 0.85; } .week-event-title { font-weight: 600; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* ── 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; } .season-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } /* ── Year View ── */ .year-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; } /* ── Mini-Month (shared) ── */ .mini-month { background: #16161e; border: 1px solid #222; border-radius: 8px; padding: 8px; cursor: pointer; transition: border-color 0.15s; } .mini-month:hover { border-color: #444; } .mini-month.current { border-color: #6366f1; background: rgba(99,102,241,0.06); } .mini-month-title { font-size: 12px; font-weight: 600; text-align: center; color: #e2e8f0; margin-bottom: 4px; } .mini-wd-row { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; margin-bottom: 2px; } .mini-wd { text-align: center; font-size: 8px; color: #4a5568; font-weight: 600; } .mini-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; } .mini-day { position: relative; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #94a3b8; border-radius: 3px; aspect-ratio: 1; cursor: pointer; } .mini-day:hover { background: rgba(255,255,255,0.08); } .mini-day.today { background: #4f46e5; color: #fff; font-weight: 700; } .mini-day.empty { cursor: default; } .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; } /* ── 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; } /* ── Mobile ── */ @media (max-width: 768px) { :host { padding: 0.25rem; } .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; } .day-num { font-size: 11px; } .ev-label { display: none; } .dot { width: 5px; height: 5px; } .moon { font-size: 8px; } .nav-title { font-size: 13px; } .nav { gap: 4px; } .sources { gap: 4px; } .src-badge { font-size: 9px; padding: 2px 6px; } .wd { font-size: 10px; padding: 3px; } .week-view { font-size: 10px; } .zoom-track { min-width: 150px; } .zoom-ctrl { gap: 4px; padding: 6px 8px; } .coupling-btn { font-size: 10px; padding: 3px 8px; } .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; } .day-num { font-size: 10px; } .wd { font-size: 9px; padding: 2px; } .nav { flex-wrap: wrap; justify-content: center; } .nav-title { width: 100%; order: -1; margin-bottom: 4px; } .year-grid { grid-template-columns: repeat(2, 1fr); } .mini-day { font-size: 8px; } .mini-month-title { font-size: 10px; } } `; } // ── Escape HTML ── private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-calendar-view", FolkCalendarView);