import { Map as MapLibreMap, NavigationControl, Popup, addProtocol, } from "../vendor/maplibre/maplibre-gl.mjs"; import { yardGeoJSON } from "./data/yard.js"; import { resolveFeatureLinks, resolveFeatureStyle } from "./lib/feature-types.js"; const MAP_MAX_ZOOM = 24; const MANDAN_BOUNDS = [ [-101.05, 46.74], [-100.75, 46.93], ]; const TIMELINE_PRESETS = [ { id: "purchase", label: "Axvigs purchase house", date: "2021-02-01" }, { id: "trees-2021", label: "2021 trees planted", date: "2021-09-22" }, { id: "today", label: "Today", date: getTodayDateString() }, ]; const mapElement = document.getElementById("map"); const statusElement = document.getElementById("map-status"); const legend = document.getElementById("legend"); const protocol = new globalThis.pmtiles.Protocol(); addProtocol("pmtiles", protocol.tile); const basemapLayers = globalThis.basemaps .layers("protomaps", globalThis.basemaps.namedFlavor("light"), { lang: "en" }) .filter((layer) => layer.type !== "symbol"); const map = new MapLibreMap({ container: mapElement, center: [-100.89875, 46.82682], zoom: 20, maxZoom: MAP_MAX_ZOOM, maxBounds: MANDAN_BOUNDS, style: { version: 8, sources: { protomaps: { type: "vector", url: `pmtiles://${new URL("./tiles/mandan.pmtiles", document.baseURI).href}`, attribution: '© OpenStreetMap contributors', }, }, layers: basemapLayers, }, }); map.addControl(new NavigationControl(), "bottom-right"); let selectedTimelineDate = TIMELINE_PRESETS.at(-1).date; let renderedLayerIds = []; let renderedSourceIds = []; let renderedLayerHandlers = []; map.on("load", () => { renderYardForDate(selectedTimelineDate); addTimelineControl(); map.fitBounds(getGeoJSONBounds(yardGeoJSON), { padding: 70, maxZoom: MAP_MAX_ZOOM, duration: 0, }); }); map.on("sourcedata", (event) => { if (event.sourceId === "protomaps" && event.isSourceLoaded) { statusElement.hidden = true; } }); map.on("error", (event) => { const message = event.error?.message || "The vector tile archive could not be loaded."; statusElement.textContent = `Map unavailable: ${message}`; statusElement.hidden = false; }); function renderYardForDate(date) { removeRenderedYard(); const features = yardGeoJSON.features.filter((feature) => featureExistsOnDate(feature, date)); features.forEach((feature, index) => addFeatureLayer(feature, index)); renderLegend(features); syncTimelineButtons(); } function removeRenderedYard() { for (const { layerId, event, handler } of renderedLayerHandlers) { map.off(event, layerId, handler); } for (const layerId of renderedLayerIds.reverse()) { if (map.getLayer(layerId)) { map.removeLayer(layerId); } } for (const sourceId of renderedSourceIds) { if (map.getSource(sourceId)) { map.removeSource(sourceId); } } renderedLayerIds = []; renderedSourceIds = []; renderedLayerHandlers = []; } function addFeatureLayer(feature, index) { const sourceId = `yard-source-${index}`; const layerId = `yard-layer-${index}`; const style = resolveFeatureStyle(feature.properties.kind, feature.properties.details); const layer = buildMapLibreLayer(layerId, sourceId, feature, style); map.addSource(sourceId, { type: "geojson", data: feature }); map.addLayer(layer); renderedSourceIds.push(sourceId); renderedLayerIds.push(layerId); const handleMouseEnter = () => { map.getCanvas().style.cursor = "pointer"; }; const handleMouseLeave = () => { map.getCanvas().style.cursor = ""; }; const handleClick = (event) => { const coordinates = feature.geometry.type === "Point" ? feature.geometry.coordinates : [event.lngLat.lng, event.lngLat.lat]; new Popup({ offset: 10 }) .setLngLat(coordinates) .setHTML(buildPopupContent(feature.properties)) .addTo(map); }; for (const [event, handler] of [ ["mouseenter", handleMouseEnter], ["mouseleave", handleMouseLeave], ["click", handleClick], ]) { map.on(event, layerId, handler); renderedLayerHandlers.push({ layerId, event, handler }); } } function buildMapLibreLayer(id, source, feature, style) { if (feature.geometry.type === "Polygon" || feature.geometry.type === "MultiPolygon") { return { id, source, type: "fill", paint: { "fill-color": style.fillColor ?? style.color ?? "#333333", "fill-opacity": style.fillOpacity ?? 0.25, "fill-outline-color": style.color ?? "#333333", }, }; } if (feature.geometry.type === "LineString" || feature.geometry.type === "MultiLineString") { const layer = { id, source, type: "line", paint: { "line-color": style.color ?? "#333333", "line-opacity": style.opacity ?? 1, "line-width": style.weight ?? 2, }, }; if (style.dashArray) { layer.paint["line-dasharray"] = style.dashArray.split(/\s+/).map(Number); } return layer; } return { id, source, type: "circle", paint: { "circle-color": style.fillColor ?? style.color ?? "#333333", "circle-opacity": style.fillOpacity ?? 0.85, "circle-stroke-color": style.color ?? "#333333", "circle-stroke-width": style.weight ?? 2, "circle-radius": getCircleRadius(style, feature.geometry.coordinates[1]), }, }; } function getCircleRadius(style, latitude) { const radiusMeters = style.radiusMeters ?? (typeof style.iconDiameterMeters === "number" ? style.iconDiameterMeters / 2 : null); if (radiusMeters === null) { return style.radius ?? 5; } const metersPerPixelAtZoomZero = 156543.03392 * Math.cos((latitude * Math.PI) / 180); return [ "interpolate", ["exponential", 2], ["zoom"], 15, (radiusMeters * 2 ** 15) / metersPerPixelAtZoomZero, MAP_MAX_ZOOM, (radiusMeters * 2 ** MAP_MAX_ZOOM) / metersPerPixelAtZoomZero, ]; } function featureExistsOnDate(feature, date) { const { plantedDate, removedDate } = feature.properties; return (!plantedDate || plantedDate <= date) && (!removedDate || date <= removedDate); } const timelineButtons = new globalThis.Map(); function addTimelineControl() { class TimelineControl { onAdd() { const container = document.createElement("div"); container.className = "maplibregl-ctrl timeline-control"; const title = document.createElement("div"); title.className = "timeline-control-title"; title.textContent = "Timeline"; const buttonGroup = document.createElement("div"); buttonGroup.className = "timeline-buttons"; buttonGroup.setAttribute("role", "group"); buttonGroup.setAttribute("aria-label", "Choose map date"); for (const preset of TIMELINE_PRESETS) { const button = document.createElement("button"); button.type = "button"; button.textContent = preset.label; button.title = preset.date; button.addEventListener("click", () => { selectedTimelineDate = preset.date; renderYardForDate(selectedTimelineDate); }); timelineButtons.set(preset.id, button); buttonGroup.append(button); } container.append(title, buttonGroup); return container; } onRemove() {} } map.addControl(new TimelineControl(), "top-right"); syncTimelineButtons(); } function syncTimelineButtons() { for (const preset of TIMELINE_PRESETS) { const button = timelineButtons.get(preset.id); if (!button) continue; const isSelected = preset.date === selectedTimelineDate; button.classList.toggle("is-selected", isSelected); button.setAttribute("aria-pressed", String(isSelected)); } } function renderLegend(features) { const summary = new globalThis.Map(); for (const feature of features) { const key = feature.properties.kind; const style = resolveFeatureStyle(key, feature.properties.details); const existing = summary.get(key) ?? { label: feature.properties.label, count: 0, color: style.fillColor ?? style.color ?? "#333333", }; existing.count += 1; summary.set(key, existing); } legend.replaceChildren( ...[...summary.values()].map((entry) => { const row = document.createElement("div"); row.className = "legend-item"; const swatch = document.createElement("span"); swatch.className = "legend-swatch"; swatch.style.background = entry.color; const label = document.createElement("span"); label.textContent = `${entry.label} (${entry.count})`; row.append(swatch, label); return row; }), ); } function getTodayDateString(date = new Date()) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } function getGeoJSONBounds(collection) { let west = Infinity; let south = Infinity; let east = -Infinity; let north = -Infinity; visitCoordinates(collection.features.map((feature) => feature.geometry.coordinates), ([lon, lat]) => { west = Math.min(west, lon); south = Math.min(south, lat); east = Math.max(east, lon); north = Math.max(north, lat); }); return [ [west, south], [east, north], ]; } function visitCoordinates(coordinates, callback) { if (typeof coordinates[0] === "number") { callback(coordinates); return; } for (const coordinate of coordinates) { visitCoordinates(coordinate, callback); } } function buildPopupContent(properties) { const { kind, label, name, plantedDate, removedDate, details, parentId, groupIds } = properties; const entries = Object.entries(details ?? {}).filter( ([key, value]) => value !== null && value !== undefined && shouldShowDetailField(key), ); const lifecycleEntries = []; const relationshipEntries = []; const linkEntries = resolveFeatureLinks(kind, details); if (plantedDate) lifecycleEntries.push(["plantedDate", plantedDate]); if (removedDate) lifecycleEntries.push(["removedDate", removedDate]); if (parentId) relationshipEntries.push(["parentFeature", parentId]); if (groupIds?.length) relationshipEntries.push(["groups", groupIds.join(", ")]); const title = `${escapeHtml(name)}`; const subtitle = name === label ? "" : `