feat(rcal): zoom bar relocation, likelihood feature, rich demo data, remove floating map

- Move zoom bars between calendar and map panes when docked (3-column grid layout)
- Add likelihood/pencil-in feature: tentative events render with dashed borders, lower opacity, percentage badges across all 7 view paths + modal + map markers
- Expand demo data from ~47 to 105 events spanning months -1 to +8 with periodic (yoga, standups), episodic (workshop series, book club), and tentative (25-80% likelihood) events
- Add Community and Health source categories
- Remove floating map option — map is now docked or minimized only
- Decouple zoom bars by default (spatialGranularity=Country, zoomCoupled=false)
- Preserve effective spatial index when decoupling via 'c' key or button
- Extend lunar computation range to month+9

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-10 17:07:16 -07:00
parent b2347ec418
commit 1cd8225680
2 changed files with 173 additions and 55 deletions

View File

@ -126,11 +126,11 @@ class FolkCalendarView extends HTMLElement {
// Spatio-temporal state
private temporalGranularity = 4; // MONTH
private spatialGranularity: number | null = null; // null = auto-coupled
private zoomCoupled = true;
private spatialGranularity: number | null = 3; // Country (independent default)
private zoomCoupled = false;
// Map panel state (replaces old tab system)
private mapPanelState: "minimized" | "floating" | "docked" = "docked";
private mapPanelState: "minimized" | "docked" = "docked";
private currentTileLayer: any = null;
private lunarOverlayExpanded = false;
private _wheelTimer: ReturnType<typeof setTimeout> | null = null;
@ -267,15 +267,21 @@ class FolkCalendarView extends HTMLElement {
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";
// Toggle: docked ↔ minimized
this.mapPanelState = this.mapPanelState === "docked" ? "minimized" : "docked";
this.render();
break;
case "c": case "C":
this.zoomCoupled = !this.zoomCoupled;
if (this.zoomCoupled) { this.spatialGranularity = null; this.syncMapToSpatial(); }
if (!this.zoomCoupled) {
// Coupling: let spatial follow temporal
this.zoomCoupled = true;
this.spatialGranularity = null;
this.syncMapToSpatial();
} else {
// Decoupling: preserve current effective spatial index
this.spatialGranularity = this.getEffectiveSpatialIndex();
this.zoomCoupled = false;
}
this.render();
break;
}
@ -406,6 +412,8 @@ class FolkCalendarView extends HTMLElement {
{ name: "Travel", color: "#f97316" },
{ name: "Personal", color: "#10b981" },
{ name: "Conferences", color: "#8b5cf6" },
{ name: "Community", color: "#ec4899" },
{ name: "Health", color: "#06b6d4" },
];
const rel = (monthDelta: number, day: number, hour: number, min: number) =>
@ -414,7 +422,7 @@ class FolkCalendarView extends HTMLElement {
const demoEvents: {
start: Date; durationMin: number; title: string; source: number;
desc: string; location: string | null; virtual: boolean;
lat?: number; lng?: number; breadcrumb?: string;
lat?: number; lng?: number; breadcrumb?: string; likelihood?: number;
}[] = [
// ── 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" },
@ -422,11 +430,13 @@ class FolkCalendarView extends HTMLElement {
{ 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, 23, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, lat: 52.4960, lng: 13.4088, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ start: rel(-1, 25, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, 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, 27, 19, 0), durationMin: 90, title: "Climate Action Meetup", source: 4, desc: "Monthly community meetup on local climate projects", location: "Regenbogenfabrik", virtual: false, lat: 52.4945, lng: 13.4290, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ 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 ──
// ── THIS MONTH — Periodic: MWF standups ──
{ 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" },
@ -435,42 +445,109 @@ class FolkCalendarView extends HTMLElement {
{ 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, 4, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ 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, 6, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ 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, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Weekly sync", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ 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, 9, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ start: rel(0, 10, 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, 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, 11, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ start: rel(0, 11, 18, 0), durationMin: 120, title: "Workshop: Intro to CRDTs (1/4)", source: 4, desc: "Community workshop series on conflict-free replicated data types", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" },
{ 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, 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, 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, 16, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ start: rel(0, 16, 14, 0), durationMin: 45, title: "Physiotherapy", source: 5, desc: "Shoulder rehab session", location: "Praxis Neuk\u00f6lln", virtual: false, lat: 52.4812, lng: 13.4350, breadcrumb: "Earth > Europe > Germany > Berlin > Neuk\u00f6lln" },
{ 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, 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, 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, 18, 18, 0), durationMin: 120, title: "Workshop: CRDTs (2/4)", source: 4, desc: "Hands-on: Counters, sets, and registers", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" },
{ start: rel(0, 19, 9, 0), durationMin: 45, title: "Dentist", source: 5, 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: 4, 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: 4, 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, 25, 18, 0), durationMin: 120, title: "Workshop: CRDTs (3/4)", source: 4, desc: "Merging trees and documents", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" },
{ start: rel(0, 26, 18, 0), durationMin: 180, title: "Hackathon \u2014 c-base", source: 4, 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, 27, 19, 0), durationMin: 90, title: "Climate Action Meetup", source: 4, desc: "Monthly: urban food systems", location: "Regenbogenfabrik", virtual: false, lat: 52.4945, lng: 13.4290, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ 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 ──
// ── NEXT MONTH (+1) ──
{ start: rel(1, 1, 18, 0), durationMin: 120, title: "Workshop: CRDTs (4/4)", source: 4, desc: "Final session: production patterns", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" },
{ 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, 6, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ start: rel(1, 8, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ 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, 10, 19, 0), durationMin: 90, title: "Book Club", source: 4, desc: '"Braiding Sweetgrass"', location: "Shakespeare & Sons, Berlin", virtual: false, lat: 52.4925, lng: 13.4310, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ 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" },
{ start: rel(1, 15, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Weekly sync", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ start: rel(1, 20, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ start: rel(1, 22, 14, 0), durationMin: 45, title: "Physiotherapy", source: 5, desc: "Shoulder rehab follow-up", location: "Praxis Neuk\u00f6lln", virtual: false, lat: 52.4812, lng: 13.4350, breadcrumb: "Earth > Europe > Germany > Berlin > Neuk\u00f6lln" },
{ start: rel(1, 25, 19, 0), durationMin: 90, title: "Climate Action Meetup", source: 4, desc: "Monthly: renewable energy co-ops", location: "Regenbogenfabrik", virtual: false, lat: 52.4945, lng: 13.4290, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
// ── MONTH +2 ──
{ start: rel(2, 3, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Weekly sync", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ start: rel(2, 5, 10, 0), durationMin: 120, title: "Sprint Planning", source: 0, desc: "Sprint 26", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ start: rel(2, 8, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ start: rel(2, 10, 19, 0), durationMin: 90, title: "Book Club", source: 4, desc: '"Ministry for the Future"', location: "Shakespeare & Sons, Berlin", virtual: false, lat: 52.4925, lng: 13.4310, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ start: rel(2, 14, 9, 0), durationMin: 480, title: "DWeb Camp Day 1", source: 3, desc: "Decentralized web camp", location: "Camp Navarro, CA", virtual: false, lat: 39.1766, lng: -123.6335, breadcrumb: "Earth > Americas > USA > California" },
{ start: rel(2, 15, 9, 0), durationMin: 480, title: "DWeb Camp Day 2", source: 3, desc: "Workshops and lightning talks", location: "Camp Navarro, CA", virtual: false, lat: 39.1766, lng: -123.6335, breadcrumb: "Earth > Americas > USA > California" },
{ start: rel(2, 20, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ start: rel(2, 25, 19, 0), durationMin: 90, title: "Climate Action Meetup", source: 4, desc: "Monthly: water commons", location: "Regenbogenfabrik", virtual: false, lat: 52.4945, lng: 13.4290, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
// ── MONTH +3 — tentative events begin ──
{ start: rel(3, 5, 10, 0), durationMin: 120, title: "Sprint Planning", source: 0, desc: "Sprint 27", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin", likelihood: 80 },
{ start: rel(3, 8, 9, 0), durationMin: 540, title: "IPFS Camp", source: 3, desc: "Potential IPFS community gathering", location: "Lisbon", virtual: false, lat: 38.7223, lng: -9.1393, breadcrumb: "Earth > Europe > Portugal > Lisbon", likelihood: 50 },
{ start: rel(3, 9, 9, 0), durationMin: 540, title: "IPFS Camp Day 2", source: 3, desc: "Workshops track", location: "Lisbon", virtual: false, lat: 38.7223, lng: -9.1393, breadcrumb: "Earth > Europe > Portugal > Lisbon", likelihood: 50 },
{ start: rel(3, 12, 19, 0), durationMin: 90, title: "Book Club", source: 4, desc: "TBD", location: "Shakespeare & Sons", virtual: false, lat: 52.4925, lng: 13.4310, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg", likelihood: 70 },
{ start: rel(3, 15, 14, 0), durationMin: 45, title: "Physiotherapy", source: 5, desc: "Check-in session", location: "Praxis Neuk\u00f6lln", virtual: false, lat: 52.4812, lng: 13.4350, breadcrumb: "Earth > Europe > Germany > Berlin > Neuk\u00f6lln", likelihood: 75 },
{ start: rel(3, 20, 10, 0), durationMin: 480, title: "Summer Retreat \u2014 Alps", source: 2, desc: "Hiking & writing retreat in Bavarian Alps", location: "Garmisch-Partenkirchen", virtual: false, lat: 47.4921, lng: 11.0958, breadcrumb: "Earth > Europe > Germany > Bavaria", likelihood: 60 },
{ start: rel(3, 21, 10, 0), durationMin: 480, title: "Summer Retreat Day 2", source: 2, desc: "Zugspitze summit attempt", location: "Garmisch-Partenkirchen", virtual: false, lat: 47.4921, lng: 11.0958, breadcrumb: "Earth > Europe > Germany > Bavaria", likelihood: 60 },
// ── MONTH +4 ──
{ start: rel(4, 2, 10, 0), durationMin: 120, title: "Sprint Planning", source: 0, desc: "Sprint 28", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin", likelihood: 75 },
{ start: rel(4, 6, 9, 0), durationMin: 480, title: "EthCC Paris", source: 3, desc: "Ethereum community conference", location: "Palais Brongniart, Paris", virtual: false, lat: 48.8696, lng: 2.3413, breadcrumb: "Earth > Europe > France > Paris", likelihood: 65 },
{ start: rel(4, 7, 9, 0), durationMin: 480, title: "EthCC Day 2", source: 3, desc: "Panel on decentralized identity", location: "Palais Brongniart, Paris", virtual: false, lat: 48.8696, lng: 2.3413, breadcrumb: "Earth > Europe > France > Paris", likelihood: 65 },
{ start: rel(4, 15, 19, 0), durationMin: 90, title: "Climate Action Meetup", source: 4, desc: "Monthly: transport decarbonization", location: "Regenbogenfabrik", virtual: false, lat: 52.4945, lng: 13.4290, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg", likelihood: 70 },
{ start: rel(4, 20, 19, 0), durationMin: 90, title: "Book Club", source: 4, desc: "TBD", location: "Shakespeare & Sons", virtual: false, lat: 52.4925, lng: 13.4310, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg", likelihood: 60 },
// ── MONTH +5 ──
{ start: rel(5, 5, 10, 0), durationMin: 120, title: "Sprint Planning", source: 0, desc: "Sprint 29", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin", likelihood: 60 },
{ start: rel(5, 10, 10, 0), durationMin: 480, title: "Team Offsite \u2014 Tenerife", source: 0, desc: "Winter offsite planning", location: "Tenerife", virtual: false, lat: 28.2916, lng: -16.6291, breadcrumb: "Earth > Europe > Spain > Canary Islands", likelihood: 40 },
{ start: rel(5, 11, 10, 0), durationMin: 480, title: "Team Offsite Day 2", source: 0, desc: "Workshops + hike", location: "Tenerife", virtual: false, lat: 28.2916, lng: -16.6291, breadcrumb: "Earth > Europe > Spain > Canary Islands", likelihood: 40 },
{ start: rel(5, 18, 19, 0), durationMin: 90, title: "Book Club", source: 4, desc: "TBD", location: "Shakespeare & Sons", virtual: false, lat: 52.4925, lng: 13.4310, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg", likelihood: 55 },
// ── MONTH +6 ──
{ start: rel(6, 3, 9, 0), durationMin: 480, title: "RheinMain Salon", source: 3, desc: "Regional tech meetup", location: "Frankfurt", virtual: false, lat: 50.1109, lng: 8.6821, breadcrumb: "Earth > Europe > Germany > Hessen > Frankfurt", likelihood: 45 },
{ start: rel(6, 12, 10, 0), durationMin: 480, title: "DevConnect Istanbul", source: 3, desc: "Ethereum developer conference", location: "Istanbul", virtual: false, lat: 41.0082, lng: 28.9784, breadcrumb: "Earth > Europe > Turkey > Istanbul", likelihood: 35 },
{ start: rel(6, 13, 10, 0), durationMin: 480, title: "DevConnect Day 2", source: 3, desc: "Identity & privacy track", location: "Istanbul", virtual: false, lat: 41.0082, lng: 28.9784, breadcrumb: "Earth > Europe > Turkey > Istanbul", likelihood: 35 },
{ start: rel(6, 20, 19, 0), durationMin: 90, title: "Climate Action Meetup", source: 4, desc: "Monthly TBD", location: "Regenbogenfabrik", virtual: false, lat: 52.4945, lng: 13.4290, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg", likelihood: 50 },
// ── MONTH +7 ──
{ start: rel(7, 5, 10, 0), durationMin: 120, title: "Sprint Planning", source: 0, desc: "Sprint 30", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin", likelihood: 40 },
{ start: rel(7, 15, 10, 0), durationMin: 480, title: "36C3 Day 1", source: 3, desc: "Chaos Communication Congress", location: "CCH, Hamburg", virtual: false, lat: 53.5631, lng: 9.9864, breadcrumb: "Earth > Europe > Germany > Hamburg", likelihood: 30 },
{ start: rel(7, 16, 10, 0), durationMin: 480, title: "36C3 Day 2", source: 3, desc: "Assembly: local-first computing", location: "CCH, Hamburg", virtual: false, lat: 53.5631, lng: 9.9864, breadcrumb: "Earth > Europe > Germany > Hamburg", likelihood: 30 },
// ── MONTH +8 ──
{ start: rel(8, 8, 10, 0), durationMin: 480, title: "FOSDEM", source: 3, desc: "Free & Open Source Developers European Meeting", location: "ULB, Brussels", virtual: false, lat: 50.8120, lng: 4.3817, breadcrumb: "Earth > Europe > Belgium > Brussels", likelihood: 25 },
{ start: rel(8, 9, 10, 0), durationMin: 480, title: "FOSDEM Day 2", source: 3, desc: "Decentralization devroom", location: "ULB, Brussels", virtual: false, lat: 50.8120, lng: 4.3817, breadcrumb: "Earth > Europe > Belgium > Brussels", likelihood: 25 },
];
this.events = demoEvents.map((e, i) => {
@ -491,14 +568,15 @@ class FolkCalendarView extends HTMLElement {
virtual_url: e.virtual ? "#" : undefined,
latitude: e.lat,
longitude: e.lng,
likelihood: e.likelihood ?? null,
};
});
this.sources = sources;
// Compute lunar phases for visible range (±6 months for season/year views)
// Compute lunar phases for visible range (month-6 through month+9)
const lunar: Record<string, { phase: string; illumination: number }> = {};
for (let m = month - 6; m <= month + 6; m++) {
for (let m = month - 6; m <= month + 9; 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();
@ -593,6 +671,25 @@ class FolkCalendarView extends HTMLElement {
&& !this.filteredSources.has(e.source_name));
}
/** Returns visual style overrides for tentative (likelihood < 100) events. */
private getEventStyles(ev: any): { bgColor: string; borderStyle: string; opacity: number; isTentative: boolean; likelihoodLabel: string } {
const baseColor = ev.source_color || "#6366f1";
const likelihood: number | null = ev.likelihood ?? null;
if (likelihood === null || likelihood >= 100) {
return { bgColor: `${baseColor}20`, borderStyle: "solid", opacity: 1, isTentative: false, likelihoodLabel: "" };
}
// Scale opacity from 0.5 (0%) to 1.0 (100%)
const alpha = Math.round(0.5 + (likelihood / 100) * 0.5);
const hexAlpha = Math.round((0.08 + (likelihood / 100) * 0.12) * 255).toString(16).padStart(2, "0");
return {
bgColor: `${baseColor}${hexAlpha}`,
borderStyle: "dashed",
opacity: 0.5 + (likelihood / 100) * 0.5,
isTentative: true,
likelihoodLabel: `${likelihood}%`,
};
}
private getSpatialLabel(breadcrumb: string | null | undefined, level: number): string {
if (!breadcrumb) return "";
const parts = breadcrumb.split(" > ").map(s => s.trim());
@ -695,11 +792,12 @@ class FolkCalendarView extends HTMLElement {
<div class="calendar-pane" id="calendar-pane">
${this.renderCalendarContent()}
</div>
${isDocked ? `<div class="zoom-bar--middle">${this.renderZoomController()}</div>` : ""}
${this.renderMapPanel()}
</div>
<div class="bottom-bar">
${this.renderZoomController()}
${!isDocked ? 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>
@ -897,17 +995,13 @@ class FolkCalendarView extends HTMLElement {
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">
return `<div class="map-panel map-panel--docked" 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>
<button class="map-ctrl-btn" id="map-minimize" title="Minimize (m)">\u2212</button>
</div>
</div>
<div class="map-body" id="map-host">
@ -970,15 +1064,22 @@ class FolkCalendarView extends HTMLElement {
</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.slice(0, 5).map(e => {
const es = this.getEventStyles(e);
return es.isTentative
? `<span class="dot dot--tentative" style="border-color:${e.source_color || "#6366f1"}"></span>`
: `<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 es = this.getEventStyles(e);
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>`;
const likelihoodHtml = es.isTentative ? `<span class="ev-likelihood">${es.likelihoodLabel}</span>` : "";
return `<div class="ev-label" style="border-left:2px ${es.borderStyle} ${evColor};background:${es.bgColor};opacity:${es.opacity}" 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)}${likelihoodHtml}${cityHtml}</div>`;
}).join("")}
` : ""}
</div>`;
@ -1079,11 +1180,11 @@ class FolkCalendarView extends HTMLElement {
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 es = this.getEventStyles(ev);
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"}">
left:${leftPx}px;width:${widthPx}px;background:${es.bgColor};border-top:3px ${es.borderStyle} ${ev.source_color || "#6366f1"};opacity:${es.opacity}">
<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>` : ""}
@ -1364,15 +1465,16 @@ class FolkCalendarView extends HTMLElement {
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 es = this.getEventStyles(ev);
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>` : "";
const likelihoodBadge = es.isTentative ? `<span class="ev-likelihood">${es.likelihoodLabel}</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>
top:${topPx}px;height:${heightPx}px;background:${es.bgColor};border-left-color:${ev.source_color || "#6366f1"};border-left-style:${es.borderStyle};opacity:${es.opacity}">
<div class="tl-event-title">${this.esc(ev.title)}${likelihoodBadge}</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>` : ""}
@ -1453,7 +1555,7 @@ class FolkCalendarView extends HTMLElement {
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 es = this.getEventStyles(ev);
const colLeft = `calc(44px + ${i} * ((100% - 44px) / 7) + 2px)`;
const colWidth = `calc((100% - 44px) / 7 - 4px)`;
const showMeta = heightPx >= 36;
@ -1462,7 +1564,7 @@ class FolkCalendarView extends HTMLElement {
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"}">
background:${es.bgColor};border-left-color:${ev.source_color || "#6366f1"};border-left-style:${es.borderStyle};opacity:${es.opacity}">
<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>`;
@ -1503,10 +1605,12 @@ class FolkCalendarView extends HTMLElement {
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>
const es = this.getEventStyles(e);
const likelihoodBadge = es.isTentative ? `<span class="dd-likelihood">${es.likelihoodLabel}</span>` : "";
return `<div class="dd-event" data-event-id="${e.id}" style="opacity:${es.opacity}">
<div class="dd-color" style="background:${e.source_color || "#6366f1"};${es.isTentative ? "border:1px dashed " + (e.source_color || "#6366f1") + ";background:transparent" : ""}"></div>
<div class="dd-info">
<div class="dd-title">${this.esc(e.title)}${srcTag}</div>
<div class="dd-title">${this.esc(e.title)}${likelihoodBadge}${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>
@ -1519,11 +1623,13 @@ class FolkCalendarView extends HTMLElement {
private renderEventModal(): string {
const e = this.selectedEvent;
const es = this.getEventStyles(e);
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>
${es.isTentative ? `<div class="modal-field" style="color:var(--rs-warning);font-weight:500">Penciled in (${es.likelihoodLabel})</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>` : ""}
@ -1621,16 +1727,8 @@ class FolkCalendarView extends HTMLElement {
});
// Map panel controls
$("map-fab")?.addEventListener("click", () => { this.mapPanelState = "floating"; this.render(); });
$("map-fab")?.addEventListener("click", () => { this.mapPanelState = "docked"; 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");
@ -1697,8 +1795,14 @@ class FolkCalendarView extends HTMLElement {
});
});
$("toggle-coupling")?.addEventListener("click", () => {
this.zoomCoupled = !this.zoomCoupled;
if (this.zoomCoupled) { this.spatialGranularity = null; this.syncMapToSpatial(); }
if (!this.zoomCoupled) {
this.zoomCoupled = true;
this.spatialGranularity = null;
this.syncMapToSpatial();
} else {
this.spatialGranularity = this.getEffectiveSpatialIndex();
this.zoomCoupled = false;
}
this.render();
});
@ -2070,9 +2174,13 @@ class FolkCalendarView extends HTMLElement {
const located = this.getVisibleLocatedEvents();
for (const ev of located) {
const es = this.getEventStyles(ev);
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,
fillColor: ev.source_color || "#6366f1",
fillOpacity: es.isTentative ? 0.3 : 0.7,
weight: 2,
dashArray: es.isTentative ? "4 3" : undefined,
});
marker.bindPopup(`<div style="font-size:13px;color:#1a1a2e">
<div style="font-weight:600">${this.esc(ev.title)}</div>
@ -2192,12 +2300,18 @@ class FolkCalendarView extends HTMLElement {
/* ── Main Layout ── */
.main-layout { position: relative; min-height: 400px; }
.main-layout--docked { display: grid; grid-template-columns: 1fr 400px; gap: 8px; min-height: 500px; }
.main-layout--docked { display: grid; grid-template-columns: 1fr auto 400px; gap: 0; min-height: 500px; }
.calendar-pane { overflow: auto; min-width: 0; touch-action: pan-y; user-select: none; }
.zoom-bar--middle { display: flex; flex-direction: column; justify-content: center; padding: 8px 2px; border-left: 1px solid var(--rs-border-subtle); border-right: 1px solid var(--rs-border-subtle); }
.zoom-bar--middle .zoom-bar { padding: 0; }
.zoom-bar--middle .zoom-bar__label-end { display: none; }
.zoom-bar--middle .zoom-bar__track { min-width: 100px; }
.zoom-bar--middle .zoom-bar__tick-label { font-size: 7px; }
.zoom-bar--middle .coupling-btn { padding: 2px 6px; font-size: 10px; }
.zoom-bar--middle .variant-indicator { display: none; }
/* ── 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); }
@ -2235,6 +2349,9 @@ class FolkCalendarView extends HTMLElement {
.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; }
.ev-likelihood { font-size: 8px; color: var(--rs-warning); margin-left: 3px; }
.dd-likelihood { font-size: 9px; color: var(--rs-warning); margin-left: 6px; }
.dot--tentative { border: 1px dashed; background: transparent !important; width: 6px; height: 6px; border-radius: 50%; display: inline-block; margin: 1px; }
/* ── Drop Target ── */
.day.drop-target { background: rgba(245,158,11,0.15); border: 2px dashed var(--rs-warning); }
@ -2428,7 +2545,7 @@ class FolkCalendarView extends HTMLElement {
@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; }
.zoom-bar--middle { display: none; }
.year-grid { grid-template-columns: repeat(3, 1fr); }
.season-grid { grid-template-columns: 1fr; }
.day { min-height: 52px; padding: 4px; }

View File

@ -33,6 +33,7 @@ export interface CalendarEvent {
timezone: string | null;
rrule: string | null;
status: string | null;
likelihood: number | null; // 0-100, null = confirmed (100%)
visibility: string | null;
sourceId: string | null;
sourceName: string | null;