rspace-online/modules/rcal/components/folk-calendar-view.ts

1384 lines
68 KiB
TypeScript

/**
* <folk-calendar-view> — 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).
* 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<number, "day"|"week"|"month"|"season"|"year"> = {
2: "day", 3: "week", 4: "month", 5: "season", 6: "year"
};
// ── Leaflet CDN Loader ──
let _leafletReady = false;
let _leafletPromise: Promise<void> | null = null;
function ensureLeaflet(): Promise<void> {
if (_leafletReady && typeof (window as any).L !== "undefined") return Promise.resolve();
if (_leafletPromise) return _leafletPromise;
_leafletPromise = new Promise<void>((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<string, string> = {
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<string, { phase: string; illumination: number }> = {};
private showLunar = true;
private selectedDate = "";
private selectedEvent: any = null;
private expandedDay = "";
private error = "";
private filteredSources = new Set<string>();
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;
private activeTab: "temporal" | "spatial" | "lunar" = "temporal";
// 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.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":
if (this.activeTab === "spatial") {
// Already on spatial tab — no toggle needed
} else {
this.activeTab = "spatial";
}
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<string, { phase: string; illumination: number }> = {};
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 {
const match = window.location.pathname.match(/^\/([^/]+)\/cal/);
return match ? `/${match[1]}/cal` : "";
}
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();
}
this.shadow.innerHTML = `
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
<style>${this.getStyles()}</style>
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
<div class="nav">
<button class="nav-btn" id="prev">\u2190</button>
<button class="nav-btn" id="today">Today</button>
<span class="nav-title">${this.getViewLabel()}</span>
<button class="nav-btn ${this.showLunar ? "active" : ""}" id="toggle-lunar">\u{1F319}</button>
<button class="nav-btn" id="next">\u2192</button>
</div>
${this.renderTabs()}
${this.activeTab !== "lunar" ? this.renderZoomController() : ""}
${this.activeTab !== "lunar" ? this.renderSources() : ""}
${this.renderTabContent()}
<div class="kbd-hint">
<kbd>+</kbd>/<kbd>-</kbd> zoom &bull;
<kbd>\u2190</kbd>/<kbd>\u2192</kbd> nav &bull;
<kbd>t</kbd> today &bull;
<kbd>1-5</kbd> view &bull;
<kbd>m</kbd> map &bull;
<kbd>c</kbd> coupling &bull;
<kbd>l</kbd> lunar
</div>
${this.selectedEvent ? this.renderEventModal() : ""}
`;
this.attachListeners();
// Reattach or initialize map
if (this.activeTab === "spatial") {
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" });
}
}
// ── Tabs ──
private renderTabs(): string {
return `<div class="tabs">
<button class="tab-btn ${this.activeTab === "temporal" ? "active" : ""}" data-tab="temporal">Temporal</button>
<button class="tab-btn ${this.activeTab === "spatial" ? "active" : ""}" data-tab="spatial">Spatial</button>
<button class="tab-btn ${this.activeTab === "lunar" ? "active" : ""}" data-tab="lunar">Lunar</button>
</div>`;
}
// ── 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 `<div class="zoom-ctrl">
<button class="zoom-btn" id="zoom-in" ${!canIn ? "disabled" : ""} title="Zoom in (+)">+</button>
<div class="zoom-track">
${levels.map(l => `<div class="zoom-tick" data-zoom="${l.idx}">
<div class="zoom-tick-dot ${this.temporalGranularity === l.idx ? "active" : ""}"></div>
<div class="zoom-tick-label ${this.temporalGranularity === l.idx ? "active" : ""}">${l.label}</div>
</div>`).join("")}
</div>
<button class="zoom-btn" id="zoom-out" ${!canOut ? "disabled" : ""} title="Zoom out (\u2212)">&#x2212;</button>
<button class="coupling-btn ${this.zoomCoupled ? "coupled" : ""}" id="toggle-coupling"
title="${this.zoomCoupled ? "Unlink spatial zoom (c)" : "Link spatial zoom (c)"}">
${this.zoomCoupled ? "\u{1F517}" : "\u{1F513}"} ${spatialLabel}
</button>
</div>`;
}
// ── Sources ──
private renderSources(): string {
if (this.sources.length === 0) return "";
return `<div class="sources">
${this.sources.map(s => `<span class="src-badge ${this.filteredSources.has(s.name) ? "filtered" : ""}"
data-source="${this.esc(s.name)}"
style="border-color:${s.color || "#666"};color:${s.color || "#aaa"}">${this.esc(s.name)}</span>`).join("")}
</div>`;
}
// ── Tab Content Router ──
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 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();
}
}
// ── Spatial Tab (Split Layout) ──
private renderSpatialTab(): string {
return `<div class="spatial-split">
<div class="spatial-calendar">${this.renderCalendarContent()}</div>
<div class="spatial-map" id="map-host">
<div class="map-overlay-label" id="map-spatial-label">${SPATIAL_LABELS[this.getEffectiveSpatialIndex()]}</div>
</div>
</div>`;
}
// ── 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 `<div class="lunar-tab">
<div class="lunar-hero">
<div class="lunar-emoji">${phase.emoji}</div>
<div class="lunar-phase-name">${phase.phase.replace(/_/g, " ")}</div>
<div class="lunar-stats">
<span>Illumination: ${Math.round(phase.illumination * 100)}%</span>
<span>Age: ${phase.age.toFixed(1)} days</span>
<span>Cycle: ${synodic.durationDays.toFixed(1)} days</span>
</div>
</div>
<div class="synodic-section">
<div class="synodic-labels">
<span>\u{1F311} ${synodic.startDate.toLocaleDateString("default", { month: "short", day: "numeric" })}</span>
<span>\u{1F311} ${synodic.endDate.toLocaleDateString("default", { month: "short", day: "numeric" })}</span>
</div>
<div class="synodic-bar">
<div class="synodic-fill" style="width:${progress * 100}%"></div>
${synodic.phases.map(p => {
const pProg = (p.date.getTime() - synodic.startDate.getTime()) / total;
return `<span class="synodic-marker" style="left:${pProg * 100}%"
title="${p.phase.replace(/_/g, " ")} \u2014 ${p.date.toLocaleDateString("default", { month: "short", day: "numeric" })}">${p.emoji}</span>`;
}).join("")}
</div>
</div>
<div class="phase-section">
<div class="phase-section-title">Phases this cycle</div>
<div class="phase-timeline">
${synodic.phases.map(p => {
const isPast = p.date < this.currentDate;
const isCurrent = p.phase === phase.phase;
return `<div class="phase-row ${isCurrent ? "current" : ""} ${isPast && !isCurrent ? "past" : ""}">
<span class="phase-row-emoji">${p.emoji}</span>
<div class="phase-row-info">
<div class="phase-row-name">${p.phase.replace(/_/g, " ")}</div>
<div class="phase-row-date">${p.date.toLocaleDateString("default", { weekday: "short", month: "short", day: "numeric" })}</div>
</div>
</div>`;
}).join("")}
</div>
</div>
</div>`;
}
// ── Month View ──
private renderMonth(): string {
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
return `
<div class="weekdays">
${["S", "M", "T", "W", "T", "F", "S"].map(d => `<div class="wd">${d}</div>`).join("")}
</div>
<div class="grid">
${this.renderDays(year, month)}
</div>`;
}
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 += `<div class="day other"><div class="day-num">${prevDays - i}</div></div>`;
}
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 += `<div class="day ${isToday ? "today" : ""} ${isExpanded ? "expanded" : ""}" data-date="${ds}">
<div class="day-num">
<span>${d}</span>
${this.showLunar && lunar ? `<span class="moon">${this.getMoonEmoji(lunar.phase)}</span>` : ""}
</div>
${dayEvents.length > 0 ? `
<div class="dots">
${dayEvents.slice(0, 4).map(e => `<span class="dot" style="background:${e.source_color || "#6366f1"}"></span>`).join("")}
${dayEvents.length > 4 ? `<span style="font-size:8px;color:#888">+${dayEvents.length - 4}</span>` : ""}
</div>
${dayEvents.slice(0, 2).map(e =>
`<div class="ev-label" style="border-left:2px solid ${e.source_color || "#6366f1"}" data-event-id="${e.id}"><span class="ev-time">${this.formatTime(e.start_time)}</span>${this.esc(e.title)}</div>`
).join("")}
` : ""}
</div>`;
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 += `<div class="day other"><div class="day-num">${i}</div></div>`;
}
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 `
<div class="season-header">${seasonName} ${year} <span class="season-q">Q${quarter + 1}</span></div>
<div class="season-grid">
${months.map(m => this.renderMiniMonth(year, m)).join("")}
</div>
<div class="mini-hint">Click any day to zoom in</div>`;
}
// ── Year View (12 mini-months) ──
private renderYear(): string {
const year = this.currentDate.getFullYear();
return `
<div class="year-grid">
${Array.from({length: 12}, (_, i) => this.renderMiniMonth(year, i)).join("")}
</div>`;
}
// ── 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 += `<div class="mini-day empty"></div>`; }
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 += `<div class="mini-day ${isToday ? "today" : ""}" data-mini-date="${ds}"
title="${hasEvents ? dayEvents.length + ' event' + (dayEvents.length > 1 ? 's' : '') : ''}">${d}${hasEvents ? `<span class="mini-dot" style="background:${dayEvents[0].source_color || '#6366f1'}"></span>` : ""}</div>`;
}
return `<div class="mini-month ${isCurrentMonth ? "current" : ""}" data-mini-month="${month}">
<div class="mini-month-title">${monthName}</div>
<div class="mini-wd-row">${["S","M","T","W","T","F","S"].map(d => `<div class="mini-wd">${d}</div>`).join("")}</div>
<div class="mini-grid">${daysHtml}</div>
</div>`;
}
// ── 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 += `<div class="hour-row"><span class="hour-label">${label}</span><div class="hour-content"></div></div>`;
}
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 += `<div class="tl-event" data-event-id="${ev.id}" style="
top:${topPx}px;height:${heightPx}px;background:${bgColor};border-left-color:${ev.source_color || "#6366f1"}">
<div class="tl-event-title">${this.esc(ev.title)}</div>
<div class="tl-event-time">${this.formatTime(ev.start_time)} \u2013 ${this.formatTime(ev.end_time)}</div>
${ev.location_name ? `<div class="tl-event-loc">${this.esc(ev.location_name)}</div>` : ""}
</div>`;
}
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 = `<div class="now-line" style="top:${nowPx}px"><div class="now-dot"></div></div>`;
}
}
return `
<div class="day-view">
<div class="day-view-header">
${this.showLunar && lunar ? `${this.getMoonEmoji(lunar.phase)} ${Math.round(lunar.illumination * 100)}% illuminated \u00B7 ` : ""}
${dayEvents.length} event${dayEvents.length !== 1 ? "s" : ""}
</div>
${allDay.length > 0 ? `<div class="day-allday">
<div class="day-allday-label">All Day</div>
${allDay.map(e => `<div class="dd-event" data-event-id="${e.id}">
<div class="dd-color" style="background:${e.source_color || "#6366f1"}"></div>
<div class="dd-info"><div class="dd-title">${this.esc(e.title)}</div></div>
</div>`).join("")}
</div>` : ""}
<div class="timeline" style="height:${(END_HOUR - START_HOUR + 1) * HOUR_HEIGHT}px">
${hoursHtml}${eventsHtml}${nowHtml}
</div>
</div>`;
}
// ── 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 = `<div class="week-day-header" style="border-bottom:none"></div>`;
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 += `<div class="week-day-header ${ds === todayStr ? "today" : ""}" data-date="${ds}" data-day-click="true">
<span class="week-day-name">${day.toLocaleDateString("default", { weekday: "short" })}</span>
<span class="week-day-num">${day.getDate()}</span>
</div>`;
}
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 += `<div class="week-time-label">${label}</div>`;
for (let i = 0; i < 7; i++) {
gridHtml += `<div class="week-cell ${this.dateStr(days[i]) === todayStr ? "today" : ""}"></div>`;
}
}
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 += `<div class="week-event" data-event-id="${ev.id}" style="
top:${topPx}px;height:${heightPx}px;left:${colLeft};width:${colWidth};
background:${bgColor};border-left-color:${ev.source_color || "#6366f1"}">
<div class="week-event-title">${this.esc(ev.title)}</div>
</div>`;
}
}
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 = `<div class="now-line" style="top:${nowPx}px;left:44px;right:0"><div class="now-dot"></div></div>`;
}
}
return `
<div class="week-view">
<div class="week-header">${headerHtml}</div>
<div style="position:relative;overflow-y:auto;max-height:600px;">
<div class="week-grid" style="position:relative;height:${totalHeight}px">${gridHtml}</div>
${eventsOverlay}${nowHtml}
</div>
</div>`;
}
// ── 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 `<div class="day-detail">
<div class="dd-header">
<span class="dd-date">${label}</span>
<button class="dd-close" id="dd-close">\u2715</button>
</div>
${dayEvents.length === 0 ? `<div class="dd-empty">No events</div>` :
dayEvents.sort((a, b) => a.start_time.localeCompare(b.start_time)).map(e => `
<div class="dd-event" data-event-id="${e.id}">
<div class="dd-color" style="background:${e.source_color || "#6366f1"}"></div>
<div class="dd-info">
<div class="dd-title">${this.esc(e.title)}</div>
<div class="dd-meta">${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" : ""}</div>
</div>
</div>
`).join("")}
</div>`;
}
// ── Event Modal ──
private renderEventModal(): string {
const e = this.selectedEvent;
return `
<div class="modal-bg" id="modal-overlay">
<div class="modal">
<button class="modal-close" id="modal-close">\u2715</button>
<div class="modal-title">${this.esc(e.title)}</div>
${e.description ? `<div class="modal-field">${this.esc(e.description)}</div>` : ""}
<div class="modal-field">${new Date(e.start_time).toLocaleString()}${e.end_time ? ` \u2013 ${new Date(e.end_time).toLocaleString()}` : ""}</div>
${e.location_name ? `<div class="modal-field">\u{1F4CD} ${this.esc(e.location_name)}</div>` : ""}
${e.location_breadcrumb ? `<div class="modal-field" style="font-size:11px;color:#64748b">${this.esc(e.location_breadcrumb)}</div>` : ""}
${e.source_name ? `<div class="modal-field" style="margin-top:8px"><span class="src-badge" style="border-color:${e.source_color || "#666"};color:${e.source_color || "#aaa"}">${this.esc(e.source_name)}</span></div>` : ""}
${e.is_virtual ? `<div class="modal-field">\u{1F4BB} ${this.esc(e.virtual_platform || "Virtual")} ${e.virtual_url ? `<a href="${e.virtual_url}" target="_blank" style="color:#6366f1">Join</a>` : ""}</div>` : ""}
${e.latitude != null ? `<div class="modal-field" style="font-size:11px;color:#64748b">\u{1F4CD} ${e.latitude.toFixed(4)}, ${e.longitude.toFixed(4)}</div>` : ""}
</div>
</div>`;
}
// ── 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(); });
// Tabs
$$("[data-tab]").forEach(el => {
el.addEventListener("click", () => {
this.activeTab = (el as HTMLElement).dataset.tab as any;
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(); });
}
// ── 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 = "400px";
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 <a href="https://www.openstreetmap.org/copyright">OSM</a> \u00A9 <a href="https://carto.com/">CARTO</a>',
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(`<div style="font-size:13px;color:#1a1a2e">
<div style="font-weight:600">${this.esc(ev.title)}</div>
${ev.location_name ? `<div style="color:#666;font-size:11px">${this.esc(ev.location_name)}</div>` : ""}
<div style="color:#888;font-size:11px">${new Date(ev.start_time).toLocaleDateString("default", { month: "short", day: "numeric" })} ${this.formatTime(ev.start_time)}</div>
</div>`);
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; }
/* ── 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; }
/* ── 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; }
/* ── 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; }
/* ── 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; }
.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; }
/* ── 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; }
/* ── Mobile ── */
@media (max-width: 768px) {
:host { padding: 0.25rem; }
.spatial-split { grid-template-columns: 1fr; }
.spatial-map { min-height: 300px; }
.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-emoji { font-size: 56px; }
.lunar-stats { gap: 10px; font-size: 12px; }
}
@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);