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>\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>
|
|
`;
|
|
|
|
// 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,
|
|
};
|
|
}
|
|
}
|