Merge branch 'dev'
CI/CD / deploy (push) Has been cancelled
Details
CI/CD / deploy (push) Has been cancelled
Details
This commit is contained in:
commit
0283d52989
|
|
@ -106,6 +106,7 @@ import { calendarSchema, calendarDocId, type CalendarDoc } from "../schemas";
|
|||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
||||
import { generateBreadcrumb, CONTINENT_CENTROIDS } from "../geo-hierarchy";
|
||||
|
||||
// ── Component ──
|
||||
|
||||
|
|
@ -260,6 +261,7 @@ class FolkCalendarView extends HTMLElement {
|
|||
all_day: e.allDay, source_id: e.sourceId,
|
||||
source_name: e.sourceName, source_color: e.sourceColor,
|
||||
location_name: e.locationName,
|
||||
location_breadcrumb: e.locationBreadcrumb ?? null,
|
||||
location_lat: e.locationLat, location_lng: e.locationLng,
|
||||
latitude: e.locationLat ?? null,
|
||||
longitude: e.locationLng ?? null,
|
||||
|
|
@ -270,6 +272,7 @@ class FolkCalendarView extends HTMLElement {
|
|||
}
|
||||
}
|
||||
|
||||
this.fillMissingBreadcrumbs();
|
||||
this.render();
|
||||
}
|
||||
|
||||
|
|
@ -435,6 +438,25 @@ class FolkCalendarView extends HTMLElement {
|
|||
return [sumLat / located.length, sumLng / located.length];
|
||||
}
|
||||
|
||||
/** Fill missing location_breadcrumb fields from lat/lng using static geo lookup. */
|
||||
private fillMissingBreadcrumbs() {
|
||||
for (const ev of this.events) {
|
||||
if (!ev.location_breadcrumb && ev.latitude != null && ev.longitude != null) {
|
||||
ev.location_breadcrumb = generateBreadcrumb(ev.latitude, ev.longitude, ev.location_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Get events for a specific month (used by year/season/multi-year spatial labels). */
|
||||
private getMonthEvents(year: number, month: number): any[] {
|
||||
const start = new Date(year, month, 1).getTime();
|
||||
const end = new Date(year, month + 1, 1).getTime();
|
||||
return this.events.filter(e => {
|
||||
const t = new Date(e.start_time).getTime();
|
||||
return t >= start && t < end && !this.filteredSources.has(e.source_name);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Demo Data ──
|
||||
|
||||
private loadDemoData() {
|
||||
|
|
@ -650,6 +672,7 @@ class FolkCalendarView extends HTMLElement {
|
|||
});
|
||||
|
||||
this.sources = sources;
|
||||
this.fillMissingBreadcrumbs();
|
||||
|
||||
// Compute lunar phases for visible range (month-6 through month+9)
|
||||
const lunar: Record<string, { phase: string; illumination: number }> = {};
|
||||
|
|
@ -706,6 +729,7 @@ class FolkCalendarView extends HTMLElement {
|
|||
if (lunarRes.ok) { this.lunarData = await lunarRes.json(); }
|
||||
if (remindersRes?.ok) { const data = await remindersRes.json(); this.reminders = data.reminders || []; }
|
||||
} catch { /* offline fallback */ }
|
||||
this.fillMissingBreadcrumbs();
|
||||
this.render();
|
||||
}
|
||||
|
||||
|
|
@ -1133,7 +1157,20 @@ class FolkCalendarView extends HTMLElement {
|
|||
private renderMonth(): string {
|
||||
const year = this.currentDate.getFullYear();
|
||||
const month = this.currentDate.getMonth();
|
||||
const spatialLevel = this.getEffectiveSpatialIndex();
|
||||
|
||||
// Spatial summary bar for zoomed-out views (country level or broader)
|
||||
let spatialSummary = "";
|
||||
if (spatialLevel <= 3) {
|
||||
const monthEvents = this.getMonthEvents(year, month);
|
||||
const countries = this.getUniqueSpatialLabels(monthEvents, 3, 5);
|
||||
if (countries.length > 0) {
|
||||
spatialSummary = `<div class="season-cities" style="margin-bottom:6px">${countries.map(c => `<span class="season-city-chip">${this.esc(c)}</span>`).join("")}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
${spatialSummary}
|
||||
<div class="weekdays">
|
||||
${["S", "M", "T", "W", "T", "F", "S"].map(d => `<div class="wd">${d}</div>`).join("")}
|
||||
</div>
|
||||
|
|
@ -1328,18 +1365,16 @@ class FolkCalendarView extends HTMLElement {
|
|||
const months = [quarter * 3, quarter * 3 + 1, quarter * 3 + 2];
|
||||
const seasonName = ["Winter", "Spring", "Summer", "Autumn"][quarter];
|
||||
|
||||
const spatialLevel = this.getEffectiveSpatialIndex();
|
||||
const labelLevel = spatialLevel <= 3 ? 3 : 5; // country or city based on zoom
|
||||
|
||||
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>`
|
||||
const monthEvents = this.getMonthEvents(year, m);
|
||||
const labels = this.getUniqueSpatialLabels(monthEvents, labelLevel, 4);
|
||||
const labelsHtml = labels.length > 0
|
||||
? `<div class="season-cities">${labels.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>`;
|
||||
return `<div class="season-month-wrap">${this.renderMiniMonth(year, m)}${labelsHtml}</div>`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
|
|
@ -1354,9 +1389,21 @@ class FolkCalendarView extends HTMLElement {
|
|||
|
||||
private renderYear(): string {
|
||||
const year = this.currentDate.getFullYear();
|
||||
const spatialLevel = this.getEffectiveSpatialIndex();
|
||||
const labelLevel = spatialLevel <= 3 ? 3 : 5; // country or city based on zoom
|
||||
|
||||
const monthsHtml = Array.from({length: 12}, (_, i) => {
|
||||
const monthEvents = this.getMonthEvents(year, i);
|
||||
const labels = this.getUniqueSpatialLabels(monthEvents, labelLevel, 3);
|
||||
const chipsHtml = labels.length > 0
|
||||
? `<div class="season-cities">${labels.map(c => `<span class="season-city-chip">${this.esc(c)}</span>`).join("")}</div>`
|
||||
: "";
|
||||
return `<div class="season-month-wrap">${this.renderMiniMonth(year, i)}${chipsHtml}</div>`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
<div class="year-grid">
|
||||
${Array.from({length: 12}, (_, i) => this.renderMiniMonth(year, i)).join("")}
|
||||
${monthsHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
|
@ -1537,8 +1584,10 @@ class FolkCalendarView extends HTMLElement {
|
|||
</div>`;
|
||||
}
|
||||
|
||||
const countries = this.getUniqueSpatialLabels(monthEvents, 3, 2);
|
||||
const countryHtml = countries.length > 0 ? `<span class="yv-country">${countries.join(", ")}</span>` : "";
|
||||
const spatialLevel = this.getEffectiveSpatialIndex();
|
||||
const labelLevel = spatialLevel <= 1 ? 1 : spatialLevel <= 3 ? 3 : 5;
|
||||
const spatialLabels = this.getUniqueSpatialLabels(monthEvents, labelLevel, 2);
|
||||
const countryHtml = spatialLabels.length > 0 ? `<span class="yv-country">${spatialLabels.join(", ")}</span>` : "";
|
||||
|
||||
html += `<div class="yv-month" data-mini-month="${m}">
|
||||
<div class="yv-label">${monthName}${countryHtml}</div>
|
||||
|
|
@ -1561,13 +1610,22 @@ class FolkCalendarView extends HTMLElement {
|
|||
const isCurrent = y === new Date().getFullYear();
|
||||
|
||||
let monthsHtml = "";
|
||||
const yearEvents: any[] = [];
|
||||
for (let m = 0; m < 12; m++) {
|
||||
monthsHtml += this.renderMicroMonth(y, m);
|
||||
yearEvents.push(...this.getMonthEvents(y, m));
|
||||
}
|
||||
|
||||
// Continent-level labels for the year
|
||||
const continents = this.getUniqueSpatialLabels(yearEvents, 1, 3);
|
||||
const continentHtml = continents.length > 0
|
||||
? `<div class="season-cities" style="margin-top:4px">${continents.map(c => `<span class="season-city-chip">${this.esc(c)}</span>`).join("")}</div>`
|
||||
: "";
|
||||
|
||||
html += `<div class="my-year ${isCurrent ? "current" : ""}" data-my-year="${y}">
|
||||
<div class="my-year-label">${y}</div>
|
||||
<div class="my-months">${monthsHtml}</div>
|
||||
${continentHtml}
|
||||
</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
|
|
@ -2714,8 +2772,92 @@ class FolkCalendarView extends HTMLElement {
|
|||
this.mapMarkerLayer.clearLayers();
|
||||
|
||||
const located = this.getVisibleLocatedEvents();
|
||||
const spatialLevel = this.getEffectiveSpatialIndex();
|
||||
|
||||
for (const ev of located) {
|
||||
if (spatialLevel <= 1) {
|
||||
// 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 marker = L.circleMarker([ev.latitude, ev.longitude], {
|
||||
radius: 6, color: ev.source_color || "#6366f1",
|
||||
|
|
@ -2731,16 +2873,6 @@ class FolkCalendarView extends HTMLElement {
|
|||
</div>`);
|
||||
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() {
|
||||
|
|
@ -2748,6 +2880,9 @@ class FolkCalendarView extends HTMLElement {
|
|||
if (!L || !this.transitLineLayer) return;
|
||||
this.transitLineLayer.clearLayers();
|
||||
|
||||
// Skip transit lines at aggregate zoom levels (spatial < 6)
|
||||
if (this.getEffectiveSpatialIndex() < 6) return;
|
||||
|
||||
const sorted = this.getVisibleLocatedEvents()
|
||||
.sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
|
||||
|
||||
|
|
@ -2873,6 +3008,10 @@ 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: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-section { margin: 0 0 8px; }
|
||||
.synodic-labels { display: flex; justify-content: space-between; font-size: 11px; color: var(--rs-text-muted); margin-bottom: 6px; }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* Static geographic hierarchy lookup — no API calls, works offline.
|
||||
*
|
||||
* Provides continent/country bounding-box lookups and breadcrumb generation
|
||||
* for semantic zoom on the map and calendar views.
|
||||
*/
|
||||
|
||||
// ── Continent definitions ──
|
||||
|
||||
interface BoundingBox {
|
||||
name: string;
|
||||
latMin: number;
|
||||
latMax: number;
|
||||
lngMin: number;
|
||||
lngMax: number;
|
||||
}
|
||||
|
||||
interface CountryDef extends BoundingBox {
|
||||
continent: string;
|
||||
area: number; // approx sq degrees for specificity sorting
|
||||
}
|
||||
|
||||
export const CONTINENTS: BoundingBox[] = [
|
||||
{ name: "Europe", latMin: 35, latMax: 72, lngMin: -25, lngMax: 45 },
|
||||
{ name: "Asia", latMin: -10, latMax: 75, lngMin: 45, lngMax: 180 },
|
||||
{ name: "Africa", latMin: -35, latMax: 37, lngMin: -20, lngMax: 55 },
|
||||
{ name: "North America", latMin: 15, latMax: 84, lngMin: -170, lngMax: -50 },
|
||||
{ name: "South America", latMin: -56, latMax: 15, lngMin: -82, lngMax: -34 },
|
||||
{ name: "Oceania", latMin: -48, latMax: 0, lngMin: 110, lngMax: 180 },
|
||||
{ name: "Antarctica", latMin: -90, latMax: -60, lngMin: -180, lngMax: 180 },
|
||||
];
|
||||
|
||||
export const CONTINENT_CENTROIDS: Record<string, [number, number]> = {
|
||||
"Europe": [50.0, 10.0],
|
||||
"Asia": [35.0, 90.0],
|
||||
"Africa": [5.0, 25.0],
|
||||
"North America": [45.0, -100.0],
|
||||
"South America": [-15.0, -60.0],
|
||||
"Oceania": [-25.0, 140.0],
|
||||
"Antarctica": [-82.0, 0.0],
|
||||
};
|
||||
|
||||
// ~50 major countries sorted by area (ascending) for specificity-first matching
|
||||
const COUNTRIES: CountryDef[] = [
|
||||
// Europe
|
||||
{ name: "Luxembourg", continent: "Europe", latMin: 49.4, latMax: 50.2, lngMin: 5.7, lngMax: 6.5, area: 1 },
|
||||
{ name: "Slovenia", continent: "Europe", latMin: 45.4, latMax: 46.9, lngMin: 13.4, lngMax: 16.6, area: 5 },
|
||||
{ name: "Belgium", continent: "Europe", latMin: 49.5, latMax: 51.5, lngMin: 2.5, lngMax: 6.4, area: 8 },
|
||||
{ name: "Netherlands", continent: "Europe", latMin: 50.7, latMax: 53.6, lngMin: 3.4, lngMax: 7.2, area: 11 },
|
||||
{ name: "Switzerland", continent: "Europe", latMin: 45.8, latMax: 47.8, lngMin: 5.9, lngMax: 10.5, area: 9 },
|
||||
{ name: "Denmark", continent: "Europe", latMin: 54.5, latMax: 57.8, lngMin: 8.0, lngMax: 15.2, area: 24 },
|
||||
{ name: "Austria", continent: "Europe", latMin: 46.4, latMax: 49.0, lngMin: 9.5, lngMax: 17.2, area: 20 },
|
||||
{ name: "Czech Republic",continent: "Europe", latMin: 48.5, latMax: 51.1, lngMin: 12.1, lngMax: 18.9, area: 18 },
|
||||
{ name: "Ireland", continent: "Europe", latMin: 51.4, latMax: 55.4, lngMin: -10.5, lngMax: -5.5, area: 20 },
|
||||
{ name: "Portugal", continent: "Europe", latMin: 36.9, latMax: 42.2, lngMin: -9.5, lngMax: -6.2, area: 18 },
|
||||
{ name: "Hungary", continent: "Europe", latMin: 45.7, latMax: 48.6, lngMin: 16.1, lngMax: 22.9, area: 20 },
|
||||
{ name: "Croatia", continent: "Europe", latMin: 42.4, latMax: 46.6, lngMin: 13.5, lngMax: 19.4, area: 25 },
|
||||
{ name: "Greece", continent: "Europe", latMin: 34.8, latMax: 41.8, lngMin: 19.3, lngMax: 29.6, area: 72 },
|
||||
{ name: "Italy", continent: "Europe", latMin: 36.6, latMax: 47.1, lngMin: 6.6, lngMax: 18.5, area: 125 },
|
||||
{ name: "United Kingdom",continent: "Europe", latMin: 49.9, latMax: 58.7, lngMin: -8.2, lngMax: 1.8, area: 88 },
|
||||
{ name: "Romania", continent: "Europe", latMin: 43.6, latMax: 48.3, lngMin: 20.3, lngMax: 29.7, area: 44 },
|
||||
{ name: "Poland", continent: "Europe", latMin: 49.0, latMax: 54.8, lngMin: 14.1, lngMax: 24.2, area: 59 },
|
||||
{ name: "Germany", continent: "Europe", latMin: 47.3, latMax: 55.1, lngMin: 5.9, lngMax: 15.0, area: 71 },
|
||||
{ name: "Finland", continent: "Europe", latMin: 59.8, latMax: 70.1, lngMin: 20.6, lngMax: 31.6, area: 113 },
|
||||
{ name: "Norway", continent: "Europe", latMin: 57.9, latMax: 71.2, lngMin: 4.6, lngMax: 31.1, area: 353 },
|
||||
{ name: "Sweden", continent: "Europe", latMin: 55.3, latMax: 69.1, lngMin: 11.1, lngMax: 24.2, area: 181 },
|
||||
{ name: "France", continent: "Europe", latMin: 41.3, latMax: 51.1, lngMin: -5.1, lngMax: 9.6, area: 144 },
|
||||
{ name: "Spain", continent: "Europe", latMin: 36.0, latMax: 43.8, lngMin: -9.3, lngMax: 4.3, area: 106 },
|
||||
{ name: "Ukraine", continent: "Europe", latMin: 44.4, latMax: 52.4, lngMin: 22.1, lngMax: 40.2, area: 145 },
|
||||
{ name: "Turkey", continent: "Europe", latMin: 36.0, latMax: 42.1, lngMin: 26.0, lngMax: 44.8, area: 115 },
|
||||
// Asia
|
||||
{ name: "Israel", continent: "Asia", latMin: 29.5, latMax: 33.3, lngMin: 34.2, lngMax: 35.9, area: 6 },
|
||||
{ name: "South Korea", continent: "Asia", latMin: 33.1, latMax: 38.6, lngMin: 124.6, lngMax: 131.9, area: 40 },
|
||||
{ name: "Japan", continent: "Asia", latMin: 24.0, latMax: 45.5, lngMin: 123.0, lngMax: 146.0, area: 495 },
|
||||
{ name: "Thailand", continent: "Asia", latMin: 5.6, latMax: 20.5, lngMin: 97.3, lngMax: 105.6, area: 124 },
|
||||
{ name: "Vietnam", continent: "Asia", latMin: 8.4, latMax: 23.4, lngMin: 102.1, lngMax: 109.5, area: 111 },
|
||||
{ name: "Indonesia", continent: "Asia", latMin: -11.0, latMax: 6.0, lngMin: 95.0, lngMax: 141.0, area: 782 },
|
||||
{ name: "India", continent: "Asia", latMin: 6.7, latMax: 35.5, lngMin: 68.2, lngMax: 97.4, area: 841 },
|
||||
{ name: "China", continent: "Asia", latMin: 18.2, latMax: 53.6, lngMin: 73.5, lngMax: 134.8, area: 2172 },
|
||||
// Africa
|
||||
{ name: "Tunisia", continent: "Africa", latMin: 30.2, latMax: 37.3, lngMin: 7.5, lngMax: 11.6, area: 29 },
|
||||
{ name: "Morocco", continent: "Africa", latMin: 27.7, latMax: 35.9, lngMin: -13.2, lngMax: -1.0, area: 100 },
|
||||
{ name: "Kenya", continent: "Africa", latMin: -4.7, latMax: 5.0, lngMin: 33.9, lngMax: 41.9, area: 78 },
|
||||
{ name: "South Africa", continent: "Africa", latMin: -34.8, latMax: -22.1, lngMin: 16.5, lngMax: 32.9, area: 209 },
|
||||
{ name: "Nigeria", continent: "Africa", latMin: 4.3, latMax: 13.9, lngMin: 2.7, lngMax: 14.7, area: 115 },
|
||||
{ name: "Egypt", continent: "Africa", latMin: 22.0, latMax: 31.7, lngMin: 24.7, lngMax: 36.9, area: 118 },
|
||||
{ name: "Ethiopia", continent: "Africa", latMin: 3.4, latMax: 14.9, lngMin: 33.0, lngMax: 48.0, area: 173 },
|
||||
// North America
|
||||
{ name: "Costa Rica", continent: "North America", latMin: 8.0, latMax: 11.2, lngMin: -85.9, lngMax: -82.6, area: 11 },
|
||||
{ name: "Mexico", continent: "North America", latMin: 14.5, latMax: 32.7, lngMin: -118.4, lngMax: -86.7, area: 577 },
|
||||
{ name: "United States", continent: "North America", latMin: 24.5, latMax: 49.4, lngMin: -125.0, lngMax: -66.9, area: 1449 },
|
||||
{ name: "Canada", continent: "North America", latMin: 41.7, latMax: 83.1, lngMin: -141.0, lngMax: -52.6, area: 3652 },
|
||||
// South America
|
||||
{ name: "Colombia", continent: "South America", latMin: -4.2, latMax: 12.5, lngMin: -79.0, lngMax: -66.9, area: 202 },
|
||||
{ name: "Argentina", continent: "South America", latMin: -55.1, latMax: -21.8, lngMin: -73.6, lngMax: -53.6, area: 666 },
|
||||
{ name: "Brazil", continent: "South America", latMin: -33.8, latMax: 5.3, lngMin: -73.9, lngMax: -34.8, area: 1528 },
|
||||
// Oceania
|
||||
{ name: "New Zealand", continent: "Oceania", latMin: -47.3, latMax: -34.4, lngMin: 166.4, lngMax: 178.6, area: 157 },
|
||||
{ name: "Australia", continent: "Oceania", latMin: -43.6, latMax: -10.7, lngMin: 113.2, lngMax: 153.6, area: 1331 },
|
||||
].sort((a, b) => a.area - b.area); // smallest first for specificity
|
||||
|
||||
// ── Lookup functions ──
|
||||
|
||||
function inBox(lat: number, lng: number, box: BoundingBox): boolean {
|
||||
return lat >= box.latMin && lat <= box.latMax && lng >= box.lngMin && lng <= box.lngMax;
|
||||
}
|
||||
|
||||
export function lookupContinent(lat: number, lng: number): string | null {
|
||||
for (const c of CONTINENTS) {
|
||||
if (inBox(lat, lng, c)) return c.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function lookupCountry(lat: number, lng: number): string | null {
|
||||
// Countries sorted by area ascending → smallest match first (most specific)
|
||||
for (const c of COUNTRIES) {
|
||||
if (inBox(lat, lng, c)) return c.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a geographic breadcrumb string from coordinates.
|
||||
* Returns e.g. "Earth > Europe > Germany > Berlin"
|
||||
*
|
||||
* @param lat Latitude
|
||||
* @param lng Longitude
|
||||
* @param locationName Optional city/venue name to append as most specific level
|
||||
*/
|
||||
export function generateBreadcrumb(lat: number, lng: number, locationName?: string | null): string {
|
||||
const parts = ["Earth"];
|
||||
const continent = lookupContinent(lat, lng);
|
||||
if (continent) parts.push(continent);
|
||||
const country = lookupCountry(lat, lng);
|
||||
if (country) parts.push(country);
|
||||
if (locationName) {
|
||||
// Strip common venue prefixes to get just the city/place
|
||||
// e.g. "Factory Berlin" → "Berlin", "KICC, Nairobi" → "Nairobi"
|
||||
const city = locationName.includes(",")
|
||||
? locationName.split(",").pop()!.trim()
|
||||
: locationName;
|
||||
// Avoid duplicating country in breadcrumb
|
||||
if (city && city !== country) {
|
||||
parts.push(city);
|
||||
}
|
||||
}
|
||||
return parts.join(" > ");
|
||||
}
|
||||
|
|
@ -70,7 +70,7 @@ export class CalLocalFirstClient {
|
|||
const docId = calendarDocId(this.#space) as DocumentId;
|
||||
this.#sync.change<CalendarDoc>(docId, `Update event ${eventId}`, (d) => {
|
||||
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, 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, 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 {
|
||||
Object.assign(d.events[eventId], changes);
|
||||
d.events[eventId].updatedAt = Date.now();
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ function eventToRow(ev: CalendarEvent, sources: Record<string, CalendarSource>)
|
|||
location_lat: ev.locationLat,
|
||||
location_lng: ev.locationLng,
|
||||
location_granularity: ev.locationGranularity,
|
||||
location_breadcrumb: ev.locationBreadcrumb,
|
||||
is_virtual: ev.isVirtual,
|
||||
virtual_url: ev.virtualUrl,
|
||||
virtual_platform: ev.virtualPlatform,
|
||||
|
|
@ -254,6 +255,7 @@ function seedDemoIfEmpty(space: string) {
|
|||
locationGranularity: e.locationGranularity || null,
|
||||
locationLat: e.locationLat ?? null,
|
||||
locationLng: e.locationLng ?? null,
|
||||
locationBreadcrumb: null,
|
||||
isVirtual: e.isVirtual || false,
|
||||
virtualUrl: e.virtualUrl || null,
|
||||
virtualPlatform: e.virtualPlatform || null,
|
||||
|
|
@ -410,6 +412,7 @@ routes.post("/api/events", async (c) => {
|
|||
locationGranularity: null,
|
||||
locationLat: null,
|
||||
locationLng: null,
|
||||
locationBreadcrumb: null,
|
||||
isVirtual: is_virtual || false,
|
||||
virtualUrl: virtual_url || null,
|
||||
virtualPlatform: virtual_platform || null,
|
||||
|
|
@ -540,6 +543,7 @@ routes.post("/api/import-ics", async (c) => {
|
|||
locationGranularity: null,
|
||||
locationLat: null,
|
||||
locationLng: null,
|
||||
locationBreadcrumb: null,
|
||||
isVirtual: false,
|
||||
virtualUrl: null,
|
||||
virtualPlatform: null,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export interface CalendarEvent {
|
|||
virtualPlatform: string | null;
|
||||
rToolSource: string | null;
|
||||
rToolEntityId: string | null;
|
||||
locationBreadcrumb: string | null; // "Earth > Europe > Germany > Berlin"
|
||||
attendees: unknown[];
|
||||
attendeeCount: number;
|
||||
tags: string[] | null;
|
||||
|
|
|
|||
|
|
@ -259,6 +259,7 @@ async function executeCalendarEvent(
|
|||
locationGranularity: null,
|
||||
locationLat: null,
|
||||
locationLng: null,
|
||||
locationBreadcrumb: null,
|
||||
isVirtual: false,
|
||||
virtualUrl: null,
|
||||
virtualPlatform: null,
|
||||
|
|
@ -1085,6 +1086,7 @@ function syncReminderToCalendar(reminder: Reminder, space: string): string | nul
|
|||
locationGranularity: null,
|
||||
locationLat: null,
|
||||
locationLng: null,
|
||||
locationBreadcrumb: null,
|
||||
isVirtual: false,
|
||||
virtualUrl: null,
|
||||
virtualPlatform: null,
|
||||
|
|
@ -1695,6 +1697,7 @@ async function executeWorkflowNode(
|
|||
locationGranularity: null,
|
||||
locationLat: null,
|
||||
locationLng: null,
|
||||
locationBreadcrumb: null,
|
||||
isVirtual: false,
|
||||
virtualUrl: null,
|
||||
virtualPlatform: null,
|
||||
|
|
|
|||
Loading…
Reference in New Issue