1403 lines
36 KiB
TypeScript
1403 lines
36 KiB
TypeScript
import { FolkShape } from "./folk-shape";
|
||
import { css, html } from "./tags";
|
||
import type {
|
||
RoomState,
|
||
ParticipantState,
|
||
LocationState,
|
||
PrivacySettings,
|
||
PrecisionLevel,
|
||
WaypointState,
|
||
} from "../modules/rmaps/components/map-sync";
|
||
import { RoomSync } from "../modules/rmaps/components/map-sync";
|
||
import { fuzzLocation, haversineDistance, formatDistance } from "../modules/rmaps/components/map-privacy";
|
||
|
||
const MAPLIBRE_CSS = "https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.css";
|
||
const MAPLIBRE_JS = "https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.js";
|
||
|
||
// Default tile provider (OpenStreetMap)
|
||
const DEFAULT_STYLE = {
|
||
version: 8,
|
||
sources: {
|
||
osm: {
|
||
type: "raster",
|
||
tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
|
||
tileSize: 256,
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||
},
|
||
},
|
||
layers: [
|
||
{
|
||
id: "osm",
|
||
type: "raster",
|
||
source: "osm",
|
||
},
|
||
],
|
||
};
|
||
|
||
const EMOJI_OPTIONS = ["😎", "🧑", "👩", "🧔", "👨", "🐱", "🐶", "🦊", "🐻", "🐸", "🌟", "🔥"];
|
||
const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
||
|
||
const styles = css`
|
||
:host {
|
||
background: var(--rs-bg-surface, #fff);
|
||
color: var(--rs-text-primary, #1e293b);
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||
min-width: 400px;
|
||
min-height: 300px;
|
||
}
|
||
|
||
.header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 8px 12px;
|
||
background: #22c55e;
|
||
color: white;
|
||
border-radius: 8px 8px 0 0;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
cursor: move;
|
||
}
|
||
|
||
.header-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.header-actions button {
|
||
background: transparent;
|
||
border: none;
|
||
color: white;
|
||
cursor: pointer;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.header-actions button:hover {
|
||
background: rgba(255, 255, 255, 0.2);
|
||
}
|
||
|
||
.status-dot {
|
||
display: inline-block;
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
margin-left: 4px;
|
||
}
|
||
.status-dot.connected { background: #86efac; }
|
||
.status-dot.disconnected { background: #fbbf24; }
|
||
|
||
.map-container {
|
||
width: 100%;
|
||
height: calc(100% - 36px);
|
||
border-radius: 0 0 8px 8px;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
:host([data-has-collab]) .map-container {
|
||
height: calc(100% - 36px - 36px);
|
||
border-radius: 0;
|
||
}
|
||
|
||
.map {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.loading {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #f1f5f9;
|
||
color: #64748b;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.loading.hidden {
|
||
display: none;
|
||
}
|
||
|
||
.search-box {
|
||
position: absolute;
|
||
top: 10px;
|
||
left: 10px;
|
||
z-index: 10;
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.search-input {
|
||
padding: 8px 12px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||
font-size: 13px;
|
||
width: 200px;
|
||
outline: none;
|
||
}
|
||
|
||
.search-btn {
|
||
padding: 8px 12px;
|
||
background: #22c55e;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||
}
|
||
|
||
.search-btn:hover {
|
||
background: #16a34a;
|
||
}
|
||
|
||
.locate-btn {
|
||
position: absolute;
|
||
bottom: 80px;
|
||
right: 10px;
|
||
z-index: 10;
|
||
width: 32px;
|
||
height: 32px;
|
||
background: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||
font-size: 16px;
|
||
}
|
||
|
||
.locate-btn:hover {
|
||
background: #f1f5f9;
|
||
}
|
||
|
||
/* Override MapLibre default styles */
|
||
.maplibregl-ctrl-attrib {
|
||
font-size: 10px !important;
|
||
}
|
||
|
||
/* ── Collab toolbar ── */
|
||
.collab-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 8px;
|
||
background: #f8fafc;
|
||
border-top: 1px solid #e2e8f0;
|
||
border-radius: 0 0 8px 8px;
|
||
font-size: 12px;
|
||
position: relative;
|
||
}
|
||
|
||
.collab-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
padding: 4px 8px;
|
||
background: white;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.collab-btn:hover {
|
||
background: #f1f5f9;
|
||
}
|
||
|
||
.collab-btn.sharing {
|
||
background: #dcfce7;
|
||
border-color: #22c55e;
|
||
animation: pulse-green 2s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes pulse-green {
|
||
0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.3); }
|
||
50% { box-shadow: 0 0 0 4px rgba(34, 197, 94, 0); }
|
||
}
|
||
|
||
.collab-spacer {
|
||
flex: 1;
|
||
}
|
||
|
||
/* ── Emoji picker ── */
|
||
.emoji-picker {
|
||
position: absolute;
|
||
bottom: 40px;
|
||
left: 8px;
|
||
background: white;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 8px;
|
||
padding: 8px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||
z-index: 20;
|
||
display: none;
|
||
}
|
||
|
||
.emoji-picker.open {
|
||
display: block;
|
||
}
|
||
|
||
.emoji-picker-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 4px;
|
||
}
|
||
|
||
.emoji-opt {
|
||
width: 32px;
|
||
height: 32px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: 1px solid transparent;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 18px;
|
||
background: none;
|
||
padding: 0;
|
||
}
|
||
|
||
.emoji-opt:hover {
|
||
background: #f1f5f9;
|
||
border-color: #e2e8f0;
|
||
}
|
||
|
||
.emoji-opt.selected {
|
||
border-color: #22c55e;
|
||
background: #dcfce7;
|
||
}
|
||
|
||
/* ── Participant panel ── */
|
||
.participant-panel {
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
width: 200px;
|
||
height: 100%;
|
||
background: white;
|
||
border-left: 1px solid #e2e8f0;
|
||
z-index: 15;
|
||
overflow-y: auto;
|
||
display: none;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.participant-panel.open {
|
||
display: flex;
|
||
}
|
||
|
||
.participant-panel-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 8px 10px;
|
||
border-bottom: 1px solid #e2e8f0;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #475569;
|
||
}
|
||
|
||
.participant-panel-close {
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
color: #94a3b8;
|
||
padding: 0 2px;
|
||
}
|
||
|
||
.participant-entry {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 10px;
|
||
border-bottom: 1px solid #f1f5f9;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.participant-emoji {
|
||
font-size: 18px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.participant-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.participant-name {
|
||
font-weight: 500;
|
||
color: #1e293b;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.participant-meta {
|
||
color: #94a3b8;
|
||
font-size: 10px;
|
||
}
|
||
|
||
.participant-status-dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.participant-status-dot.online { background: #22c55e; }
|
||
.participant-status-dot.away { background: #fbbf24; }
|
||
.participant-status-dot.ghost { background: #94a3b8; }
|
||
.participant-status-dot.offline { background: #e2e8f0; }
|
||
|
||
/* ── Waypoint input ── */
|
||
.waypoint-input-wrap {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
z-index: 20;
|
||
background: white;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||
display: none;
|
||
}
|
||
|
||
.waypoint-input-wrap.open {
|
||
display: flex;
|
||
gap: 6px;
|
||
}
|
||
|
||
.waypoint-input {
|
||
padding: 6px 10px;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
outline: none;
|
||
width: 140px;
|
||
}
|
||
|
||
.waypoint-input:focus {
|
||
border-color: #22c55e;
|
||
}
|
||
|
||
.waypoint-submit {
|
||
padding: 6px 10px;
|
||
background: #22c55e;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.waypoint-cancel {
|
||
padding: 6px 10px;
|
||
background: #f1f5f9;
|
||
color: #64748b;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
}
|
||
`;
|
||
|
||
export interface MapMarker {
|
||
id: string;
|
||
lng: number;
|
||
lat: number;
|
||
color?: string;
|
||
label?: string;
|
||
}
|
||
|
||
// MapLibre types (loaded dynamically from CDN)
|
||
interface MapLibreMap {
|
||
flyTo(options: { center: [number, number] }): void;
|
||
setZoom(zoom: number): void;
|
||
getCenter(): { lng: number; lat: number };
|
||
getZoom(): number;
|
||
addControl(control: unknown, position?: string): void;
|
||
on(event: string, handler: (e: MapLibreEvent) => void): void;
|
||
}
|
||
|
||
interface MapLibreEvent {
|
||
lngLat: { lng: number; lat: number };
|
||
}
|
||
|
||
interface MapLibreMarker {
|
||
setLngLat(coords: [number, number]): this;
|
||
addTo(map: MapLibreMap): this;
|
||
setPopup(popup: unknown): this;
|
||
remove(): void;
|
||
getElement(): HTMLElement;
|
||
}
|
||
|
||
interface MapLibreGL {
|
||
Map: new (options: {
|
||
container: HTMLElement;
|
||
style: object;
|
||
center: [number, number];
|
||
zoom: number;
|
||
}) => MapLibreMap;
|
||
NavigationControl: new () => unknown;
|
||
Marker: new (options?: { element?: HTMLElement }) => MapLibreMarker;
|
||
Popup: new (options?: { offset?: number }) => { setText(text: string): unknown; setHTML(html: string): unknown };
|
||
}
|
||
|
||
declare global {
|
||
interface HTMLElementTagNameMap {
|
||
"folk-map": FolkMap;
|
||
}
|
||
interface Window {
|
||
maplibregl: MapLibreGL;
|
||
}
|
||
}
|
||
|
||
export class FolkMap extends FolkShape {
|
||
static override tagName = "folk-map";
|
||
|
||
static {
|
||
const sheet = new CSSStyleSheet();
|
||
const parentRules = Array.from(FolkShape.styles.cssRules)
|
||
.map((r) => r.cssText)
|
||
.join("\n");
|
||
const childRules = Array.from(styles.cssRules)
|
||
.map((r) => r.cssText)
|
||
.join("\n");
|
||
sheet.replaceSync(`${parentRules}\n${childRules}`);
|
||
this.styles = sheet;
|
||
}
|
||
|
||
// ── Existing solo-map state ──
|
||
#map: MapLibreMap | null = null;
|
||
#markers: MapMarker[] = [];
|
||
#mapMarkerInstances = new Map<string, MapLibreMarker>();
|
||
#center: [number, number] = [-74.006, 40.7128]; // NYC default
|
||
#zoom = 12;
|
||
#mapEl: HTMLElement | null = null;
|
||
#loadingEl: HTMLElement | null = null;
|
||
|
||
// ── Collab state ──
|
||
#roomSlug = "";
|
||
#sync: RoomSync | null = null;
|
||
#syncUrl = "";
|
||
#participantId = "";
|
||
#userName = "";
|
||
#userEmoji = "😎";
|
||
#userColor = "#22c55e";
|
||
#sharingLocation = false;
|
||
#watchId: number | null = null;
|
||
#privacySettings: PrivacySettings = { precision: "exact" as PrecisionLevel, ghostMode: false };
|
||
#participantMarkers = new Map<string, MapLibreMarker>();
|
||
#waypointMarkers = new Map<string, MapLibreMarker>();
|
||
#syncConnected = false;
|
||
#stalenessTimer: ReturnType<typeof setInterval> | null = null;
|
||
#showParticipants = false;
|
||
#showEmojiPicker = false;
|
||
|
||
// ── DOM refs for collab UI ──
|
||
#collabToolbar: HTMLElement | null = null;
|
||
#emojiPickerEl: HTMLElement | null = null;
|
||
#participantPanel: HTMLElement | null = null;
|
||
#participantList: HTMLElement | null = null;
|
||
#shareBtn: HTMLButtonElement | null = null;
|
||
#countBadge: HTMLElement | null = null;
|
||
#statusDot: HTMLElement | null = null;
|
||
#headerLabel: HTMLElement | null = null;
|
||
#waypointInputWrap: HTMLElement | null = null;
|
||
#emojiBtnEl: HTMLButtonElement | null = null;
|
||
|
||
get center(): [number, number] {
|
||
return this.#center;
|
||
}
|
||
|
||
set center(value: [number, number]) {
|
||
this.#center = value;
|
||
this.#map?.flyTo({ center: value });
|
||
}
|
||
|
||
get zoom(): number {
|
||
return this.#zoom;
|
||
}
|
||
|
||
set zoom(value: number) {
|
||
this.#zoom = value;
|
||
this.#map?.setZoom(value);
|
||
}
|
||
|
||
get markers(): MapMarker[] {
|
||
return this.#markers;
|
||
}
|
||
|
||
get roomSlug(): string {
|
||
return this.#roomSlug;
|
||
}
|
||
|
||
set roomSlug(value: string) {
|
||
const slug = (value || "").trim().toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
||
if (slug === this.#roomSlug) return;
|
||
if (this.#roomSlug) this.#leaveRoom();
|
||
this.#roomSlug = slug;
|
||
if (slug) {
|
||
this.setAttribute("data-has-collab", "");
|
||
if (this.#collabToolbar) this.#collabToolbar.style.display = "flex";
|
||
this.#initRoomSync();
|
||
} else {
|
||
this.removeAttribute("data-has-collab");
|
||
if (this.#collabToolbar) this.#collabToolbar.style.display = "none";
|
||
}
|
||
if (this.#headerLabel) {
|
||
this.#headerLabel.textContent = slug ? `Map — ${slug}` : "Map";
|
||
}
|
||
}
|
||
|
||
addMarker(marker: MapMarker) {
|
||
this.#markers.push(marker);
|
||
this.#renderMarker(marker);
|
||
this.dispatchEvent(new CustomEvent("marker-add", { detail: { marker } }));
|
||
}
|
||
|
||
removeMarker(id: string) {
|
||
const instance = this.#mapMarkerInstances.get(id);
|
||
if (instance) {
|
||
instance.remove();
|
||
this.#mapMarkerInstances.delete(id);
|
||
}
|
||
this.#markers = this.#markers.filter((m) => m.id !== id);
|
||
}
|
||
|
||
override createRenderRoot() {
|
||
const root = super.createRenderRoot();
|
||
|
||
// Parse initial attributes
|
||
const centerAttr = this.getAttribute("center");
|
||
if (centerAttr) {
|
||
const [lng, lat] = centerAttr.split(",").map(Number);
|
||
if (!isNaN(lng) && !isNaN(lat)) {
|
||
this.#center = [lng, lat];
|
||
}
|
||
}
|
||
const zoomAttr = this.getAttribute("zoom");
|
||
if (zoomAttr && !isNaN(Number(zoomAttr))) {
|
||
this.#zoom = Number(zoomAttr);
|
||
}
|
||
|
||
const wrapper = document.createElement("div");
|
||
wrapper.innerHTML = html`
|
||
<div class="header">
|
||
<span class="header-title">
|
||
<span>🗺</span>
|
||
<span class="header-label">Map</span>
|
||
<span class="status-dot disconnected" style="display:none"></span>
|
||
</span>
|
||
<div class="header-actions">
|
||
<button class="close-btn" title="Close">×</button>
|
||
</div>
|
||
</div>
|
||
<div class="map-container">
|
||
<div class="loading">Loading map...</div>
|
||
<div class="search-box">
|
||
<input type="text" class="search-input" placeholder="Search location..." />
|
||
<button class="search-btn">🔍</button>
|
||
</div>
|
||
<button class="locate-btn" title="My Location">📍</button>
|
||
<div class="map"></div>
|
||
<!-- Participant panel overlay -->
|
||
<div class="participant-panel">
|
||
<div class="participant-panel-header">
|
||
<span>Participants</span>
|
||
<button class="participant-panel-close">×</button>
|
||
</div>
|
||
<div class="participant-list"></div>
|
||
</div>
|
||
<!-- Waypoint name input -->
|
||
<div class="waypoint-input-wrap">
|
||
<input type="text" class="waypoint-input" placeholder="Pin name..." />
|
||
<button class="waypoint-submit">📌</button>
|
||
<button class="waypoint-cancel">×</button>
|
||
</div>
|
||
</div>
|
||
<!-- Collab toolbar (hidden until roomSlug set) -->
|
||
<div class="collab-toolbar" style="display:none">
|
||
<button class="collab-btn emoji-btn" title="Change avatar">😎</button>
|
||
<button class="collab-btn share-btn" title="Share location">📍 Share</button>
|
||
<button class="collab-btn pin-btn" title="Drop pin">📌 Pin</button>
|
||
<div class="collab-spacer"></div>
|
||
<button class="collab-btn count-btn" title="Participants">👥 <span class="count-badge">0</span></button>
|
||
</div>
|
||
<!-- Emoji picker dropdown -->
|
||
<div class="emoji-picker">
|
||
<div class="emoji-picker-grid">
|
||
${EMOJI_OPTIONS.map((e) => `<button class="emoji-opt" data-emoji="${e}">${e}</button>`).join("")}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Replace the container div (slot's parent) with our wrapper
|
||
const slot = root.querySelector("slot");
|
||
const containerDiv = slot?.parentElement as HTMLElement;
|
||
if (containerDiv) {
|
||
containerDiv.replaceWith(wrapper);
|
||
}
|
||
|
||
// ── Grab DOM refs ──
|
||
this.#mapEl = wrapper.querySelector(".map");
|
||
this.#loadingEl = wrapper.querySelector(".loading");
|
||
this.#headerLabel = wrapper.querySelector(".header-label");
|
||
this.#statusDot = wrapper.querySelector(".status-dot");
|
||
this.#collabToolbar = wrapper.querySelector(".collab-toolbar");
|
||
this.#emojiPickerEl = wrapper.querySelector(".emoji-picker");
|
||
this.#participantPanel = wrapper.querySelector(".participant-panel");
|
||
this.#participantList = wrapper.querySelector(".participant-list");
|
||
this.#waypointInputWrap = wrapper.querySelector(".waypoint-input-wrap");
|
||
|
||
const searchInput = wrapper.querySelector(".search-input") as HTMLInputElement;
|
||
const searchBtn = wrapper.querySelector(".search-btn") as HTMLButtonElement;
|
||
const locateBtn = wrapper.querySelector(".locate-btn") as HTMLButtonElement;
|
||
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
|
||
|
||
this.#shareBtn = wrapper.querySelector(".share-btn") as HTMLButtonElement;
|
||
this.#emojiBtnEl = wrapper.querySelector(".emoji-btn") as HTMLButtonElement;
|
||
this.#countBadge = wrapper.querySelector(".count-badge") as HTMLElement;
|
||
const pinBtn = wrapper.querySelector(".pin-btn") as HTMLButtonElement;
|
||
const countBtn = wrapper.querySelector(".count-btn") as HTMLButtonElement;
|
||
const panelCloseBtn = wrapper.querySelector(".participant-panel-close") as HTMLButtonElement;
|
||
const waypointInput = wrapper.querySelector(".waypoint-input") as HTMLInputElement;
|
||
const waypointSubmit = wrapper.querySelector(".waypoint-submit") as HTMLButtonElement;
|
||
const waypointCancel = wrapper.querySelector(".waypoint-cancel") as HTMLButtonElement;
|
||
|
||
// Load MapLibre and initialize map
|
||
this.#loadMapLibre().then(() => {
|
||
this.#initMap();
|
||
});
|
||
|
||
// ── Search handler ──
|
||
const handleSearch = async () => {
|
||
const query = searchInput.value.trim();
|
||
if (!query) return;
|
||
try {
|
||
const response = await fetch(
|
||
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}`
|
||
);
|
||
const results = await response.json();
|
||
if (results.length > 0) {
|
||
const { lon, lat } = results[0];
|
||
this.center = [parseFloat(lon), parseFloat(lat)];
|
||
this.zoom = 15;
|
||
}
|
||
} catch (error) {
|
||
console.error("Search error:", error);
|
||
}
|
||
};
|
||
|
||
searchBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
handleSearch();
|
||
});
|
||
|
||
searchInput.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
handleSearch();
|
||
}
|
||
});
|
||
|
||
// Prevent map interactions from triggering shape drag
|
||
searchInput.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||
|
||
// Location button (solo locate — pan to my location)
|
||
locateBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
if ("geolocation" in navigator) {
|
||
navigator.geolocation.getCurrentPosition(
|
||
(pos) => {
|
||
this.center = [pos.coords.longitude, pos.coords.latitude];
|
||
this.zoom = 15;
|
||
},
|
||
(err) => {
|
||
console.error("Geolocation error:", err);
|
||
}
|
||
);
|
||
}
|
||
});
|
||
|
||
// Close button
|
||
closeBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
this.dispatchEvent(new CustomEvent("close"));
|
||
});
|
||
|
||
// ── Collab button handlers ──
|
||
// Emoji button → toggle picker
|
||
this.#emojiBtnEl.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
this.#showEmojiPicker = !this.#showEmojiPicker;
|
||
this.#emojiPickerEl?.classList.toggle("open", this.#showEmojiPicker);
|
||
});
|
||
|
||
// Emoji option clicks
|
||
this.#emojiPickerEl?.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
const target = (e.target as HTMLElement).closest(".emoji-opt") as HTMLElement | null;
|
||
if (!target) return;
|
||
const emoji = target.dataset.emoji;
|
||
if (emoji) this.#changeEmoji(emoji);
|
||
});
|
||
|
||
// Share location toggle
|
||
this.#shareBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
this.#toggleLocationSharing();
|
||
});
|
||
|
||
// Drop waypoint
|
||
pinBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
this.#showWaypointInput();
|
||
});
|
||
|
||
// Waypoint input events
|
||
waypointInput.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||
waypointSubmit.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
this.#submitWaypoint(waypointInput.value.trim());
|
||
waypointInput.value = "";
|
||
});
|
||
waypointCancel.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
waypointInput.value = "";
|
||
this.#waypointInputWrap?.classList.remove("open");
|
||
});
|
||
waypointInput.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
this.#submitWaypoint(waypointInput.value.trim());
|
||
waypointInput.value = "";
|
||
} else if (e.key === "Escape") {
|
||
waypointInput.value = "";
|
||
this.#waypointInputWrap?.classList.remove("open");
|
||
}
|
||
});
|
||
|
||
// Participant count → toggle panel
|
||
countBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
this.#showParticipants = !this.#showParticipants;
|
||
this.#participantPanel?.classList.toggle("open", this.#showParticipants);
|
||
});
|
||
|
||
panelCloseBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
this.#showParticipants = false;
|
||
this.#participantPanel?.classList.remove("open");
|
||
});
|
||
|
||
// Stop propagation on collab toolbar
|
||
this.#collabToolbar?.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||
this.#emojiPickerEl?.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||
|
||
return root;
|
||
}
|
||
|
||
disconnectedCallback() {
|
||
super.disconnectedCallback?.();
|
||
this.#leaveRoom();
|
||
}
|
||
|
||
// ── MapLibre loading ──
|
||
|
||
async #loadMapLibre() {
|
||
if (window.maplibregl) return;
|
||
|
||
const link = document.createElement("link");
|
||
link.rel = "stylesheet";
|
||
link.href = MAPLIBRE_CSS;
|
||
document.head.appendChild(link);
|
||
|
||
return new Promise<void>((resolve, reject) => {
|
||
const script = document.createElement("script");
|
||
script.src = MAPLIBRE_JS;
|
||
script.onload = () => resolve();
|
||
script.onerror = reject;
|
||
document.head.appendChild(script);
|
||
});
|
||
}
|
||
|
||
#initMap() {
|
||
if (!this.#mapEl || !window.maplibregl) return;
|
||
|
||
this.#map = new window.maplibregl.Map({
|
||
container: this.#mapEl,
|
||
style: DEFAULT_STYLE,
|
||
center: this.#center,
|
||
zoom: this.#zoom,
|
||
});
|
||
|
||
this.#map.addControl(new window.maplibregl.NavigationControl(), "top-right");
|
||
|
||
this.#map.on("load", () => {
|
||
if (this.#loadingEl) {
|
||
this.#loadingEl.classList.add("hidden");
|
||
}
|
||
this.#markers.forEach((marker) => this.#renderMarker(marker));
|
||
});
|
||
|
||
this.#map.on("moveend", () => {
|
||
const center = this.#map!.getCenter();
|
||
this.#center = [center.lng, center.lat];
|
||
this.#zoom = this.#map!.getZoom();
|
||
this.dispatchEvent(
|
||
new CustomEvent("map-move", {
|
||
detail: { center: this.#center, zoom: this.#zoom },
|
||
})
|
||
);
|
||
});
|
||
|
||
// Click to add marker (solo mode only)
|
||
this.#map.on("click", (e) => {
|
||
if (this.#roomSlug) return; // In collab mode, don't add solo markers on click
|
||
const marker: MapMarker = {
|
||
id: crypto.randomUUID(),
|
||
lng: e.lngLat.lng,
|
||
lat: e.lngLat.lat,
|
||
color: "#22c55e",
|
||
};
|
||
this.addMarker(marker);
|
||
});
|
||
}
|
||
|
||
#renderMarker(marker: MapMarker) {
|
||
if (!this.#map || !window.maplibregl) return;
|
||
|
||
const el = document.createElement("div");
|
||
el.style.cssText = `
|
||
width: 24px;
|
||
height: 24px;
|
||
background: ${marker.color || "#22c55e"};
|
||
border: 2px solid white;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||
`;
|
||
|
||
const mapMarker = new window.maplibregl.Marker({ element: el })
|
||
.setLngLat([marker.lng, marker.lat])
|
||
.addTo(this.#map);
|
||
|
||
if (marker.label) {
|
||
mapMarker.setPopup(
|
||
new window.maplibregl.Popup({ offset: 25 }).setText(marker.label)
|
||
);
|
||
}
|
||
|
||
this.#mapMarkerInstances.set(marker.id, mapMarker);
|
||
}
|
||
|
||
// ── Room sync methods ──
|
||
|
||
#loadUserProfile() {
|
||
try {
|
||
const stored = localStorage.getItem("rmaps_user");
|
||
if (stored) {
|
||
const profile = JSON.parse(stored);
|
||
this.#userName = profile.name || "";
|
||
this.#userEmoji = profile.emoji || "😎";
|
||
this.#userColor = profile.color || "#22c55e";
|
||
if (profile.privacy) {
|
||
this.#privacySettings = profile.privacy;
|
||
}
|
||
}
|
||
} catch {}
|
||
// Generate participant ID if not set
|
||
if (!this.#participantId) {
|
||
this.#participantId = localStorage.getItem("rmaps_participant_id") || crypto.randomUUID();
|
||
localStorage.setItem("rmaps_participant_id", this.#participantId);
|
||
}
|
||
}
|
||
|
||
#saveUserProfile() {
|
||
try {
|
||
localStorage.setItem("rmaps_user", JSON.stringify({
|
||
name: this.#userName,
|
||
emoji: this.#userEmoji,
|
||
color: this.#userColor,
|
||
privacy: this.#privacySettings,
|
||
}));
|
||
} catch {}
|
||
}
|
||
|
||
#getApiBase(): string {
|
||
const match = window.location.pathname.match(/^\/([^/]+)/);
|
||
const spaceSlug = match ? match[1] : "default";
|
||
return `/${spaceSlug}/rmaps`;
|
||
}
|
||
|
||
async #initRoomSync() {
|
||
if (!this.#roomSlug) return;
|
||
|
||
this.#loadUserProfile();
|
||
|
||
// Prompt for name if not set
|
||
if (!this.#userName) {
|
||
this.#userName = prompt("Your name for this map room:") || "Anonymous";
|
||
this.#saveUserProfile();
|
||
}
|
||
|
||
// Fetch sync URL from API
|
||
try {
|
||
const apiBase = this.#getApiBase();
|
||
const resp = await fetch(`${apiBase}/api/sync-url`);
|
||
if (resp.ok) {
|
||
const data = await resp.json();
|
||
this.#syncUrl = data.url || "";
|
||
}
|
||
} catch {
|
||
console.warn("[folk-map] Could not fetch sync URL, running local-only");
|
||
}
|
||
|
||
// Create RoomSync
|
||
this.#sync = new RoomSync(
|
||
this.#roomSlug,
|
||
this.#participantId,
|
||
(state) => this.#onRoomStateChange(state),
|
||
(connected) => {
|
||
this.#syncConnected = connected;
|
||
if (this.#statusDot) {
|
||
this.#statusDot.style.display = "";
|
||
this.#statusDot.className = `status-dot ${connected ? "connected" : "disconnected"}`;
|
||
}
|
||
}
|
||
);
|
||
|
||
// Connect (falls back to local-only if no syncUrl)
|
||
this.#sync.connect(this.#syncUrl || undefined);
|
||
|
||
// Join with participant info
|
||
const participant: ParticipantState = {
|
||
id: this.#participantId,
|
||
name: this.#userName,
|
||
emoji: this.#userEmoji,
|
||
color: this.#userColor,
|
||
joinedAt: new Date().toISOString(),
|
||
lastSeen: new Date().toISOString(),
|
||
status: "online",
|
||
};
|
||
this.#sync.join(participant);
|
||
|
||
// Update emoji button
|
||
if (this.#emojiBtnEl) this.#emojiBtnEl.textContent = this.#userEmoji;
|
||
|
||
// Start staleness timer
|
||
this.#stalenessTimer = setInterval(() => this.#refreshStaleness(), 15000);
|
||
}
|
||
|
||
#leaveRoom() {
|
||
// Stop GPS watching
|
||
if (this.#watchId !== null) {
|
||
navigator.geolocation.clearWatch(this.#watchId);
|
||
this.#watchId = null;
|
||
}
|
||
this.#sharingLocation = false;
|
||
if (this.#shareBtn) this.#shareBtn.classList.remove("sharing");
|
||
|
||
// Leave sync
|
||
this.#sync?.leave();
|
||
this.#sync = null;
|
||
|
||
// Clear participant markers
|
||
for (const m of this.#participantMarkers.values()) m.remove();
|
||
this.#participantMarkers.clear();
|
||
|
||
// Clear waypoint markers
|
||
for (const m of this.#waypointMarkers.values()) m.remove();
|
||
this.#waypointMarkers.clear();
|
||
|
||
// Clear staleness timer
|
||
if (this.#stalenessTimer) {
|
||
clearInterval(this.#stalenessTimer);
|
||
this.#stalenessTimer = null;
|
||
}
|
||
|
||
// Reset status dot
|
||
if (this.#statusDot) this.#statusDot.style.display = "none";
|
||
|
||
// Close panels
|
||
this.#showParticipants = false;
|
||
this.#showEmojiPicker = false;
|
||
this.#participantPanel?.classList.remove("open");
|
||
this.#emojiPickerEl?.classList.remove("open");
|
||
}
|
||
|
||
#onRoomStateChange(state: RoomState) {
|
||
if (!this.#map || !window.maplibregl) return;
|
||
|
||
const participants = Object.values(state.participants);
|
||
const now = Date.now();
|
||
|
||
// ── Update/create participant markers ──
|
||
const activeIds = new Set<string>();
|
||
for (const p of participants) {
|
||
if (p.id === this.#participantId && !this.#sharingLocation) continue;
|
||
activeIds.add(p.id);
|
||
|
||
if (!p.location) {
|
||
// No location — remove marker if exists
|
||
const existing = this.#participantMarkers.get(p.id);
|
||
if (existing) {
|
||
existing.remove();
|
||
this.#participantMarkers.delete(p.id);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
const age = now - new Date(p.lastSeen).getTime();
|
||
const isStale = age > STALE_THRESHOLD_MS;
|
||
const opacity = isStale ? 0.4 : 1;
|
||
const ageText = isStale ? ` (${formatDistance(0)}${Math.floor(age / 60000)}m ago)` : "";
|
||
|
||
const existing = this.#participantMarkers.get(p.id);
|
||
if (existing) {
|
||
// Update position
|
||
existing.setLngLat([p.location.longitude, p.location.latitude]);
|
||
const el = existing.getElement();
|
||
el.style.opacity = String(opacity);
|
||
// Update tooltip
|
||
const nameLabel = el.querySelector(".p-label") as HTMLElement;
|
||
if (nameLabel) nameLabel.textContent = p.name + ageText;
|
||
// Update heading arrow
|
||
const arrow = el.querySelector(".heading-arrow") as HTMLElement;
|
||
if (arrow && p.location.heading != null) {
|
||
arrow.style.display = "";
|
||
arrow.style.transform = `rotate(${p.location.heading}deg)`;
|
||
} else if (arrow) {
|
||
arrow.style.display = "none";
|
||
}
|
||
} else {
|
||
// Create new participant marker
|
||
const el = document.createElement("div");
|
||
el.style.cssText = `
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
cursor: pointer;
|
||
opacity: ${opacity};
|
||
pointer-events: auto;
|
||
`;
|
||
el.innerHTML = `
|
||
<div class="heading-arrow" style="
|
||
width: 0; height: 0;
|
||
border-left: 5px solid transparent;
|
||
border-right: 5px solid transparent;
|
||
border-bottom: 8px solid ${p.color || "#22c55e"};
|
||
margin-bottom: 2px;
|
||
display: ${p.location.heading != null ? "" : "none"};
|
||
transform: rotate(${p.location.heading || 0}deg);
|
||
"></div>
|
||
<div style="
|
||
font-size: 22px;
|
||
line-height: 1;
|
||
text-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||
">${p.emoji || "😎"}</div>
|
||
<div class="p-label" style="
|
||
font-size: 9px;
|
||
background: rgba(0,0,0,0.6);
|
||
color: white;
|
||
padding: 1px 4px;
|
||
border-radius: 3px;
|
||
white-space: nowrap;
|
||
margin-top: 2px;
|
||
">${p.name}${ageText}</div>
|
||
`;
|
||
|
||
const marker = new window.maplibregl.Marker({ element: el })
|
||
.setLngLat([p.location.longitude, p.location.latitude])
|
||
.addTo(this.#map);
|
||
this.#participantMarkers.set(p.id, marker);
|
||
}
|
||
}
|
||
|
||
// Remove markers for participants who left
|
||
for (const [id, marker] of this.#participantMarkers) {
|
||
if (!activeIds.has(id)) {
|
||
marker.remove();
|
||
this.#participantMarkers.delete(id);
|
||
}
|
||
}
|
||
|
||
// ── Update waypoint markers ──
|
||
const activeWpIds = new Set<string>();
|
||
for (const wp of state.waypoints) {
|
||
activeWpIds.add(wp.id);
|
||
const existing = this.#waypointMarkers.get(wp.id);
|
||
if (!existing) {
|
||
const el = document.createElement("div");
|
||
el.style.cssText = `
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
cursor: pointer;
|
||
`;
|
||
el.innerHTML = `
|
||
<div style="font-size: 20px; line-height: 1;">📌</div>
|
||
<div style="
|
||
font-size: 9px;
|
||
background: rgba(0,0,0,0.6);
|
||
color: white;
|
||
padding: 1px 4px;
|
||
border-radius: 3px;
|
||
white-space: nowrap;
|
||
margin-top: 2px;
|
||
">${wp.name || "Pin"}</div>
|
||
`;
|
||
|
||
const marker = new window.maplibregl.Marker({ element: el })
|
||
.setLngLat([wp.longitude, wp.latitude])
|
||
.addTo(this.#map);
|
||
this.#waypointMarkers.set(wp.id, marker);
|
||
}
|
||
}
|
||
|
||
// Remove deleted waypoints
|
||
for (const [id, marker] of this.#waypointMarkers) {
|
||
if (!activeWpIds.has(id)) {
|
||
marker.remove();
|
||
this.#waypointMarkers.delete(id);
|
||
}
|
||
}
|
||
|
||
// ── Update count badge ──
|
||
if (this.#countBadge) {
|
||
this.#countBadge.textContent = String(participants.length);
|
||
}
|
||
|
||
// ── Refresh participant panel if open ──
|
||
if (this.#showParticipants) {
|
||
this.#renderParticipantPanel(state);
|
||
}
|
||
}
|
||
|
||
#toggleLocationSharing() {
|
||
if (!this.#sync) return;
|
||
|
||
if (this.#sharingLocation) {
|
||
// Stop sharing
|
||
if (this.#watchId !== null) {
|
||
navigator.geolocation.clearWatch(this.#watchId);
|
||
this.#watchId = null;
|
||
}
|
||
this.#sharingLocation = false;
|
||
this.#shareBtn?.classList.remove("sharing");
|
||
if (this.#shareBtn) this.#shareBtn.innerHTML = "📍 Share";
|
||
this.#sync.clearLocation();
|
||
return;
|
||
}
|
||
|
||
if (!("geolocation" in navigator)) return;
|
||
|
||
this.#sharingLocation = true;
|
||
this.#shareBtn?.classList.add("sharing");
|
||
if (this.#shareBtn) this.#shareBtn.innerHTML = "📍 Sharing...";
|
||
|
||
this.#watchId = navigator.geolocation.watchPosition(
|
||
(pos) => {
|
||
const { latitude, longitude, accuracy, altitude, heading, speed } = pos.coords;
|
||
const fuzzed = fuzzLocation(latitude, longitude, this.#privacySettings.precision);
|
||
|
||
const location: LocationState = {
|
||
latitude: fuzzed.latitude,
|
||
longitude: fuzzed.longitude,
|
||
accuracy,
|
||
altitude: altitude ?? undefined,
|
||
heading: heading ?? undefined,
|
||
speed: speed ?? undefined,
|
||
timestamp: new Date().toISOString(),
|
||
source: "gps",
|
||
};
|
||
this.#sync?.updateLocation(location);
|
||
},
|
||
(err) => {
|
||
console.warn("[folk-map] GPS error:", err);
|
||
// Try lower accuracy
|
||
if (err.code === err.TIMEOUT && this.#watchId !== null) {
|
||
navigator.geolocation.clearWatch(this.#watchId);
|
||
this.#watchId = navigator.geolocation.watchPosition(
|
||
(pos) => {
|
||
const { latitude, longitude, accuracy } = pos.coords;
|
||
const fuzzed = fuzzLocation(latitude, longitude, this.#privacySettings.precision);
|
||
this.#sync?.updateLocation({
|
||
latitude: fuzzed.latitude,
|
||
longitude: fuzzed.longitude,
|
||
accuracy,
|
||
timestamp: new Date().toISOString(),
|
||
source: "network",
|
||
});
|
||
},
|
||
() => {},
|
||
{ enableHighAccuracy: false, timeout: 30000, maximumAge: 60000 }
|
||
);
|
||
}
|
||
},
|
||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 5000 }
|
||
);
|
||
}
|
||
|
||
#showWaypointInput() {
|
||
this.#waypointInputWrap?.classList.add("open");
|
||
const input = this.#waypointInputWrap?.querySelector(".waypoint-input") as HTMLInputElement;
|
||
if (input) {
|
||
input.value = "";
|
||
input.focus();
|
||
}
|
||
}
|
||
|
||
#submitWaypoint(name: string) {
|
||
this.#waypointInputWrap?.classList.remove("open");
|
||
if (!this.#sync || !this.#map) return;
|
||
|
||
const center = this.#map.getCenter();
|
||
const waypoint: WaypointState = {
|
||
id: crypto.randomUUID(),
|
||
name: name || "Pin",
|
||
latitude: center.lat,
|
||
longitude: center.lng,
|
||
createdBy: this.#participantId,
|
||
createdAt: new Date().toISOString(),
|
||
type: "poi",
|
||
};
|
||
this.#sync.addWaypoint(waypoint);
|
||
}
|
||
|
||
#changeEmoji(emoji: string) {
|
||
this.#userEmoji = emoji;
|
||
this.#saveUserProfile();
|
||
if (this.#emojiBtnEl) this.#emojiBtnEl.textContent = emoji;
|
||
|
||
// Close picker
|
||
this.#showEmojiPicker = false;
|
||
this.#emojiPickerEl?.classList.remove("open");
|
||
|
||
// Update selected state in picker
|
||
this.#emojiPickerEl?.querySelectorAll(".emoji-opt").forEach((el) => {
|
||
el.classList.toggle("selected", (el as HTMLElement).dataset.emoji === emoji);
|
||
});
|
||
|
||
// Re-join with updated emoji
|
||
if (this.#sync) {
|
||
this.#sync.join({
|
||
id: this.#participantId,
|
||
name: this.#userName,
|
||
emoji: this.#userEmoji,
|
||
color: this.#userColor,
|
||
joinedAt: new Date().toISOString(),
|
||
lastSeen: new Date().toISOString(),
|
||
status: "online",
|
||
});
|
||
}
|
||
}
|
||
|
||
#refreshStaleness() {
|
||
if (!this.#sync) return;
|
||
const state = this.#sync.getState();
|
||
const now = Date.now();
|
||
|
||
for (const p of Object.values(state.participants)) {
|
||
if (p.id === this.#participantId) continue;
|
||
const marker = this.#participantMarkers.get(p.id);
|
||
if (!marker) continue;
|
||
|
||
const age = now - new Date(p.lastSeen).getTime();
|
||
const isStale = age > STALE_THRESHOLD_MS;
|
||
const el = marker.getElement();
|
||
el.style.opacity = isStale ? "0.4" : "1";
|
||
|
||
const nameLabel = el.querySelector(".p-label") as HTMLElement;
|
||
if (nameLabel) {
|
||
const ageText = isStale ? ` (${Math.floor(age / 60000)}m ago)` : "";
|
||
nameLabel.textContent = p.name + ageText;
|
||
}
|
||
}
|
||
}
|
||
|
||
#renderParticipantPanel(state: RoomState) {
|
||
if (!this.#participantList) return;
|
||
|
||
const myLocation = state.participants[this.#participantId]?.location;
|
||
const entries = Object.values(state.participants).map((p) => {
|
||
let distText = "";
|
||
if (myLocation && p.location && p.id !== this.#participantId) {
|
||
const dist = haversineDistance(
|
||
myLocation.latitude, myLocation.longitude,
|
||
p.location.latitude, p.location.longitude
|
||
);
|
||
distText = formatDistance(dist);
|
||
}
|
||
|
||
const now = Date.now();
|
||
const age = now - new Date(p.lastSeen).getTime();
|
||
const isStale = age > STALE_THRESHOLD_MS;
|
||
const statusClass = isStale ? "away" : p.status;
|
||
|
||
return `
|
||
<div class="participant-entry">
|
||
<span class="participant-emoji">${p.emoji || "😎"}</span>
|
||
<div class="participant-info">
|
||
<div class="participant-name">${p.name}${p.id === this.#participantId ? " (you)" : ""}</div>
|
||
<div class="participant-meta">
|
||
${p.location ? (distText || "sharing") : "no location"}
|
||
${isStale ? ` · ${Math.floor(age / 60000)}m ago` : ""}
|
||
</div>
|
||
</div>
|
||
<div class="participant-status-dot ${statusClass}"></div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
this.#participantList.innerHTML = entries.join("");
|
||
}
|
||
|
||
// ── Serialization ──
|
||
|
||
static override fromData(data: Record<string, any>): FolkMap {
|
||
const shape = FolkShape.fromData(data) as FolkMap;
|
||
if (data.center) shape.center = data.center;
|
||
if (data.zoom !== undefined) shape.zoom = data.zoom;
|
||
if (data.roomSlug) shape.roomSlug = data.roomSlug;
|
||
return shape;
|
||
}
|
||
|
||
override toJSON() {
|
||
return {
|
||
...super.toJSON(),
|
||
type: "folk-map",
|
||
center: this.center,
|
||
zoom: this.zoom,
|
||
markers: this.markers,
|
||
roomSlug: this.#roomSlug,
|
||
};
|
||
}
|
||
|
||
override applyData(data: Record<string, any>): void {
|
||
super.applyData(data);
|
||
if (data.center !== undefined) this.center = data.center;
|
||
if (data.zoom !== undefined && this.zoom !== data.zoom) this.zoom = data.zoom;
|
||
if ("roomSlug" in data && this.#roomSlug !== data.roomSlug) {
|
||
this.roomSlug = data.roomSlug;
|
||
}
|
||
}
|
||
}
|