Merge branch 'dev'
This commit is contained in:
commit
e1af7c785b
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 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");
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
Loading…
Reference in New Issue