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:
parent
1b2842fc4a
commit
40be1c63b3
925
lib/folk-map.ts
925
lib/folk-map.ts
File diff suppressed because it is too large
Load Diff
|
|
@ -55,7 +55,8 @@ const LIGHT_STYLE = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const PARTICIPANT_COLORS = ["#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#8b5cf6", "#ec4899", "#14b8a6", "#f97316"];
|
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 {
|
class FolkMapViewer extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
|
|
@ -104,6 +105,8 @@ class FolkMapViewer extends HTMLElement {
|
||||||
private showShareModal = false;
|
private showShareModal = false;
|
||||||
private showMeetingModal = false;
|
private showMeetingModal = false;
|
||||||
private showImportModal = false;
|
private showImportModal = false;
|
||||||
|
private showEmojiPicker = false;
|
||||||
|
private stalenessTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
private selectedParticipant: string | null = null;
|
private selectedParticipant: string | null = null;
|
||||||
private selectedWaypoint: string | null = null;
|
private selectedWaypoint: string | null = null;
|
||||||
private activeRoute: { segments: any[]; totalDistance: number; estimatedTime: number; destination: 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.disconnect();
|
||||||
this._themeObserver = null;
|
this._themeObserver = null;
|
||||||
}
|
}
|
||||||
|
if (this.stalenessTimer) { clearInterval(this.stalenessTimer); this.stalenessTimer = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── User profile ────────────────────────────────────────────
|
// ─── User profile ────────────────────────────────────────────
|
||||||
|
|
@ -183,6 +187,37 @@ class FolkMapViewer extends HTMLElement {
|
||||||
this.userColor = PARTICIPANT_COLORS[Math.floor(Math.random() * PARTICIPANT_COLORS.length)];
|
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 {
|
private ensureUserProfile(): boolean {
|
||||||
if (this.userName) return true;
|
if (this.userName) return true;
|
||||||
// Use EncryptID username if authenticated
|
// Use EncryptID username if authenticated
|
||||||
|
|
@ -977,6 +1012,8 @@ class FolkMapViewer extends HTMLElement {
|
||||||
this.render();
|
this.render();
|
||||||
this.initMapView();
|
this.initMapView();
|
||||||
this.initRoomSync();
|
this.initRoomSync();
|
||||||
|
// Periodically refresh staleness indicators
|
||||||
|
this.stalenessTimer = setInterval(() => this.refreshStaleness(), 15000);
|
||||||
}
|
}
|
||||||
|
|
||||||
private createRoom() {
|
private createRoom() {
|
||||||
|
|
@ -1011,6 +1048,7 @@ class FolkMapViewer extends HTMLElement {
|
||||||
this._themeObserver = null;
|
this._themeObserver = null;
|
||||||
}
|
}
|
||||||
if (this.thumbnailTimer) clearTimeout(this.thumbnailTimer);
|
if (this.thumbnailTimer) clearTimeout(this.thumbnailTimer);
|
||||||
|
if (this.stalenessTimer) { clearInterval(this.stalenessTimer); this.stalenessTimer = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── MapLibre GL ─────────────────────────────────────────────
|
// ─── MapLibre GL ─────────────────────────────────────────────
|
||||||
|
|
@ -1155,8 +1193,37 @@ class FolkMapViewer extends HTMLElement {
|
||||||
currentIds.add(id);
|
currentIds.add(id);
|
||||||
if (p.location) {
|
if (p.location) {
|
||||||
const lngLat: [number, number] = [p.location.longitude, p.location.latitude];
|
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)) {
|
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 {
|
} else {
|
||||||
const dark = this.isDarkTheme();
|
const dark = this.isDarkTheme();
|
||||||
const markerBg = dark ? '#1a1a2e' : '#fafaf7';
|
const markerBg = dark ? '#1a1a2e' : '#fafaf7';
|
||||||
|
|
@ -1165,13 +1232,38 @@ class FolkMapViewer extends HTMLElement {
|
||||||
el.className = "participant-marker";
|
el.className = "participant-marker";
|
||||||
el.style.cssText = `
|
el.style.cssText = `
|
||||||
width: 36px; height: 36px; border-radius: 50%;
|
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;
|
display: flex; align-items: center; justify-content: center;
|
||||||
font-size: 18px; cursor: pointer; position: relative;
|
font-size: 18px; cursor: pointer; position: relative;
|
||||||
box-shadow: 0 0 8px ${p.color}60;
|
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
|
// Name label below
|
||||||
const label = document.createElement("div");
|
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));
|
distLabel = formatDistance(haversineDistance(myLoc.latitude, myLoc.longitude, p.location.latitude, p.location.longitude));
|
||||||
}
|
}
|
||||||
const statusColor = statusColors[p.status] || "#64748b";
|
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 `
|
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;">
|
<div style="position:relative;">
|
||||||
<span style="font-size:18px">${this.esc(p.emoji)}</span>
|
<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>
|
||||||
<div style="flex:1;min-width:0;">
|
<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);">
|
<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}` : ""}
|
${distLabel ? ` \u2022 ${distLabel}` : ""}
|
||||||
</div>
|
</div>
|
||||||
</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() {
|
private applyDarkFilter() {
|
||||||
const container = this.shadow.getElementById("map-container");
|
const container = this.shadow.getElementById("map-container");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
@ -2057,6 +2187,12 @@ class FolkMapViewer extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls">
|
<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 ${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="privacy-toggle">\u{1F6E1} Privacy</button>
|
||||||
<button class="ctrl-btn" id="drop-waypoint">\u{1F4CC} Drop Pin</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.shadow.getElementById("bell-toggle")?.addEventListener("click", () => {
|
||||||
this.pushManager?.toggle(this.room, this.participantId).then(subscribed => {
|
this.pushManager?.toggle(this.room, this.participantId).then(subscribed => {
|
||||||
const bell = this.shadow.getElementById("bell-toggle");
|
const bell = this.shadow.getElementById("bell-toggle");
|
||||||
|
|
|
||||||
|
|
@ -4124,7 +4124,12 @@
|
||||||
document.getElementById("new-image").addEventListener("click", () => setPendingTool("folk-image"));
|
document.getElementById("new-image").addEventListener("click", () => setPendingTool("folk-image"));
|
||||||
document.getElementById("new-bookmark").addEventListener("click", () => setPendingTool("folk-bookmark"));
|
document.getElementById("new-bookmark").addEventListener("click", () => setPendingTool("folk-bookmark"));
|
||||||
document.getElementById("new-calendar").addEventListener("click", () => setPendingTool("folk-calendar"));
|
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").addEventListener("click", () => setPendingTool("folk-holon"));
|
||||||
document.getElementById("new-holon-browser").addEventListener("click", () => setPendingTool("folk-holon-browser"));
|
document.getElementById("new-holon-browser").addEventListener("click", () => setPendingTool("folk-holon-browser"));
|
||||||
document.getElementById("new-image-gen").addEventListener("click", () => setPendingTool("folk-image-gen"));
|
document.getElementById("new-image-gen").addEventListener("click", () => setPendingTool("folk-image-gen"));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue