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 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