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

2138 lines
107 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <folk-calendar-view> — 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<number, "day"|"week"|"month"|"season"|"year"|"multi-year"> = {
2: "day", 3: "week", 4: "month", 5: "season", 6: "year", 7: "multi-year"
};
const VIEW_VARIANTS: Record<string, number> = {
day: 2, week: 1, month: 2, season: 1, year: 2, "multi-year": 1
};
// ── 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" | "multi-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;
// Map panel state (replaces old tab system)
private mapPanelState: "minimized" | "floating" | "docked" = "floating";
private lunarOverlayExpanded = false;
private _wheelTimer: ReturnType<typeof setTimeout> | null = null;
// Transition state
private _pendingTransition: 'nav-left' | 'nav-right' | 'zoom-in' | 'zoom-out' | null = null;
private _ghostHtml: string | null = null;
private _transitionActive = false;
private viewVariant = 0;
// 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 "6": this.setTemporalGranularity(7); break;
case "v": case "V": {
const maxVariant = VIEW_VARIANTS[this.viewMode] || 1;
if (maxVariant > 1) {
this.viewVariant = (this.viewVariant + 1) % maxVariant;
this.render();
}
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) {
const oldGranularity = this.temporalGranularity;
n = Math.max(2, Math.min(7, n));
if (n !== oldGranularity) {
const calPane = this.shadow.getElementById('calendar-pane');
this._ghostHtml = calPane?.innerHTML || null;
this._pendingTransition = n < oldGranularity ? 'zoom-in' : 'zoom-out';
this.viewVariant = 0;
}
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() { if (this.temporalGranularity < 7) 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 {
// When on the rcal page directly, extract from URL
const match = window.location.pathname.match(/^(\/[^/]+)?\/rcal/);
if (match) return match[0];
// 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) {
const calPane = this.shadow.getElementById('calendar-pane');
this._ghostHtml = calPane?.innerHTML || null;
this._pendingTransition = delta > 0 ? 'nav-left' : 'nav-right';
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;
case "multi-year":
this.currentDate = new Date(this.currentDate.getFullYear() + delta * 9, 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 getSpatialLabel(breadcrumb: string | null | undefined, level: number): string {
if (!breadcrumb) return "";
const parts = breadcrumb.split(" > ").map(s => s.trim());
// SPATIAL_LABELS: Planet(0), Continent(1), Bioregion(2), Country(3), Region(4), City(5), Neighborhood(6), Address(7), Coordinates(8)
// Breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" → indices 0,1,2,3,4
// Map: Country(3)→idx 2, Region(4)→idx 3, City(5)→idx 3-4, Neighborhood(6)→idx 4
const indexMap: Record<number, number> = { 0: 0, 1: 1, 2: 1, 3: 2, 4: 3, 5: 3, 6: 4, 7: 4, 8: 4 };
const partIdx = indexMap[level] ?? 2;
return parts[partIdx] || parts[parts.length - 1] || "";
}
private getCurrentSpatialLabel(): string {
return SPATIAL_LABELS[this.getEffectiveSpatialIndex()];
}
private getUniqueSpatialLabels(events: any[], level: number, max: number): string[] {
const labels = new Set<string>();
for (const e of events) {
if (labels.size >= max) break;
const lbl = this.getSpatialLabel(e.location_breadcrumb, level);
if (lbl) labels.add(lbl);
}
return Array.from(labels);
}
private toggleSource(name: string) {
if (this.filteredSources.has(name)) { this.filteredSources.delete(name); }
else { this.filteredSources.add(name); }
this.render();
}
// ── Transitions ──
private playTransition(calPane: HTMLElement, direction: string, oldHtml: string) {
if (this._transitionActive) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
this._transitionActive = true;
// Create ghost overlay with old content
const ghost = document.createElement('div');
ghost.className = 'transition-ghost';
ghost.innerHTML = oldHtml;
// Wrap new content in enter wrapper
const enterWrap = document.createElement('div');
enterWrap.className = 'transition-enter';
while (calPane.firstChild) enterWrap.appendChild(calPane.firstChild);
calPane.appendChild(enterWrap);
calPane.appendChild(ghost);
// Apply direction-based animation classes
const animMap: Record<string, [string, string]> = {
'nav-left': ['ghost-slide-left', 'enter-slide-left'],
'nav-right': ['ghost-slide-right', 'enter-slide-right'],
'zoom-in': ['ghost-zoom-in', 'enter-zoom-in'],
'zoom-out': ['ghost-zoom-out', 'enter-zoom-out'],
};
const [ghostAnim, enterAnim] = animMap[direction] || animMap['nav-left'];
ghost.classList.add(ghostAnim);
enterWrap.classList.add(enterAnim);
const cleanup = () => {
if (!ghost.parentNode) return;
ghost.remove();
while (enterWrap.firstChild) calPane.insertBefore(enterWrap.firstChild, enterWrap);
enterWrap.remove();
this._transitionActive = false;
};
ghost.addEventListener('animationend', cleanup, { once: true });
setTimeout(cleanup, 400); // safety fallback
}
// ── 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 = `
<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" id="next">\u2192</button>
</div>
${this.renderSources()}
<div class="main-layout ${isDocked ? "main-layout--docked" : ""}">
<div class="calendar-pane" id="calendar-pane">
${this.renderCalendarContent()}
</div>
${this.renderMapPanel()}
</div>
<div class="bottom-bar">
${this.renderZoomController()}
${this.renderLunarOverlay()}
<button class="bottom-bar__lunar-toggle ${this.showLunar ? "active" : ""}" id="toggle-lunar" title="Toggle lunar phases (l)">\u{1F319}</button>
</div>
${this.selectedEvent ? this.renderEventModal() : ""}
`;
this.attachListeners();
// Play transition if pending
if (this._pendingTransition && this._ghostHtml) {
const calPaneEl = this.shadow.getElementById('calendar-pane');
if (calPaneEl) this.playTransition(calPaneEl, this._pendingTransition, this._ghostHtml);
}
this._pendingTransition = null;
this._ghostHtml = null;
// 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()}`;
case "multi-year": {
const centerYear = d.getFullYear();
const startYear = centerYear - 4;
const endYear = centerYear + 4;
return `${startYear} \u2013 ${endYear}`;
}
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 = `
<div class="lunar-expanded">
<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-chips">
${synodic.phases.map(p => {
const isCurrent = p.phase === phase.phase;
const isPast = p.date < this.currentDate && !isCurrent;
return `<span class="phase-chip ${isCurrent ? "current" : ""} ${isPast ? "past" : ""}"
title="${p.date.toLocaleDateString("default", { weekday: "short", month: "short", day: "numeric" })}">
${p.emoji} <span class="phase-chip-label">${p.phase.replace(/_/g, " ")}</span>
</span>`;
}).join("")}
</div>
</div>`;
}
return `<div class="lunar-overlay">
<div class="lunar-summary" id="lunar-summary">
<span class="lunar-summary-phase">${phase.emoji} ${phaseName}</span>
<span class="lunar-summary-stats">${illumPct}% illuminated</span>
<span class="lunar-summary-stats">Day ${dayNum}/29</span>
<span class="lunar-summary-chevron">${chevron}</span>
</div>
${expandedHtml}
</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" }, { idx: 7, label: "Years" },
];
const spatialLabel = SPATIAL_LABELS[this.getEffectiveSpatialIndex()];
const maxVariant = VIEW_VARIANTS[this.viewMode] || 1;
// Position as percentage along the 5-step range (idx 27)
const pct = ((this.temporalGranularity - 2) / 5) * 100;
return `<div class="zoom-bar">
<div class="zoom-bar__row">
<span class="zoom-bar__label-end">Day</span>
<div class="zoom-bar__track" id="zoom-track">
<div class="zoom-bar__gradient"></div>
<div class="zoom-bar__thumb" id="zoom-thumb" style="left:${pct}%"></div>
${levels.map(l => {
const tickPct = ((l.idx - 2) / 5) * 100;
return `<div class="zoom-bar__tick" data-zoom="${l.idx}" style="left:${tickPct}%">
<div class="zoom-bar__tick-mark ${this.temporalGranularity === l.idx ? "active" : ""}"></div>
<div class="zoom-bar__tick-label ${this.temporalGranularity === l.idx ? "active" : ""}">${l.label}</div>
</div>`;
}).join("")}
</div>
<span class="zoom-bar__label-end">Years</span>
${maxVariant > 1 ? `<div class="variant-indicator" title="Press v to toggle variant">
${Array.from({length: maxVariant}, (_, i) =>
`<span class="variant-dot ${i === this.viewVariant ? "active" : ""}"></span>`
).join("")}
</div>` : ""}
<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>
</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>`;
}
// ── Map Panel (floating / docked / minimized) ──
private renderMapPanel(): string {
if (this.mapPanelState === "minimized") {
return `<button class="map-fab" id="map-fab" title="Show map (m)">\u{1F5FA}</button>`;
}
const mode = this.mapPanelState;
const spatialLabel = SPATIAL_LABELS[this.getEffectiveSpatialIndex()];
return `<div class="map-panel map-panel--${mode}" id="map-panel">
<div class="map-panel-header">
<span class="map-panel-title">\u{1F5FA} ${spatialLabel}</span>
<div class="map-panel-controls">
${mode === "floating"
? `<button class="map-ctrl-btn" id="map-dock" title="Dock side-by-side">\u{2B1C}</button>`
: `<button class="map-ctrl-btn" id="map-float" title="Float">\u{1F5D7}</button>`}
<button class="map-ctrl-btn" id="map-minimize" title="Minimize">\u2212</button>
</div>
</div>
<div class="map-body" id="map-host">
<div class="map-overlay-label" id="map-spatial-label">${spatialLabel}</div>
</div>
</div>`;
}
// ── Calendar Content Router ──
private renderCalendarContent(): string {
switch (this.viewMode) {
case "day": return this.viewVariant === 1 ? this.renderDayHorizontal() : this.renderDay();
case "week": return this.renderWeek();
case "month": return this.viewVariant === 1 ? this.renderMonthTransposed() : this.renderMonth();
case "season": return this.renderSeason();
case "year": return this.viewVariant === 1 ? this.renderYearVertical() : this.renderYear();
case "multi-year": return this.renderMultiYear();
default: return this.renderMonth();
}
}
// ── 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}" data-drop-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, 5).map(e => `<span class="dot" style="background:${e.source_color || "#6366f1"}"></span>`).join("")}
${dayEvents.length > 5 ? `<span style="font-size:8px;color:var(--rs-text-muted)">+${dayEvents.length - 5}</span>` : ""}
</div>
${dayEvents.slice(0, 2).map(e => {
const evColor = e.source_color || "#6366f1";
const city = this.getSpatialLabel(e.location_breadcrumb, 5);
const cityHtml = city ? `<span class="ev-loc">${this.esc(city)}</span>` : "";
const virtualHtml = e.is_virtual ? `<span class="ev-virtual" title="Virtual">\u{1F4BB}</span>` : "";
return `<div class="ev-label" style="border-left:2px solid ${evColor};background:${evColor}10" data-event-id="${e.id}">${e.rToolSource === "rSchedule" ? '<span class="ev-bell">&#128276;</span>' : ""}${virtualHtml}<span class="ev-time">${this.formatTime(e.start_time)}</span>${this.esc(e.title)}${cityHtml}</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];
const monthsHtml = months.map(m => {
const dim = new Date(year, m + 1, 0).getDate();
const monthEvents: any[] = [];
for (let d = 1; d <= dim; d++) {
const ds = `${year}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
monthEvents.push(...this.getEventsForDate(ds));
}
const cities = this.getUniqueSpatialLabels(monthEvents, 5, 4);
const citiesHtml = cities.length > 0
? `<div class="season-cities">${cities.map(c => `<span class="season-city-chip">${this.esc(c)}</span>`).join("")}</div>`
: "";
return `<div class="season-month-wrap">${this.renderMiniMonth(year, m)}${citiesHtml}</div>`;
}).join("");
return `
<div class="season-header">${seasonName} ${year} <span class="season-q">Q${quarter + 1}</span></div>
<div class="season-grid">
${monthsHtml}
</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>`;
}
// ── Day Horizontal View (variant 1) ──
private renderDayHorizontal(): 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_WIDTH = 80;
const START_HOUR = 6;
const END_HOUR = 23;
const totalWidth = (END_HOUR - START_HOUR + 1) * HOUR_WIDTH;
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 ? "12a" : h < 12 ? `${h}a` : h === 12 ? "12p" : `${h - 12}p`;
hoursHtml += `<div class="dh-hour" style="left:${(h - START_HOUR) * HOUR_WIDTH}px;width:${HOUR_WIDTH}px">${label}</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 leftPx = ((startMin - START_HOUR * 60) / 60) * HOUR_WIDTH;
const widthPx = Math.max((duration / 60) * HOUR_WIDTH, 60);
const bgColor = ev.source_color ? `${ev.source_color}24` : "#6366f124";
const virtualBadge = ev.is_virtual ? `<span class="dh-virtual" title="Virtual">\u{1F4BB}</span>` : "";
eventsHtml += `<div class="dh-event" data-event-id="${ev.id}" style="
left:${leftPx}px;width:${widthPx}px;background:${bgColor};border-top:3px solid ${ev.source_color || "#6366f1"}">
<div class="tl-event-title">${virtualBadge}${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="dh-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_WIDTH;
if (nowPx >= 0 && nowPx <= totalWidth) {
nowHtml = `<div class="dh-now" style="left:${nowPx}px"></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" : ""} \u00B7 Horizontal
</div>
<div class="dh-container" style="width:${totalWidth}px">
<div class="dh-hours">${hoursHtml}</div>
<div class="dh-events">${eventsHtml}${nowHtml}</div>
</div>
</div>`;
}
// ── Month Transposed View (variant 1) ──
private renderMonthTransposed(): string {
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const today = new Date();
const todayStr = this.dateStr(today);
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
// Group days by day-of-week, tracking which week they fall in
const firstDate = new Date(year, month, 1);
const firstDow = firstDate.getDay();
const numWeeks = Math.ceil((firstDow + daysInMonth) / 7);
// Build header: week numbers
let headerHtml = `<div class="mt-day-name"></div>`;
for (let w = 0; w < numWeeks; w++) {
// Get the Monday of this week for ISO week number
const weekStart = new Date(year, month, 1 - firstDow + w * 7);
const onejan = new Date(weekStart.getFullYear(), 0, 1);
const weekNum = Math.ceil(((weekStart.getTime() - onejan.getTime()) / 86400000 + onejan.getDay() + 1) / 7);
headerHtml += `<div class="mt-week-header">W${weekNum}</div>`;
}
// Build rows: one per day-of-week
let rowsHtml = "";
for (let dow = 0; dow < 7; dow++) {
rowsHtml += `<div class="mt-row"><div class="mt-day-name">${dayNames[dow]}</div>`;
for (let w = 0; w < numWeeks; w++) {
const dayOfMonth = 1 - firstDow + w * 7 + dow;
if (dayOfMonth < 1 || dayOfMonth > daysInMonth) {
rowsHtml += `<div class="mt-cell empty"></div>`;
continue;
}
const ds = `${year}-${String(month + 1).padStart(2, "0")}-${String(dayOfMonth).padStart(2, "0")}`;
const isToday = ds === todayStr;
const dayEvents = this.getEventsForDate(ds);
const isWeekend = dow === 0 || dow === 6;
const cities = this.getUniqueSpatialLabels(dayEvents, 5, 2);
const mtTooltip = dayEvents.length > 0 ? `${dayEvents.length} event${dayEvents.length > 1 ? 's' : ''}${cities.length ? ' \u00B7 ' + cities.join(', ') : ''}` : '';
// Stacked color bar
let mtBarHtml = "";
if (dayEvents.length > 0) {
const mtColors: Record<string, number> = {};
for (const e of dayEvents) { const c = e.source_color || "#6366f1"; mtColors[c] = (mtColors[c] || 0) + 1; }
const segs = Object.entries(mtColors).map(([c, n]) => `<span class="mt-seg" style="background:${c};flex:${n}"></span>`).join("");
mtBarHtml = `<span class="mt-color-bar">${segs}</span>`;
}
rowsHtml += `<div class="mt-cell ${isToday ? "today" : ""} ${isWeekend ? "weekend" : ""}" data-date="${ds}" title="${mtTooltip}">
<span class="mt-num">${dayOfMonth}</span>
${mtBarHtml}
${dayEvents.length > 0 ? `<span class="mt-count-num">${dayEvents.length}</span>` : ""}
</div>`;
}
rowsHtml += `</div>`;
}
return `
<div class="month-transposed">
<div class="mt-header-row">${headerHtml}</div>
${rowsHtml}
</div>`;
}
// ── Year Vertical View (variant 1 — kalnext style) ──
private renderYearVertical(): string {
const year = this.currentDate.getFullYear();
const today = new Date();
const todayStr = this.dateStr(today);
let html = `<div class="year-vertical">`;
for (let m = 0; m < 12; m++) {
const monthName = new Date(year, m, 1).toLocaleDateString("default", { month: "short" });
const dim = new Date(year, m + 1, 0).getDate();
const monthEvents: any[] = [];
let daysHtml = "";
for (let d = 1; d <= dim; d++) {
const ds = `${year}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const isToday = ds === todayStr;
const dayEvents = this.getEventsForDate(ds);
monthEvents.push(...dayEvents);
const dow = new Date(year, m, d).getDay();
const isWeekend = dow === 0 || dow === 6;
// Multi-dot support (up to 3)
let dotsHtml = "";
if (dayEvents.length > 0) {
const colors = dayEvents.slice(0, 3).map(e => e.source_color || "#6366f1");
dotsHtml = `<span class="yv-dots">${colors.map(c => `<span class="yv-dot" style="background:${c}"></span>`).join("")}</span>`;
}
daysHtml += `<div class="yv-day ${isToday ? "today" : ""} ${isWeekend ? "weekend" : ""}" data-mini-date="${ds}" title="${d} ${monthName}${dayEvents.length ? ` (${dayEvents.length} events)` : ""}">
${d}
${dotsHtml}
</div>`;
}
const countries = this.getUniqueSpatialLabels(monthEvents, 3, 2);
const countryHtml = countries.length > 0 ? `<span class="yv-country">${countries.join(", ")}</span>` : "";
html += `<div class="yv-month" data-mini-month="${m}">
<div class="yv-label">${monthName}${countryHtml}</div>
<div class="yv-days">${daysHtml}</div>
</div>`;
}
html += `</div>`;
return html;
}
// ── Multi-Year View (3x3 grid) ──
private renderMultiYear(): string {
const centerYear = this.currentDate.getFullYear();
const startYear = centerYear - 4;
let html = `<div class="multi-year-grid">`;
for (let i = 0; i < 9; i++) {
const y = startYear + i;
const isCurrent = y === new Date().getFullYear();
let monthsHtml = "";
for (let m = 0; m < 12; m++) {
monthsHtml += this.renderMicroMonth(y, m);
}
html += `<div class="my-year ${isCurrent ? "current" : ""}" data-my-year="${y}">
<div class="my-year-label">${y}</div>
<div class="my-months">${monthsHtml}</div>
</div>`;
}
html += `</div>`;
return html;
}
private renderMicroMonth(year: number, month: number): string {
const monthInitials = ["J","F","M","A","M","J","J","A","S","O","N","D"];
const dim = new Date(year, month + 1, 0).getDate();
const allEvents: any[] = [];
for (let d = 1; d <= dim; d++) {
const ds = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
allEvents.push(...this.getEventsForDate(ds));
}
const eventCount = allEvents.length;
const maxBar = 8;
const barWidth = Math.min(eventCount, maxBar);
const barPct = (barWidth / maxBar) * 100;
// Group by source_color for stacked segments
const colorCounts: Record<string, number> = {};
for (const e of allEvents) {
const c = e.source_color || "#6366f1";
colorCounts[c] = (colorCounts[c] || 0) + 1;
}
const countries = this.getUniqueSpatialLabels(allEvents, 3, 3);
const tooltipExtra = countries.length > 0 ? ` \u00B7 ${countries.join(", ")}` : "";
let barHtml = "";
if (eventCount > 0) {
const segments = Object.entries(colorCounts).map(([color, count]) => {
const segPct = (count / eventCount) * 100;
return `<span class="micro-seg" style="background:${color};width:${segPct}%"></span>`;
}).join("");
barHtml = `<span class="micro-bar-stack" style="width:${barPct}%">${segments}</span>`;
}
return `<div class="micro-month" data-micro-click="${year}-${month}" title="${new Date(year, month, 1).toLocaleDateString("default", { month: "long", year: "numeric" })}${eventCount ? ` (${eventCount} events)${tooltipExtra}` : ""}">
<span class="micro-label">${monthInitials[month]}</span>
${barHtml}
${eventCount > 0 ? `<span class="micro-count">${eventCount}</span>` : ""}
</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;
const isBusy = dayEvents.length >= 3;
const titleParts = dayEvents.slice(0, 3).map(e => e.title);
const tooltipText = hasEvents ? `${dayEvents.length} event${dayEvents.length > 1 ? 's' : ''}: ${titleParts.join(', ')}` : '';
const busyStyle = isBusy ? `background:rgba(99,102,241,0.08);` : '';
// Up to 3 stacked dots with individual colors
let dotsHtml = "";
if (hasEvents) {
const uniqueColors = dayEvents.slice(0, 3).map(e => e.source_color || '#6366f1');
dotsHtml = `<span class="mini-dots">${uniqueColors.map(c => `<span class="mini-dot" style="background:${c}"></span>`).join("")}</span>`;
}
daysHtml += `<div class="mini-day ${isToday ? "today" : ""}" data-mini-date="${ds}"
style="${busyStyle}" title="${tooltipText}">${d}${dotsHtml}</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}20` : "#6366f120";
const showDesc = heightPx >= 60 && ev.description;
const descPreview = showDesc ? (ev.description.length > 60 ? ev.description.slice(0, 57) + "..." : ev.description) : "";
const neighborhood = this.getSpatialLabel(ev.location_breadcrumb, 6);
const virtualBadge = ev.is_virtual ? `<span class="tl-virtual" title="${this.esc(ev.virtual_platform || 'Virtual')}">\u{1F4BB} ${this.esc(ev.virtual_platform || 'Virtual')}</span>` : "";
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)}${virtualBadge}</div>
${ev.location_name ? `<div class="tl-event-loc">${this.esc(ev.location_name)}</div>` : ""}
${neighborhood ? `<span class="tl-breadcrumb">${this.esc(neighborhood)}</span>` : ""}
${showDesc ? `<div class="tl-event-desc">${this.esc(descPreview)}</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}28` : "#6366f128";
const colLeft = `calc(44px + ${i} * ((100% - 44px) / 7) + 2px)`;
const colWidth = `calc((100% - 44px) / 7 - 4px)`;
const showMeta = heightPx >= 36;
const timeStr = `${this.formatTime(ev.start_time)}\u2013${this.formatTime(ev.end_time)}`;
const locName = ev.location_name ? this.esc(ev.location_name) : "";
const virtualBadge = ev.is_virtual ? `<span class="wk-virtual" title="Virtual">\u{1F4BB}</span>` : "";
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">${virtualBadge}${this.esc(ev.title)}</div>
${showMeta ? `<div class="week-event-meta"><span class="week-event-time">${timeStr}</span>${locName ? `<span class="week-event-loc">${locName}</span>` : ""}</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 => {
const ddDesc = e.description ? (e.description.length > 80 ? e.description.slice(0, 77) + "..." : e.description) : "";
const srcTag = e.source_name ? `<span class="dd-source" style="border-color:${e.source_color || '#666'};color:${e.source_color || '#aaa'}">${this.esc(e.source_name)}</span>` : "";
return `<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)}${srcTag}</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>
${ddDesc ? `<div class="dd-desc">${this.esc(ddDesc)}</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:var(--rs-text-muted)">${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:var(--rs-primary-hover)">Join</a>` : ""}</div>` : ""}
${e.latitude != null ? `<div class="modal-field" style="font-size:11px;color:var(--rs-text-muted)">\u{1F4CD} ${e.latitude.toFixed(4)}, ${e.longitude.toFixed(4)}</div>` : ""}
</div>
</div>`;
}
// ── Reminder Drop Handler ──
private getScheduleApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)/);
return match ? `${match[1]}/rschedule` : "/rschedule";
}
private async handleReminderDrop(e: DragEvent, dropDate: string) {
let title = "";
let sourceModule: string | null = null;
let sourceEntityId: string | null = null;
let sourceLabel: string | null = null;
let sourceColor: string | null = null;
// Try cross-module format first
const rspaceData = e.dataTransfer?.getData("application/rspace-item");
if (rspaceData) {
try {
const parsed = JSON.parse(rspaceData);
title = parsed.title || "";
sourceModule = parsed.module || null;
sourceEntityId = parsed.entityId || null;
sourceLabel = parsed.label || null;
sourceColor = parsed.color || null;
} catch { /* fall through to text/plain */ }
}
// Fall back to plain text
if (!title) {
title = e.dataTransfer?.getData("text/plain") || "";
}
if (!title.trim()) return;
// Prompt for quick confirmation
const confirmed = confirm(`Create reminder "${title}" on ${dropDate}?`);
if (!confirmed) return;
const remindAt = new Date(dropDate + "T09:00:00").getTime();
const base = this.getScheduleApiBase();
try {
await fetch(`${base}/api/reminders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title.trim(),
remindAt,
allDay: true,
syncToCalendar: true,
sourceModule,
sourceEntityId,
sourceLabel,
sourceColor,
}),
});
// Reload events to show the new calendar entry
if (this.space === "demo") { this.loadDemoData(); } else { this.loadMonth(); }
} catch (err) {
console.error("[rCal] Failed to create reminder:", err);
}
}
// ── 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 spectrum slider — click on track or tick labels
const zoomTrack = $("zoom-track");
if (zoomTrack) {
const pctToGranularity = (pct: number) => Math.round(pct * 5 / 100) + 2;
const trackClick = (e: MouseEvent) => {
const rect = zoomTrack.getBoundingClientRect();
const pct = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
this.setTemporalGranularity(pctToGranularity(pct));
};
zoomTrack.addEventListener("click", trackClick);
// Drag on thumb
const thumb = $("zoom-thumb");
if (thumb) {
const startDrag = (eDown: MouseEvent) => {
eDown.preventDefault();
eDown.stopPropagation();
const onMove = (eMove: MouseEvent) => {
const rect = zoomTrack.getBoundingClientRect();
const pct = Math.max(0, Math.min(100, ((eMove.clientX - rect.left) / rect.width) * 100));
(thumb as HTMLElement).style.left = `${pct}%`;
};
const onUp = (eUp: MouseEvent) => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
const rect = zoomTrack.getBoundingClientRect();
const pct = Math.max(0, Math.min(100, ((eUp.clientX - rect.left) / rect.width) * 100));
this.setTemporalGranularity(pctToGranularity(pct));
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
thumb.addEventListener("mousedown", startDrag);
// Touch support
thumb.addEventListener("touchstart", (e: Event) => {
const te = e as TouchEvent;
te.preventDefault();
const onTouchMove = (em: Event) => {
const tm = em as TouchEvent;
const rect = zoomTrack.getBoundingClientRect();
const pct = Math.max(0, Math.min(100, ((tm.touches[0].clientX - rect.left) / rect.width) * 100));
(thumb as HTMLElement).style.left = `${pct}%`;
};
const onTouchEnd = (eu: Event) => {
document.removeEventListener("touchmove", onTouchMove);
document.removeEventListener("touchend", onTouchEnd);
const tu = eu as TouchEvent;
const rect = zoomTrack.getBoundingClientRect();
const touch = tu.changedTouches[0];
const pct = Math.max(0, Math.min(100, ((touch.clientX - rect.left) / rect.width) * 100));
this.setTemporalGranularity(pctToGranularity(pct));
};
document.addEventListener("touchmove", onTouchMove);
document.addEventListener("touchend", onTouchEnd);
}, { passive: false });
}
}
$$("[data-zoom]").forEach(el => {
el.addEventListener("click", (e) => {
e.stopPropagation();
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();
});
});
// Month view: drop-on-date for reminders
$$("[data-drop-date]").forEach(el => {
el.addEventListener("dragover", (e) => {
e.preventDefault();
(el as HTMLElement).classList.add("drop-target");
});
el.addEventListener("dragleave", () => {
(el as HTMLElement).classList.remove("drop-target");
});
el.addEventListener("drop", (e) => {
e.preventDefault();
e.stopPropagation();
(el as HTMLElement).classList.remove("drop-target");
const dropDate = (el as HTMLElement).dataset.dropDate;
if (!dropDate) return;
this.handleReminderDrop(e as DragEvent, dropDate);
});
});
// 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(); });
// Multi-year: click year tile → zoom to year
$$("[data-my-year]").forEach(el => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const y = parseInt((el as HTMLElement).dataset.myYear!);
this.currentDate = new Date(y, this.currentDate.getMonth(), 1);
this.setTemporalGranularity(6);
});
});
// Multi-year: click micro-month → zoom to month
$$("[data-micro-click]").forEach(el => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const parts = (el as HTMLElement).dataset.microClick!.split("-");
this.currentDate = new Date(parseInt(parts[0]), parseInt(parts[1]), 1);
this.setTemporalGranularity(4);
});
});
// Transposed month cell click → zoom to day
$$(".mt-cell:not(.empty)").forEach(el => {
el.addEventListener("click", () => {
const date = (el as HTMLElement).dataset.date;
if (!date) return;
const parts = date.split("-");
this.currentDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
this.setTemporalGranularity(2);
});
});
// 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 <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: var(--rs-text-primary); padding: 0.5rem; }
* { box-sizing: border-box; }
.error { color: var(--rs-error); 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 var(--rs-border); background: transparent; color: var(--rs-text-secondary); cursor: pointer; font-size: 14px; -webkit-tap-highlight-color: transparent; }
.nav-btn:hover { color: var(--rs-text-primary); border-color: var(--rs-border-strong); }
.nav-btn.active { border-color: var(--rs-primary-hover); color: var(--rs-primary-hover); }
.nav-title { font-size: 15px; font-weight: 600; flex: 1; text-align: center; color: var(--rs-text-primary); }
.nav-primary { padding: 6px 14px; border-radius: 6px; border: none; background: var(--rs-primary); color: #fff; font-weight: 600; cursor: pointer; font-size: 12px; }
/* ── Bottom Bar ── */
.bottom-bar { margin-top: 12px; padding-top: 8px; border-top: 1px solid var(--rs-border-subtle); display: flex; flex-direction: column; gap: 6px; }
.bottom-bar__lunar-toggle { align-self: flex-start; padding: 4px 10px; border-radius: 12px; border: 1px solid var(--rs-border); background: transparent; color: var(--rs-text-muted); cursor: pointer; font-size: 14px; transition: all 0.15s; }
.bottom-bar__lunar-toggle:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); }
.bottom-bar__lunar-toggle.active { border-color: var(--rs-primary-hover); color: var(--rs-primary-hover); }
/* ── Lunar Overlay (bottom) ── */
.lunar-overlay { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 8px; padding: 0; 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: var(--rs-bg-hover); }
.lunar-summary-phase { font-size: 13px; font-weight: 600; color: var(--rs-text-primary); text-transform: capitalize; white-space: nowrap; }
.lunar-summary-stats { font-size: 11px; color: var(--rs-text-secondary); white-space: nowrap; }
.lunar-summary-chevron { margin-left: auto; font-size: 10px; color: var(--rs-text-muted); }
.lunar-expanded { border-top: 1px solid var(--rs-border); 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 var(--rs-border-strong); font-size: 11px; color: var(--rs-text-secondary); white-space: nowrap; flex-shrink: 0; transition: all 0.15s; }
.phase-chip.current { border-color: var(--rs-primary-hover); color: #818cf8; background: var(--rs-bg-active); }
.phase-chip.past { opacity: 0.4; }
.phase-chip-label { text-transform: capitalize; }
/* ── Zoom Spectrum Bar ── */
.zoom-bar { padding: 6px 0; }
.zoom-bar__row { display: flex; align-items: center; gap: 8px; }
.zoom-bar__label-end { font-size: 10px; color: var(--rs-text-muted); font-weight: 500; white-space: nowrap; user-select: none; flex-shrink: 0; }
.zoom-bar__track { position: relative; flex: 1; height: 28px; cursor: pointer; min-width: 180px; }
.zoom-bar__gradient { position: absolute; top: 10px; left: 0; right: 0; height: 8px; border-radius: 4px; background: linear-gradient(to right, #818cf8, #6366f1, #4f46e5, #4338ca, #3730a3, #312e81); pointer-events: none; }
.zoom-bar__thumb { position: absolute; top: 4px; width: 20px; height: 20px; border-radius: 50%; background: #818cf8; border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.3); transform: translateX(-50%); cursor: grab; transition: left 0.15s ease; z-index: 2; }
.zoom-bar__thumb:active { cursor: grabbing; transform: translateX(-50%) scale(1.15); transition: left 0s, transform 0.1s; }
.zoom-bar__tick { position: absolute; top: 0; transform: translateX(-50%); text-align: center; pointer-events: auto; cursor: pointer; z-index: 1; }
.zoom-bar__tick-mark { width: 2px; height: 28px; margin: 0 auto; background: var(--rs-border, rgba(255,255,255,0.15)); opacity: 0.4; border-radius: 1px; transition: opacity 0.15s; }
.zoom-bar__tick-mark.active { opacity: 0.8; }
.zoom-bar__tick-label { font-size: 9px; color: var(--rs-text-muted); margin-top: 2px; transition: color 0.15s; white-space: nowrap; }
.zoom-bar__tick-label.active { color: #818cf8; font-weight: 600; }
.coupling-btn { padding: 4px 10px; border-radius: 12px; border: 1px solid var(--rs-border-strong); background: transparent; color: var(--rs-text-muted); cursor: pointer; font-size: 11px; transition: all 0.15s; white-space: nowrap; flex-shrink: 0; }
.coupling-btn:hover { border-color: var(--rs-border-strong); color: var(--rs-text-secondary); }
.coupling-btn.coupled { border-color: var(--rs-primary-hover); color: #818cf8; background: var(--rs-bg-active); }
/* ── 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 var(--rs-border-strong); 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: var(--rs-bg-surface-sunken); border: 1px solid var(--rs-border-strong); 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: var(--rs-shadow-lg); }
.map-panel--docked { min-height: 400px; }
.map-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; background: var(--rs-bg-surface); border-bottom: 1px solid var(--rs-border); cursor: default; flex-shrink: 0; }
.map-panel-title { font-size: 12px; font-weight: 500; color: var(--rs-text-secondary); }
.map-panel-controls { display: flex; gap: 4px; }
.map-ctrl-btn { width: 24px; height: 24px; border-radius: 4px; border: 1px solid var(--rs-border-strong); background: transparent; color: var(--rs-text-secondary); cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; }
.map-ctrl-btn:hover { border-color: var(--rs-primary-hover); color: var(--rs-text-primary); }
.map-body { flex: 1; position: relative; min-height: 200px; }
.map-overlay-label { position: absolute; top: 8px; right: 8px; z-index: 500; background: var(--rs-bg-overlay); border: 1px solid var(--rs-border-strong); border-radius: 6px; padding: 4px 10px; font-size: 11px; color: var(--rs-text-secondary); pointer-events: none; }
.map-fab { position: absolute; bottom: 16px; right: 16px; width: 44px; height: 44px; border-radius: 50%; border: 1px solid var(--rs-border-strong); background: var(--rs-bg-surface); color: var(--rs-text-primary); cursor: pointer; font-size: 20px; display: flex; align-items: center; justify-content: center; z-index: 100; box-shadow: var(--rs-shadow-md); transition: all 0.15s; }
.map-fab:hover { border-color: var(--rs-primary-hover); background: var(--rs-bg-surface-raised); 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: var(--rs-text-muted); margin-bottom: 6px; }
.synodic-bar { height: 14px; background: var(--rs-border); border-radius: 7px; overflow: visible; position: relative; }
.synodic-fill { height: 100%; background: linear-gradient(to right, var(--rs-bg-surface), var(--rs-text-primary), var(--rs-bg-surface)); 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: var(--rs-text-muted); padding: 4px; font-weight: 600; }
.grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.day { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 6px; min-height: 80px; padding: 6px; cursor: pointer; position: relative; -webkit-tap-highlight-color: transparent; }
.day:hover { border-color: var(--rs-border-strong); }
.day.today { border-color: var(--rs-primary-hover); background: var(--rs-bg-active); }
.day.expanded { border-color: var(--rs-primary-hover); 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: var(--rs-text-secondary); line-height: 1.4; padding: 1px 3px; border-radius: 3px; cursor: pointer; }
.ev-label:hover { background: var(--rs-bg-hover); }
.ev-time { color: var(--rs-text-muted); font-size: 8px; margin-right: 2px; }
.ev-bell { margin-right: 2px; font-size: 8px; }
.ev-loc { color: var(--rs-text-muted); font-size: 7px; margin-left: 3px; }
.ev-virtual { font-size: 8px; margin-right: 2px; vertical-align: middle; }
/* ── Drop Target ── */
.day.drop-target { background: rgba(245,158,11,0.15); border: 2px dashed var(--rs-warning); }
/* ── Day Detail Panel ── */
.day-detail { grid-column: 1 / -1; background: var(--rs-bg-surface); border: 1px solid var(--rs-bg-surface-raised); 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: var(--rs-text-primary); }
.dd-close { background: none; border: none; color: var(--rs-text-muted); 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: var(--rs-bg-hover); }
.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: var(--rs-text-primary); }
.dd-meta { font-size: 11px; color: var(--rs-text-secondary); margin-top: 2px; }
.dd-empty { font-size: 12px; color: var(--rs-text-muted); padding: 8px 0; }
.dd-desc { font-size: 11px; color: var(--rs-text-muted); margin-top: 3px; line-height: 1.4; }
.dd-source { font-size: 9px; padding: 1px 6px; border-radius: 8px; border: 1px solid; margin-left: 6px; vertical-align: middle; }
/* ── 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: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); 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: var(--rs-text-secondary); margin-bottom: 6px; }
.modal-close { float: right; background: none; border: none; color: var(--rs-text-muted); font-size: 18px; cursor: pointer; }
/* ── Day View ── */
.day-view { position: relative; }
.day-view-header { font-size: 13px; color: var(--rs-text-secondary); margin-bottom: 8px; font-weight: 500; }
.day-allday { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 8px; padding: 8px; margin-bottom: 8px; }
.day-allday-label { font-size: 10px; color: var(--rs-text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
.timeline { position: relative; border-left: 1px solid var(--rs-border); margin-left: 44px; }
.hour-row { display: flex; min-height: 48px; border-bottom: 1px solid var(--rs-border-subtle); position: relative; }
.hour-label { position: absolute; left: -48px; top: -7px; width: 40px; text-align: right; font-size: 10px; color: var(--rs-text-muted); 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: var(--rs-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tl-event-time { font-size: 10px; color: var(--rs-text-secondary); }
.tl-event-loc { font-size: 10px; color: var(--rs-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tl-event-desc { font-size: 9px; color: var(--rs-text-muted); margin-top: 2px; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tl-breadcrumb { font-size: 8px; color: var(--rs-text-muted); background: var(--rs-bg-hover); padding: 1px 5px; border-radius: 3px; margin-top: 2px; display: inline-block; }
.tl-virtual { font-size: 9px; color: #818cf8; margin-left: 6px; }
.now-line { position: absolute; left: 0; right: 0; height: 2px; background: var(--rs-error); z-index: 5; }
.now-dot { position: absolute; left: -5px; top: -3px; width: 8px; height: 8px; border-radius: 50%; background: var(--rs-error); }
/* ── 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: var(--rs-text-muted); font-weight: 600; border-bottom: 1px solid var(--rs-border); cursor: pointer; }
.week-day-header:hover { color: var(--rs-text-primary); }
.week-day-header.today { color: var(--rs-primary-hover); border-bottom-color: var(--rs-primary-hover); }
.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: var(--rs-text-muted); text-align: right; padding-right: 6px; font-variant-numeric: tabular-nums; height: 48px; }
.week-cell { border-left: 1px solid var(--rs-border-subtle); border-bottom: 1px solid var(--rs-border-subtle); 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: var(--rs-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.week-event-meta { font-size: 9px; color: var(--rs-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.week-event-time { color: var(--rs-text-muted); }
.week-event-loc { margin-left: 3px; color: var(--rs-text-muted); }
.wk-virtual { font-size: 9px; margin-right: 2px; vertical-align: middle; }
/* ── Season View ── */
.season-header { text-align: center; margin-bottom: 12px; font-size: 16px; font-weight: 600; color: var(--rs-text-primary); }
.season-q { font-size: 12px; color: var(--rs-text-muted); font-weight: 400; margin-left: 4px; }
.season-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.season-month-wrap { display: flex; flex-direction: column; gap: 4px; }
.season-cities { display: flex; flex-wrap: wrap; gap: 3px; padding: 2px 4px; }
.season-city-chip { font-size: 9px; color: var(--rs-text-secondary); background: var(--rs-bg-hover); border: 1px solid var(--rs-border-strong); border-radius: 8px; padding: 1px 6px; white-space: nowrap; }
/* ── Year View ── */
.year-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
/* ── Mini-Month (shared) ── */
.mini-month { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 8px; padding: 8px; cursor: pointer; transition: border-color 0.15s; }
.mini-month:hover { border-color: var(--rs-border-strong); }
.mini-month.current { border-color: var(--rs-primary-hover); background: var(--rs-bg-active); }
.mini-month-title { font-size: 12px; font-weight: 600; text-align: center; color: var(--rs-text-primary); 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: var(--rs-text-muted); 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: var(--rs-text-secondary); border-radius: 3px; aspect-ratio: 1; cursor: pointer; }
.mini-day:hover { background: var(--rs-bg-hover); }
.mini-day.today { background: var(--rs-primary); color: #fff; font-weight: 700; }
.mini-day.empty { cursor: default; }
.mini-dots { position: absolute; bottom: 1px; left: 50%; transform: translateX(-50%); display: flex; gap: 1px; }
.mini-dot { width: 3px; height: 3px; border-radius: 50%; flex-shrink: 0; }
.mini-hint { text-align: center; font-size: 11px; color: var(--rs-text-muted); margin-top: 8px; }
/* ── Transition Animations ── */
.calendar-pane { position: relative; overflow: hidden; }
.transition-ghost {
position: absolute; inset: 0; z-index: 10; pointer-events: none;
will-change: transform, opacity;
}
.transition-enter {
will-change: transform, opacity;
}
/* Ghost exits */
.ghost-slide-left { animation: ghostSlideLeft 280ms ease-out forwards; }
.ghost-slide-right { animation: ghostSlideRight 280ms ease-out forwards; }
.ghost-zoom-in { animation: ghostZoomIn 320ms ease-in-out forwards; }
.ghost-zoom-out { animation: ghostZoomOut 320ms ease-in-out forwards; }
/* New content enters */
.enter-slide-left { animation: enterSlideLeft 280ms ease-out forwards; }
.enter-slide-right { animation: enterSlideRight 280ms ease-out forwards; }
.enter-zoom-in { animation: enterZoomIn 320ms ease-in-out forwards; }
.enter-zoom-out { animation: enterZoomOut 320ms ease-in-out forwards; }
@keyframes ghostSlideLeft { from { transform: translateX(0); opacity: 1; } to { transform: translateX(-100%); opacity: 0; } }
@keyframes ghostSlideRight { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } }
@keyframes ghostZoomIn { from { transform: scale(1); opacity: 1; } to { transform: scale(1.3); opacity: 0; } }
@keyframes ghostZoomOut { from { transform: scale(1); opacity: 1; } to { transform: scale(0.7); opacity: 0; } }
@keyframes enterSlideLeft { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes enterSlideRight { from { transform: translateX(-100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes enterZoomIn { from { transform: scale(0.7); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@keyframes enterZoomOut { from { transform: scale(1.3); opacity: 0; } to { transform: scale(1); opacity: 1; } }
/* ── Variant Indicator ── */
.variant-indicator { display: flex; gap: 4px; align-items: center; padding: 0 6px; }
.variant-dot { width: 6px; height: 6px; border-radius: 50%; border: 1px solid var(--rs-border-strong); background: transparent; transition: all 0.15s; }
.variant-dot.active { background: var(--rs-primary-hover); border-color: var(--rs-primary-hover); }
/* ── Horizontal Day View ── */
.dh-container { overflow-x: auto; overflow-y: hidden; position: relative; min-height: 180px; padding: 0 0 8px; }
.dh-hours { position: relative; height: 24px; border-bottom: 1px solid var(--rs-border); }
.dh-hour { position: absolute; top: 0; height: 24px; font-size: 10px; color: var(--rs-text-muted); text-align: center; border-left: 1px solid var(--rs-border-subtle); line-height: 24px; }
.dh-events { position: relative; min-height: 140px; padding-top: 8px; }
.dh-event { position: absolute; top: 32px; height: auto; min-height: 48px; border-radius: 6px; padding: 4px 8px; font-size: 11px; overflow: hidden; cursor: pointer; border-top: 3px solid; z-index: 1; }
.dh-event:hover { opacity: 0.85; }
.dh-now { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--rs-error); z-index: 5; }
.dh-now::before { content: ""; position: absolute; top: -3px; left: -3px; width: 8px; height: 8px; border-radius: 50%; background: var(--rs-error); }
.dh-event-loc { font-size: 9px; color: var(--rs-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dh-virtual { font-size: 9px; margin-right: 2px; vertical-align: middle; }
/* ── Month Transposed View ── */
.month-transposed { overflow-x: auto; }
.mt-header-row { display: flex; gap: 2px; margin-bottom: 4px; }
.mt-week-header { flex: 1; min-width: 48px; text-align: center; font-size: 10px; color: var(--rs-text-muted); font-weight: 600; padding: 4px; }
.mt-row { display: flex; gap: 2px; margin-bottom: 2px; }
.mt-day-name { width: 40px; flex-shrink: 0; font-size: 11px; color: var(--rs-text-muted); font-weight: 600; display: flex; align-items: center; justify-content: flex-end; padding-right: 6px; }
.mt-cell { flex: 1; min-width: 48px; min-height: 36px; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 4px; display: flex; align-items: center; justify-content: center; gap: 4px; cursor: pointer; position: relative; }
.mt-cell:hover { border-color: var(--rs-border-strong); }
.mt-cell.today { border-color: var(--rs-primary-hover); background: var(--rs-bg-active); }
.mt-cell.weekend { background: var(--rs-bg-hover); }
.mt-cell.empty { background: transparent; border-color: transparent; cursor: default; }
.mt-num { font-size: 12px; color: var(--rs-text-secondary); font-weight: 500; }
.mt-color-bar { display: flex; height: 3px; border-radius: 2px; overflow: hidden; width: 100%; position: absolute; bottom: 2px; left: 0; }
.mt-seg { height: 100%; min-width: 2px; }
.mt-count-num { font-size: 9px; color: var(--rs-text-secondary); font-weight: 600; }
/* ── Year Vertical View ── */
.year-vertical { max-height: 600px; overflow-y: auto; }
.yv-month { display: flex; align-items: flex-start; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--rs-border-subtle); cursor: pointer; }
.yv-month:hover { background: var(--rs-bg-hover); }
.yv-label { width: 36px; flex-shrink: 0; font-size: 11px; font-weight: 600; color: var(--rs-text-secondary); text-align: right; padding-top: 2px; }
.yv-days { display: flex; flex-wrap: wrap; gap: 2px; flex: 1; }
.yv-day { position: relative; width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; font-size: 9px; color: var(--rs-text-muted); border-radius: 3px; cursor: pointer; }
.yv-day:hover { background: var(--rs-bg-hover); color: var(--rs-text-primary); }
.yv-day.today { background: var(--rs-primary); color: #fff; font-weight: 700; }
.yv-day.weekend { opacity: 0.5; }
.yv-dots { position: absolute; bottom: 1px; left: 50%; transform: translateX(-50%); display: flex; gap: 1px; }
.yv-dot { width: 3px; height: 3px; border-radius: 50%; flex-shrink: 0; }
.yv-country { font-size: 9px; color: var(--rs-text-muted); margin-left: 6px; font-weight: 400; }
/* ── Multi-Year View ── */
.multi-year-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.my-year { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 8px; padding: 8px; cursor: pointer; transition: border-color 0.15s; }
.my-year:hover { border-color: var(--rs-border-strong); }
.my-year.current { border-color: var(--rs-primary-hover); background: var(--rs-bg-active); }
.my-year-label { font-size: 14px; font-weight: 700; text-align: center; color: var(--rs-text-primary); margin-bottom: 6px; }
.my-months { display: grid; grid-template-columns: repeat(4, 1fr); gap: 2px; }
.micro-month { display: flex; align-items: center; gap: 2px; padding: 2px 3px; border-radius: 3px; cursor: pointer; overflow: hidden; }
.micro-month:hover { background: var(--rs-bg-hover); }
.micro-label { font-size: 8px; color: var(--rs-text-muted); font-weight: 600; width: 8px; flex-shrink: 0; }
.micro-bar-stack { height: 3px; border-radius: 2px; flex-shrink: 0; display: flex; overflow: hidden; }
.micro-seg { height: 100%; min-width: 1px; }
.micro-count { font-size: 7px; color: var(--rs-text-muted); flex-shrink: 0; }
/* ── Keyboard Hint ── */
.kbd-hint { text-align: center; font-size: 10px; color: var(--rs-text-muted); margin-top: 12px; padding-top: 8px; border-top: 1px solid var(--rs-border-subtle); }
.kbd-hint kbd { padding: 1px 4px; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); 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; }
.season-cities, .week-event-meta, .tl-event-desc, .yv-country { display: none; }
.week-view { font-size: 10px; }
.zoom-bar__track { min-width: 120px; }
.zoom-bar__label-end { font-size: 9px; }
.zoom-bar__tick-label { font-size: 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; }
.multi-year-grid { grid-template-columns: repeat(3, 1fr); gap: 6px; }
.mt-cell { min-width: 36px; min-height: 30px; }
.mt-day-name { width: 30px; 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); }
.multi-year-grid { grid-template-columns: repeat(2, 1fr); }
.mini-day { font-size: 8px; }
.mini-month-title { font-size: 10px; }
.ev-loc, .dd-desc { display: none; }
}
`;
}
// ── Escape HTML ──
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
}
customElements.define("folk-calendar-view", FolkCalendarView);