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: '© OpenStreetMap', }, }, 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(); #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`
\u{1F5FA} Map
Loading map...
`; 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((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, }; } }