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:
parent
35c3a48296
commit
17a5922a44
|
|
@ -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">🔔</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">🔔</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; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue