rspace-online/lib/folk-map.ts

496 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>🗺</span>
<span>Map</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>
</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);
}
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,
};
}
}