496 lines
11 KiB
TypeScript
496 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: '© <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,
|
||
};
|
||
}
|
||
}
|