feat(rcal): semantic zoom on overlapping day cell events

When 3+ events land on the same day in month view, individual event
labels collapse into spatial summary chips showing where the user
needs to be (e.g. "Berlin 3", "Amsterdam 2"). Chip granularity
auto-adapts: city → country → continent as location diversity grows.
Virtual events shown separately. ≤2 events still show full labels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-09 12:44:01 -04:00
parent 35c3a48296
commit 17a5922a44
1 changed files with 76 additions and 9 deletions

View File

@ -841,6 +841,75 @@ class FolkCalendarView extends HTMLElement {
return Array.from(labels);
}
/**
* Render day cell content individual event labels when few events,
* spatial summary chips when events start to overlap.
*/
private renderDayCellContent(cellEvents: any[]): string {
// Few events: show individual labels (original behavior)
if (cellEvents.length <= 2) {
return cellEvents.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>` : "";
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("");
}
// Many events: semantic zoom — show spatial summary chips instead
// Group by location: count events per city, then decide label granularity
const located = cellEvents.filter(e => e.location_breadcrumb);
const virtualCount = cellEvents.filter(e => e.is_virtual).length;
// Try city-level grouping first
const cityGroups = new Map<string, number>();
for (const e of located) {
const city = this.getSpatialLabel(e.location_breadcrumb, 5);
if (city) cityGroups.set(city, (cityGroups.get(city) || 0) + 1);
}
// If too many cities (5+), zoom out to country level
let groups: Map<string, number>;
let levelLabel: string;
if (cityGroups.size > 4) {
groups = new Map<string, number>();
for (const e of located) {
const country = this.getSpatialLabel(e.location_breadcrumb, 3);
if (country) groups.set(country, (groups.get(country) || 0) + 1);
}
levelLabel = "country";
// If still too many countries, zoom to continent
if (groups.size > 4) {
groups = new Map<string, number>();
for (const e of located) {
const continent = this.getSpatialLabel(e.location_breadcrumb, 1);
if (continent) groups.set(continent, (groups.get(continent) || 0) + 1);
}
levelLabel = "continent";
}
} else {
groups = cityGroups;
levelLabel = "city";
}
// Render spatial chips
let html = "";
const sorted = Array.from(groups.entries()).sort((a, b) => b[1] - a[1]);
for (const [label, count] of sorted.slice(0, 3)) {
html += `<div class="ev-spatial-chip" title="${count} event${count > 1 ? "s" : ""} in ${label}"><span class="ev-spatial-name">${this.esc(label)}</span><span class="ev-spatial-count">${count}</span></div>`;
}
if (sorted.length > 3) {
html += `<div class="ev-spatial-chip ev-spatial-more">+${sorted.length - 3}</div>`;
}
if (virtualCount > 0) {
html += `<div class="ev-spatial-chip ev-spatial-virtual" title="${virtualCount} virtual">\u{1F4BB} ${virtualCount}</div>`;
}
return html;
}
private toggleSource(name: string) {
if (this.filteredSources.has(name)) { this.filteredSources.delete(name); }
else { this.filteredSources.add(name); }
@ -1222,15 +1291,7 @@ class FolkCalendarView extends HTMLElement {
${dayReminders.slice(0, 3).map(r => `<span class="dot dot--reminder" style="background:${r.sourceColor || "#f59e0b"}" title="${this.esc(r.title)}"></span>`).join("")}
${cellEvents.length > 5 ? `<span style="font-size:8px;color:var(--rs-text-muted)">+${cellEvents.length - 5}</span>` : ""}
</div>
${cellEvents.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>` : "";
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("")}
${this.renderDayCellContent(cellEvents)}
` : ""}
</div>`;
@ -2960,6 +3021,11 @@ class FolkCalendarView extends HTMLElement {
.ev-time { color: var(--rs-text-muted); font-size: 8px; margin-right: 2px; }
.ev-bell { margin-right: 2px; font-size: 8px; }
.ev-loc { color: var(--rs-text-muted); font-size: 7px; margin-left: 3px; }
.ev-spatial-chip { display: flex; align-items: center; gap: 2px; font-size: 8px; color: var(--rs-text-secondary); background: var(--rs-bg-hover); border: 1px solid var(--rs-border); border-radius: 6px; padding: 1px 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.4; }
.ev-spatial-name { overflow: hidden; text-overflow: ellipsis; }
.ev-spatial-count { font-weight: 700; color: var(--rs-text-muted); font-size: 7px; flex-shrink: 0; }
.ev-spatial-more { color: var(--rs-text-muted); font-size: 7px; justify-content: center; }
.ev-spatial-virtual { color: var(--rs-text-muted); font-size: 7px; }
.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; }
@ -3250,6 +3316,7 @@ class FolkCalendarView extends HTMLElement {
.day { min-height: 0; padding: 4px; }
.day-num { font-size: 11px; }
.ev-label { display: none; }
.ev-spatial-chip { display: none; }
/* Dots → thin colored bars on mobile */
.dots { flex-direction: column; gap: 1px; margin-top: 2px; }
.dot { width: 100%; height: 2px; border-radius: 1px; margin: 0; }