feat(rmaps): add collaborative room sync to canvas folk-map shape

Integrate RoomSync, participant markers, location sharing, waypoints,
emoji avatars, and participant panel into the canvas map shape. Users
can now create collaborative map rooms directly from the canvas toolbar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-12 22:42:34 -07:00
parent 1b2842fc4a
commit 40be1c63b3
3 changed files with 1071 additions and 26 deletions

File diff suppressed because it is too large Load Diff

View File

@ -55,7 +55,8 @@ const LIGHT_STYLE = {
};
const PARTICIPANT_COLORS = ["#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#8b5cf6", "#ec4899", "#14b8a6", "#f97316"];
const EMOJIS = ["\u{1F9ED}", "\u{1F30D}", "\u{1F680}", "\u{1F308}", "\u{2B50}", "\u{1F525}", "\u{1F33F}", "\u{1F30A}", "\u{26A1}", "\u{1F48E}"];
const EMOJIS = ["\u{1F600}", "\u{1F60E}", "\u{1F913}", "\u{1F973}", "\u{1F98A}", "\u{1F431}", "\u{1F436}", "\u{1F984}", "\u{1F31F}", "\u{1F525}", "\u{1F49C}", "\u{1F3AE}"];
const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
class FolkMapViewer extends HTMLElement {
private shadow: ShadowRoot;
@ -104,6 +105,8 @@ class FolkMapViewer extends HTMLElement {
private showShareModal = false;
private showMeetingModal = false;
private showImportModal = false;
private showEmojiPicker = false;
private stalenessTimer: ReturnType<typeof setInterval> | null = null;
private selectedParticipant: string | null = null;
private selectedWaypoint: string | null = null;
private activeRoute: { segments: any[]; totalDistance: number; estimatedTime: number; destination: string } | null = null;
@ -163,6 +166,7 @@ class FolkMapViewer extends HTMLElement {
this._themeObserver.disconnect();
this._themeObserver = null;
}
if (this.stalenessTimer) { clearInterval(this.stalenessTimer); this.stalenessTimer = null; }
}
// ─── User profile ────────────────────────────────────────────
@ -183,6 +187,37 @@ class FolkMapViewer extends HTMLElement {
this.userColor = PARTICIPANT_COLORS[Math.floor(Math.random() * PARTICIPANT_COLORS.length)];
}
private changeEmoji(emoji: string) {
this.userEmoji = emoji;
localStorage.setItem("rmaps_user", JSON.stringify({
id: this.participantId,
name: this.userName,
emoji: this.userEmoji,
color: this.userColor,
}));
// Update sync state so other participants see the change
if (this.sync) {
const state = this.sync.getState();
const me = state.participants[this.participantId];
if (me) {
me.emoji = emoji;
// Re-join with updated emoji
this.sync.join({ ...me, emoji });
}
}
// Update own marker if present
const myMarker = this.participantMarkers.get(this.participantId);
if (myMarker) {
const el = myMarker.getElement?.();
if (el) {
const emojiSpan = el.querySelector(".marker-emoji");
if (emojiSpan) emojiSpan.textContent = emoji;
}
}
this.showEmojiPicker = false;
this.updateEmojiButton();
}
private ensureUserProfile(): boolean {
if (this.userName) return true;
// Use EncryptID username if authenticated
@ -977,6 +1012,8 @@ class FolkMapViewer extends HTMLElement {
this.render();
this.initMapView();
this.initRoomSync();
// Periodically refresh staleness indicators
this.stalenessTimer = setInterval(() => this.refreshStaleness(), 15000);
}
private createRoom() {
@ -1011,6 +1048,7 @@ class FolkMapViewer extends HTMLElement {
this._themeObserver = null;
}
if (this.thumbnailTimer) clearTimeout(this.thumbnailTimer);
if (this.stalenessTimer) { clearInterval(this.stalenessTimer); this.stalenessTimer = null; }
}
// ─── MapLibre GL ─────────────────────────────────────────────
@ -1155,8 +1193,37 @@ class FolkMapViewer extends HTMLElement {
currentIds.add(id);
if (p.location) {
const lngLat: [number, number] = [p.location.longitude, p.location.latitude];
const ageMs = Date.now() - new Date(p.location.timestamp).getTime();
const isStale = ageMs > STALE_THRESHOLD_MS;
if (this.participantMarkers.has(id)) {
this.participantMarkers.get(id).setLngLat(lngLat);
const marker = this.participantMarkers.get(id);
marker.setLngLat(lngLat);
// Update staleness + heading on existing marker
const el = marker.getElement?.();
if (el) {
el.style.opacity = isStale ? "0.5" : "1";
if (isStale) {
el.style.borderColor = "#6b7280";
} else {
el.style.borderColor = p.color;
}
// Update heading arrow
const arrow = el.querySelector(".heading-arrow") as HTMLElement | null;
if (p.location.heading !== undefined && p.location.heading !== null) {
if (arrow) {
arrow.style.transform = `translateX(-50%) rotate(${p.location.heading}deg)`;
arrow.style.display = "block";
arrow.style.borderBottomColor = isStale ? "#6b7280" : p.color;
}
} else if (arrow) {
arrow.style.display = "none";
}
// Update tooltip with age
const ageSec = Math.floor(ageMs / 1000);
const ageLabel = ageSec < 60 ? `${ageSec}s ago` : ageSec < 3600 ? `${Math.floor(ageSec / 60)}m ago` : "stale";
el.title = `${p.name} - ${p.status} (${ageLabel})`;
}
} else {
const dark = this.isDarkTheme();
const markerBg = dark ? '#1a1a2e' : '#fafaf7';
@ -1165,13 +1232,38 @@ class FolkMapViewer extends HTMLElement {
el.className = "participant-marker";
el.style.cssText = `
width: 36px; height: 36px; border-radius: 50%;
border: 3px solid ${p.color}; background: ${markerBg};
border: 3px solid ${isStale ? "#6b7280" : p.color}; background: ${markerBg};
display: flex; align-items: center; justify-content: center;
font-size: 18px; cursor: pointer; position: relative;
box-shadow: 0 0 8px ${p.color}60;
opacity: ${isStale ? "0.5" : "1"};
transition: opacity 0.3s;
`;
el.textContent = p.emoji;
el.title = p.name;
// Emoji span with class for later updates
const emojiSpan = document.createElement("span");
emojiSpan.className = "marker-emoji";
emojiSpan.textContent = p.emoji;
el.appendChild(emojiSpan);
// Staleness tooltip
const ageSec = Math.floor(ageMs / 1000);
const ageLabel = ageSec < 60 ? `${ageSec}s ago` : ageSec < 3600 ? `${Math.floor(ageSec / 60)}m ago` : "stale";
el.title = `${p.name} - ${p.status} (${ageLabel})`;
// Heading arrow (CSS triangle)
const arrow = document.createElement("div");
arrow.className = "heading-arrow";
arrow.style.cssText = `
position: absolute; top: -6px; left: 50%;
width: 0; height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-bottom: 8px solid ${p.color};
transform: translateX(-50%)${p.location.heading !== undefined ? ` rotate(${p.location.heading}deg)` : ""};
display: ${p.location.heading !== undefined ? "block" : "none"};
`;
el.appendChild(arrow);
// Name label below
const label = document.createElement("div");
@ -1266,16 +1358,25 @@ class FolkMapViewer extends HTMLElement {
distLabel = formatDistance(haversineDistance(myLoc.latitude, myLoc.longitude, p.location.latitude, p.location.longitude));
}
const statusColor = statusColors[p.status] || "#64748b";
// Staleness info
let ageLabel = "";
let isStale = false;
if (p.location) {
const ageMs = Date.now() - new Date(p.location.timestamp).getTime();
isStale = ageMs > STALE_THRESHOLD_MS;
const ageSec = Math.floor(ageMs / 1000);
ageLabel = ageSec < 60 ? `${ageSec}s ago` : ageSec < 3600 ? `${Math.floor(ageSec / 60)}m ago` : "stale";
}
return `
<div class="participant-entry" style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--rs-border);">
<div class="participant-entry" style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--rs-border);${isStale ? "opacity:0.6;" : ""}">
<div style="position:relative;">
<span style="font-size:18px">${this.esc(p.emoji)}</span>
<span style="position:absolute;bottom:-2px;right:-2px;width:8px;height:8px;border-radius:50%;background:${statusColor};border:2px solid var(--rs-bg-surface);"></span>
<span style="position:absolute;bottom:-2px;right:-2px;width:8px;height:8px;border-radius:50%;background:${isStale ? "#6b7280" : statusColor};border:2px solid var(--rs-bg-surface);"></span>
</div>
<div style="flex:1;min-width:0;">
<div style="font-size:13px;font-weight:600;color:${p.color};overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${this.esc(p.name)}</div>
<div style="font-size:13px;font-weight:600;color:${isStale ? "#6b7280" : p.color};overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${this.esc(p.name)}</div>
<div style="font-size:10px;color:var(--rs-text-muted);">
${p.status === "ghost" ? "ghost mode" : p.location ? "sharing" : "no location"}
${p.status === "ghost" ? "ghost mode" : p.location ? (isStale ? ageLabel : "sharing") : "no location"}
${distLabel ? ` \u2022 ${distLabel}` : ""}
</div>
</div>
@ -1331,6 +1432,35 @@ class FolkMapViewer extends HTMLElement {
}
}
/** Periodically refresh staleness visuals on all participant markers */
private refreshStaleness() {
const state = this.sync?.getState();
if (!state) return;
for (const [id, p] of Object.entries(state.participants)) {
if (!p.location) continue;
const marker = this.participantMarkers.get(id);
if (!marker) continue;
const el = marker.getElement?.();
if (!el) continue;
const ageMs = Date.now() - new Date(p.location.timestamp).getTime();
const isStale = ageMs > STALE_THRESHOLD_MS;
el.style.opacity = isStale ? "0.5" : "1";
el.style.borderColor = isStale ? "#6b7280" : p.color;
const ageSec = Math.floor(ageMs / 1000);
const ageLabel = ageSec < 60 ? `${ageSec}s ago` : ageSec < 3600 ? `${Math.floor(ageSec / 60)}m ago` : "stale";
el.title = `${p.name} - ${p.status} (${ageLabel})`;
const arrow = el.querySelector(".heading-arrow") as HTMLElement | null;
if (arrow) arrow.style.borderBottomColor = isStale ? "#6b7280" : p.color;
}
}
private updateEmojiButton() {
const btn = this.shadow.getElementById("emoji-picker-btn");
if (btn) btn.textContent = this.userEmoji;
const picker = this.shadow.getElementById("emoji-picker-dropdown");
if (picker) picker.style.display = this.showEmojiPicker ? "flex" : "none";
}
private applyDarkFilter() {
const container = this.shadow.getElementById("map-container");
if (!container) return;
@ -2057,6 +2187,12 @@ class FolkMapViewer extends HTMLElement {
</div>
<div class="controls">
<div style="position:relative;">
<button class="ctrl-btn" id="emoji-picker-btn" title="Change your emoji avatar" style="font-size:18px;padding:4px 10px;">${this.userEmoji}</button>
<div id="emoji-picker-dropdown" style="display:none;position:absolute;bottom:100%;left:0;margin-bottom:6px;background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);border-radius:10px;padding:8px;gap:4px;flex-wrap:wrap;width:180px;box-shadow:0 4px 16px rgba(0,0,0,0.3);z-index:10;">
${EMOJIS.map(e => `<button class="emoji-opt" data-emoji-pick="${e}" style="width:32px;height:32px;border-radius:6px;border:1px solid ${e === this.userEmoji ? "#4f46e5" : "transparent"};background:${e === this.userEmoji ? "#4f46e520" : "none"};font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;">${e}</button>`).join("")}
</div>
</div>
<button class="ctrl-btn ${this.sharingLocation ? "sharing" : ""} ${this.privacySettings.ghostMode ? "ghost" : ""}" id="share-location">${this.privacySettings.ghostMode ? "\u{1F47B} Ghost Mode" : this.sharingLocation ? "\u{1F4CD} Stop Sharing" : "\u{1F4CD} Share Location"}</button>
<button class="ctrl-btn" id="privacy-toggle">\u{1F6E1} Privacy</button>
<button class="ctrl-btn" id="drop-waypoint">\u{1F4CC} Drop Pin</button>
@ -2108,6 +2244,17 @@ class FolkMapViewer extends HTMLElement {
}
});
// Emoji picker
this.shadow.getElementById("emoji-picker-btn")?.addEventListener("click", () => {
this.showEmojiPicker = !this.showEmojiPicker;
this.updateEmojiButton();
});
this.shadow.querySelectorAll("[data-emoji-pick]").forEach((btn) => {
btn.addEventListener("click", () => {
this.changeEmoji((btn as HTMLElement).dataset.emojiPick!);
});
});
this.shadow.getElementById("bell-toggle")?.addEventListener("click", () => {
this.pushManager?.toggle(this.room, this.participantId).then(subscribed => {
const bell = this.shadow.getElementById("bell-toggle");

View File

@ -4124,7 +4124,12 @@
document.getElementById("new-image").addEventListener("click", () => setPendingTool("folk-image"));
document.getElementById("new-bookmark").addEventListener("click", () => setPendingTool("folk-bookmark"));
document.getElementById("new-calendar").addEventListener("click", () => setPendingTool("folk-calendar"));
document.getElementById("new-map").addEventListener("click", () => setPendingTool("folk-map"));
document.getElementById("new-map").addEventListener("click", () => {
const roomSlug = prompt("Map room name (leave blank for solo map):");
if (roomSlug === null) return;
const slug = roomSlug.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-") || "";
setPendingTool("folk-map", slug ? { roomSlug: slug } : {});
});
document.getElementById("new-holon").addEventListener("click", () => setPendingTool("folk-holon"));
document.getElementById("new-holon-browser").addEventListener("click", () => setPendingTool("folk-holon-browser"));
document.getElementById("new-image-gen").addEventListener("click", () => setPendingTool("folk-image-gen"));