rspace-online/lib/folk-map.ts

498 lines
11 KiB
TypeScript

import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
},
},
layers: [
{
id: "osm",
type: "raster",
source: "osm",
},
],
};
const styles = css`
:host {
background: white;
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);
}
.map-container {
width: 100%;
height: calc(100% - 36px);
border-radius: 0 0 8px 8px;
overflow: hidden;
position: relative;
}
.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;
}
`;
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;
}
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 };
}
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;
}
#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;
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;
}
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>\u{1F5FA}</span>
<span>Map</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">\u00D7</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">\u{1F50D}</button>
</div>
<button class="locate-btn" title="My Location">\u{1F4CD}</button>
<div class="map"></div>
</div>
`;
const slot = root.querySelector("slot");
if (slot?.parentElement) {
const parent = slot.parentElement;
const existingDiv = parent.querySelector("div");
if (existingDiv) {
parent.replaceChild(wrapper, existingDiv);
}
}
this.#mapEl = wrapper.querySelector(".map");
this.#loadingEl = wrapper.querySelector(".loading");
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;
// Load MapLibre and initialize map
this.#loadMapLibre().then(() => {
this.#initMap();
});
// Search handler
const handleSearch = async () => {
const query = searchInput.value.trim();
if (!query) return;
try {
// Use Nominatim for geocoding
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
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"));
});
return root;
}
async #loadMapLibre() {
// Check if already loaded
if (window.maplibregl) return;
// Load CSS
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = MAPLIBRE_CSS;
document.head.appendChild(link);
// Load JS
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,
});
// Add navigation controls
this.#map.addControl(new window.maplibregl.NavigationControl(), "top-right");
// Hide loading indicator
this.#map.on("load", () => {
if (this.#loadingEl) {
this.#loadingEl.classList.add("hidden");
}
// Render any existing markers
this.#markers.forEach((marker) => this.#renderMarker(marker));
});
// Track map movement
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
this.#map.on("click", (e) => {
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);
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-map",
center: this.center,
zoom: this.zoom,
markers: this.markers,
};
}
}