feat(rcal): map shows routes with booking status, calendar keeps semantic zoom
Map always shows individual event markers (no clustering). Transit lines now colored by booking status: green solid = booked, red dashed = not yet booked. New bookingStatus field on CalendarEvent as placeholder for the forthcoming booking pipeline. Calendar views retain semantic zoom (country/city chips at year/season/month levels). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
988b10fd65
commit
65b72ed7ac
|
|
@ -106,7 +106,7 @@ import { calendarSchema, calendarDocId, type CalendarDoc } from "../schemas";
|
||||||
import type { DocumentId } from "../../../shared/local-first/document";
|
import type { DocumentId } from "../../../shared/local-first/document";
|
||||||
import { TourEngine } from "../../../shared/tour-engine";
|
import { TourEngine } from "../../../shared/tour-engine";
|
||||||
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
||||||
import { generateBreadcrumb, CONTINENT_CENTROIDS } from "../geo-hierarchy";
|
import { generateBreadcrumb } from "../geo-hierarchy";
|
||||||
|
|
||||||
// ── Component ──
|
// ── Component ──
|
||||||
|
|
||||||
|
|
@ -262,6 +262,7 @@ class FolkCalendarView extends HTMLElement {
|
||||||
source_name: e.sourceName, source_color: e.sourceColor,
|
source_name: e.sourceName, source_color: e.sourceColor,
|
||||||
location_name: e.locationName,
|
location_name: e.locationName,
|
||||||
location_breadcrumb: e.locationBreadcrumb ?? null,
|
location_breadcrumb: e.locationBreadcrumb ?? null,
|
||||||
|
booking_status: e.bookingStatus ?? null,
|
||||||
location_lat: e.locationLat, location_lng: e.locationLng,
|
location_lat: e.locationLat, location_lng: e.locationLng,
|
||||||
latitude: e.locationLat ?? null,
|
latitude: e.locationLat ?? null,
|
||||||
longitude: e.locationLng ?? null,
|
longitude: e.locationLng ?? null,
|
||||||
|
|
@ -479,7 +480,7 @@ class FolkCalendarView extends HTMLElement {
|
||||||
const demoEvents: {
|
const demoEvents: {
|
||||||
start: Date; durationMin: number; title: string; source: number;
|
start: Date; durationMin: number; title: string; source: number;
|
||||||
desc: string; location: string | null; virtual: boolean;
|
desc: string; location: string | null; virtual: boolean;
|
||||||
lat?: number; lng?: number; breadcrumb?: string; likelihood?: number;
|
lat?: number; lng?: number; breadcrumb?: string; likelihood?: number; booked?: boolean;
|
||||||
}[] = [
|
}[] = [
|
||||||
// ── LAST MONTH ──
|
// ── 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, 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" },
|
||||||
|
|
@ -514,11 +515,11 @@ class FolkCalendarView extends HTMLElement {
|
||||||
{ 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, 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, 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, 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, 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", booked: true },
|
||||||
{ 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, 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", booked: true },
|
||||||
{ 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, 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", booked: true },
|
||||||
{ 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, 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, 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", booked: true },
|
||||||
{ 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, 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, 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: 5, 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" },
|
||||||
|
|
@ -531,11 +532,11 @@ class FolkCalendarView extends HTMLElement {
|
||||||
{ 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, 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, 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, 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, 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", booked: true },
|
||||||
{ 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, 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", booked: true },
|
||||||
{ 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, 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", booked: true },
|
||||||
{ 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, 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, 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", booked: true },
|
||||||
{ 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, 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, 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, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Post-travel sync", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" },
|
||||||
|
|
@ -545,10 +546,10 @@ class FolkCalendarView extends HTMLElement {
|
||||||
// ── NEXT MONTH (+1) ──
|
// ── 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, 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, 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, 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", booked: true },
|
||||||
{ 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, 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", booked: true },
|
||||||
{ 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, 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", booked: true },
|
||||||
{ 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, 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", booked: true },
|
||||||
{ 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, 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, 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, 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" },
|
||||||
|
|
@ -668,6 +669,7 @@ class FolkCalendarView extends HTMLElement {
|
||||||
latitude: e.lat,
|
latitude: e.lat,
|
||||||
longitude: e.lng,
|
longitude: e.lng,
|
||||||
likelihood: e.likelihood ?? null,
|
likelihood: e.likelihood ?? null,
|
||||||
|
booking_status: e.booked ? "booked" : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2772,92 +2774,8 @@ class FolkCalendarView extends HTMLElement {
|
||||||
this.mapMarkerLayer.clearLayers();
|
this.mapMarkerLayer.clearLayers();
|
||||||
|
|
||||||
const located = this.getVisibleLocatedEvents();
|
const located = this.getVisibleLocatedEvents();
|
||||||
const spatialLevel = this.getEffectiveSpatialIndex();
|
|
||||||
|
|
||||||
if (spatialLevel <= 1) {
|
for (const ev of located) {
|
||||||
// Planet / Continent — group by continent
|
|
||||||
this.renderAggregateMarkers(located, 1);
|
|
||||||
} else if (spatialLevel <= 3) {
|
|
||||||
// Bioregion / Country — group by country
|
|
||||||
this.renderAggregateMarkers(located, 3);
|
|
||||||
} else if (spatialLevel <= 5) {
|
|
||||||
// Region / City — group by city
|
|
||||||
this.renderAggregateMarkers(located, 5);
|
|
||||||
} else {
|
|
||||||
// Neighborhood / Address / Coordinates — individual markers
|
|
||||||
this.renderIndividualMarkers(located);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-fit map to visible event bounds
|
|
||||||
if (this.zoomCoupled && located.length > 0) {
|
|
||||||
if (located.length === 1) {
|
|
||||||
this.leafletMap?.flyTo([located[0].latitude, located[0].longitude], this.getEffectiveLeafletZoom(), { duration: 0.8 });
|
|
||||||
} else {
|
|
||||||
const bounds = L.latLngBounds(located.map((e: any) => [e.latitude, e.longitude]));
|
|
||||||
this.leafletMap?.flyToBounds(bounds, { padding: [40, 40], maxZoom: 16, duration: 0.8 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Render aggregate cluster markers grouped by spatial label at the given level. */
|
|
||||||
private renderAggregateMarkers(events: any[], level: number) {
|
|
||||||
const L = (window as any).L;
|
|
||||||
if (!L) return;
|
|
||||||
|
|
||||||
// Group events by spatial label
|
|
||||||
const groups = new Map<string, any[]>();
|
|
||||||
for (const ev of events) {
|
|
||||||
const label = this.getSpatialLabel(ev.location_breadcrumb, level) || "Other";
|
|
||||||
if (!groups.has(label)) groups.set(label, []);
|
|
||||||
groups.get(label)!.push(ev);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [label, evs] of groups) {
|
|
||||||
// Compute centroid — use stable continent centroids at level ≤ 1
|
|
||||||
let lat: number, lng: number;
|
|
||||||
if (level <= 1 && CONTINENT_CENTROIDS[label]) {
|
|
||||||
[lat, lng] = CONTINENT_CENTROIDS[label];
|
|
||||||
} else {
|
|
||||||
let sumLat = 0, sumLng = 0;
|
|
||||||
for (const e of evs) { sumLat += e.latitude; sumLng += e.longitude; }
|
|
||||||
lat = sumLat / evs.length;
|
|
||||||
lng = sumLng / evs.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine dominant source color for border
|
|
||||||
const colorCounts: Record<string, number> = {};
|
|
||||||
for (const e of evs) {
|
|
||||||
const c = e.source_color || "#6366f1";
|
|
||||||
colorCounts[c] = (colorCounts[c] || 0) + 1;
|
|
||||||
}
|
|
||||||
const dominantColor = Object.entries(colorCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || "#6366f1";
|
|
||||||
|
|
||||||
const icon = L.divIcon({
|
|
||||||
className: "",
|
|
||||||
html: `<div class="map-cluster-label" style="border-color:${dominantColor}"><span>${this.esc(label)}</span><span class="map-cluster-count">${evs.length}</span></div>`,
|
|
||||||
iconSize: [0, 0],
|
|
||||||
iconAnchor: [0, 0],
|
|
||||||
});
|
|
||||||
|
|
||||||
const marker = L.marker([lat, lng], { icon });
|
|
||||||
|
|
||||||
// Popup listing events in this cluster (first 10)
|
|
||||||
const popupList = evs.slice(0, 10).map((e: any) =>
|
|
||||||
`<div style="font-size:11px;padding:2px 0;border-left:2px solid ${e.source_color || "#6366f1"};padding-left:6px">${this.esc(e.title)}<br><span style="color:#888;font-size:10px">${new Date(e.start_time).toLocaleDateString("default", { month: "short", day: "numeric" })}</span></div>`
|
|
||||||
).join("");
|
|
||||||
const overflow = evs.length > 10 ? `<div style="color:#888;font-size:10px;margin-top:4px">+${evs.length - 10} more</div>` : "";
|
|
||||||
marker.bindPopup(`<div style="font-size:13px;color:#1a1a2e"><div style="font-weight:600;margin-bottom:6px">${this.esc(label)} (${evs.length})</div>${popupList}${overflow}</div>`);
|
|
||||||
|
|
||||||
this.mapMarkerLayer.addLayer(marker);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Render individual event markers (original behavior). */
|
|
||||||
private renderIndividualMarkers(events: any[]) {
|
|
||||||
const L = (window as any).L;
|
|
||||||
if (!L) return;
|
|
||||||
|
|
||||||
for (const ev of events) {
|
|
||||||
const es = this.getEventStyles(ev);
|
const es = this.getEventStyles(ev);
|
||||||
const marker = L.circleMarker([ev.latitude, ev.longitude], {
|
const marker = L.circleMarker([ev.latitude, ev.longitude], {
|
||||||
radius: 6, color: ev.source_color || "#6366f1",
|
radius: 6, color: ev.source_color || "#6366f1",
|
||||||
|
|
@ -2873,6 +2791,16 @@ class FolkCalendarView extends HTMLElement {
|
||||||
</div>`);
|
</div>`);
|
||||||
this.mapMarkerLayer.addLayer(marker);
|
this.mapMarkerLayer.addLayer(marker);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-fit map to visible event bounds
|
||||||
|
if (this.zoomCoupled && located.length > 0) {
|
||||||
|
if (located.length === 1) {
|
||||||
|
this.leafletMap?.flyTo([located[0].latitude, located[0].longitude], this.getEffectiveLeafletZoom(), { duration: 0.8 });
|
||||||
|
} else {
|
||||||
|
const bounds = L.latLngBounds(located.map((e: any) => [e.latitude, e.longitude]));
|
||||||
|
this.leafletMap?.flyToBounds(bounds, { padding: [40, 40], maxZoom: 16, duration: 0.8 });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateTransitLines() {
|
private updateTransitLines() {
|
||||||
|
|
@ -2880,9 +2808,6 @@ class FolkCalendarView extends HTMLElement {
|
||||||
if (!L || !this.transitLineLayer) return;
|
if (!L || !this.transitLineLayer) return;
|
||||||
this.transitLineLayer.clearLayers();
|
this.transitLineLayer.clearLayers();
|
||||||
|
|
||||||
// Skip transit lines at aggregate zoom levels (spatial < 6)
|
|
||||||
if (this.getEffectiveSpatialIndex() < 6) return;
|
|
||||||
|
|
||||||
const sorted = this.getVisibleLocatedEvents()
|
const sorted = this.getVisibleLocatedEvents()
|
||||||
.sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
|
.sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
|
||||||
|
|
||||||
|
|
@ -2892,14 +2817,16 @@ class FolkCalendarView extends HTMLElement {
|
||||||
const curr = sorted[i], next = sorted[i + 1];
|
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;
|
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";
|
// Line color based on booking status: green = booked, red = unbooked/unknown
|
||||||
|
const isBooked = next.booking_status === "booked";
|
||||||
|
const color = isBooked ? "#22c55e" : "#ef4444";
|
||||||
|
const label = isBooked ? "Booked" : "Not booked";
|
||||||
const line = L.polyline(
|
const line = L.polyline(
|
||||||
[[curr.latitude, curr.longitude], [next.latitude, next.longitude]],
|
[[curr.latitude, curr.longitude], [next.latitude, next.longitude]],
|
||||||
{ color: isTravel ? "#f97316" : "#94a3b8", weight: isTravel ? 3 : 2,
|
{ color, weight: 3, opacity: 0.7, dashArray: isBooked ? undefined : "8, 6" }
|
||||||
opacity: isTravel ? 0.8 : 0.4, dashArray: isTravel ? "8, 8" : "4, 8" }
|
|
||||||
);
|
);
|
||||||
line.bindTooltip(
|
line.bindTooltip(
|
||||||
`${this.esc(curr.location_name || curr.title)} \u2192 ${this.esc(next.location_name || next.title)}`,
|
`${this.esc(curr.location_name || curr.title)} \u2192 ${this.esc(next.location_name || next.title)} <span style="color:${color};font-weight:600">[${label}]</span>`,
|
||||||
{ sticky: true }
|
{ sticky: true }
|
||||||
);
|
);
|
||||||
this.transitLineLayer.addLayer(line);
|
this.transitLineLayer.addLayer(line);
|
||||||
|
|
@ -3008,10 +2935,6 @@ class FolkCalendarView extends HTMLElement {
|
||||||
.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 { 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); }
|
.map-fab:hover { border-color: var(--rs-primary-hover); background: var(--rs-bg-surface-raised); transform: scale(1.1); }
|
||||||
|
|
||||||
/* ── Map Cluster Labels (semantic zoom) ── */
|
|
||||||
.map-cluster-label { background: var(--rs-bg-surface, #1e1e2e); border: 2px solid var(--rs-primary, #6366f1); border-radius: 16px; padding: 4px 10px; font-size: 12px; font-weight: 600; white-space: nowrap; box-shadow: 0 2px 8px rgba(0,0,0,0.2); color: var(--rs-text-primary, #e0e0e0); display: inline-flex; align-items: center; gap: 4px; transform: translate(-50%, -50%); pointer-events: auto; cursor: pointer; }
|
|
||||||
.map-cluster-count { font-size: 10px; opacity: 0.7; }
|
|
||||||
|
|
||||||
/* ── Synodic (reused in overlay) ── */
|
/* ── Synodic (reused in overlay) ── */
|
||||||
.synodic-section { margin: 0 0 8px; }
|
.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-labels { display: flex; justify-content: space-between; font-size: 11px; color: var(--rs-text-muted); margin-bottom: 6px; }
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ export class CalLocalFirstClient {
|
||||||
const docId = calendarDocId(this.#space) as DocumentId;
|
const docId = calendarDocId(this.#space) as DocumentId;
|
||||||
this.#sync.change<CalendarDoc>(docId, `Update event ${eventId}`, (d) => {
|
this.#sync.change<CalendarDoc>(docId, `Update event ${eventId}`, (d) => {
|
||||||
if (!d.events[eventId]) {
|
if (!d.events[eventId]) {
|
||||||
d.events[eventId] = { id: eventId, title: '', description: '', startTime: 0, endTime: 0, allDay: false, timezone: null, rrule: null, status: null, visibility: null, sourceId: null, sourceName: null, sourceType: null, sourceColor: null, locationId: null, locationName: null, coordinates: null, locationGranularity: null, locationLat: null, locationLng: null, locationBreadcrumb: null, isVirtual: false, virtualUrl: null, virtualPlatform: null, rToolSource: null, rToolEntityId: null, attendees: [], attendeeCount: 0, metadata: null, createdAt: Date.now(), updatedAt: Date.now(), ...changes } as CalendarEvent;
|
d.events[eventId] = { id: eventId, title: '', description: '', startTime: 0, endTime: 0, allDay: false, timezone: null, rrule: null, status: null, visibility: null, sourceId: null, sourceName: null, sourceType: null, sourceColor: null, locationId: null, locationName: null, coordinates: null, locationGranularity: null, locationLat: null, locationLng: null, locationBreadcrumb: null, bookingStatus: null, isVirtual: false, virtualUrl: null, virtualPlatform: null, rToolSource: null, rToolEntityId: null, attendees: [], attendeeCount: 0, metadata: null, createdAt: Date.now(), updatedAt: Date.now(), ...changes } as CalendarEvent;
|
||||||
} else {
|
} else {
|
||||||
Object.assign(d.events[eventId], changes);
|
Object.assign(d.events[eventId], changes);
|
||||||
d.events[eventId].updatedAt = Date.now();
|
d.events[eventId].updatedAt = Date.now();
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ function eventToRow(ev: CalendarEvent, sources: Record<string, CalendarSource>)
|
||||||
location_lng: ev.locationLng,
|
location_lng: ev.locationLng,
|
||||||
location_granularity: ev.locationGranularity,
|
location_granularity: ev.locationGranularity,
|
||||||
location_breadcrumb: ev.locationBreadcrumb,
|
location_breadcrumb: ev.locationBreadcrumb,
|
||||||
|
booking_status: ev.bookingStatus,
|
||||||
is_virtual: ev.isVirtual,
|
is_virtual: ev.isVirtual,
|
||||||
virtual_url: ev.virtualUrl,
|
virtual_url: ev.virtualUrl,
|
||||||
virtual_platform: ev.virtualPlatform,
|
virtual_platform: ev.virtualPlatform,
|
||||||
|
|
@ -256,6 +257,7 @@ function seedDemoIfEmpty(space: string) {
|
||||||
locationLat: e.locationLat ?? null,
|
locationLat: e.locationLat ?? null,
|
||||||
locationLng: e.locationLng ?? null,
|
locationLng: e.locationLng ?? null,
|
||||||
locationBreadcrumb: null,
|
locationBreadcrumb: null,
|
||||||
|
bookingStatus: null,
|
||||||
isVirtual: e.isVirtual || false,
|
isVirtual: e.isVirtual || false,
|
||||||
virtualUrl: e.virtualUrl || null,
|
virtualUrl: e.virtualUrl || null,
|
||||||
virtualPlatform: e.virtualPlatform || null,
|
virtualPlatform: e.virtualPlatform || null,
|
||||||
|
|
@ -413,6 +415,7 @@ routes.post("/api/events", async (c) => {
|
||||||
locationLat: null,
|
locationLat: null,
|
||||||
locationLng: null,
|
locationLng: null,
|
||||||
locationBreadcrumb: null,
|
locationBreadcrumb: null,
|
||||||
|
bookingStatus: null,
|
||||||
isVirtual: is_virtual || false,
|
isVirtual: is_virtual || false,
|
||||||
virtualUrl: virtual_url || null,
|
virtualUrl: virtual_url || null,
|
||||||
virtualPlatform: virtual_platform || null,
|
virtualPlatform: virtual_platform || null,
|
||||||
|
|
@ -544,6 +547,7 @@ routes.post("/api/import-ics", async (c) => {
|
||||||
locationLat: null,
|
locationLat: null,
|
||||||
locationLng: null,
|
locationLng: null,
|
||||||
locationBreadcrumb: null,
|
locationBreadcrumb: null,
|
||||||
|
bookingStatus: null,
|
||||||
isVirtual: false,
|
isVirtual: false,
|
||||||
virtualUrl: null,
|
virtualUrl: null,
|
||||||
virtualPlatform: null,
|
virtualPlatform: null,
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ export interface CalendarEvent {
|
||||||
rToolSource: string | null;
|
rToolSource: string | null;
|
||||||
rToolEntityId: string | null;
|
rToolEntityId: string | null;
|
||||||
locationBreadcrumb: string | null; // "Earth > Europe > Germany > Berlin"
|
locationBreadcrumb: string | null; // "Earth > Europe > Germany > Berlin"
|
||||||
|
bookingStatus: string | null; // "booked" | "unbooked" | null (placeholder for booking pipeline)
|
||||||
attendees: unknown[];
|
attendees: unknown[];
|
||||||
attendeeCount: number;
|
attendeeCount: number;
|
||||||
tags: string[] | null;
|
tags: string[] | null;
|
||||||
|
|
|
||||||
|
|
@ -260,6 +260,7 @@ async function executeCalendarEvent(
|
||||||
locationLat: null,
|
locationLat: null,
|
||||||
locationLng: null,
|
locationLng: null,
|
||||||
locationBreadcrumb: null,
|
locationBreadcrumb: null,
|
||||||
|
bookingStatus: null,
|
||||||
isVirtual: false,
|
isVirtual: false,
|
||||||
virtualUrl: null,
|
virtualUrl: null,
|
||||||
virtualPlatform: null,
|
virtualPlatform: null,
|
||||||
|
|
@ -1087,6 +1088,7 @@ function syncReminderToCalendar(reminder: Reminder, space: string): string | nul
|
||||||
locationLat: null,
|
locationLat: null,
|
||||||
locationLng: null,
|
locationLng: null,
|
||||||
locationBreadcrumb: null,
|
locationBreadcrumb: null,
|
||||||
|
bookingStatus: null,
|
||||||
isVirtual: false,
|
isVirtual: false,
|
||||||
virtualUrl: null,
|
virtualUrl: null,
|
||||||
virtualPlatform: null,
|
virtualPlatform: null,
|
||||||
|
|
@ -1698,6 +1700,7 @@ async function executeWorkflowNode(
|
||||||
locationLat: null,
|
locationLat: null,
|
||||||
locationLng: null,
|
locationLng: null,
|
||||||
locationBreadcrumb: null,
|
locationBreadcrumb: null,
|
||||||
|
bookingStatus: null,
|
||||||
isVirtual: false,
|
isVirtual: false,
|
||||||
virtualUrl: null,
|
virtualUrl: null,
|
||||||
virtualPlatform: null,
|
virtualPlatform: null,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue