import { Map as MapLibreMap, NavigationControl, Popup, addProtocol, } from "../vendor/maplibre/maplibre-gl.mjs"; import { yardGeoJSON } from "./data/yard.js"; import { CheckboxTree, loadManifest } from "../vendor/checkbox-tree/checkbox-tree.js"; import { resolveFeatureLinks, resolveFeatureStyle } from "./lib/feature-types.js"; const MAP_MAX_ZOOM = 24; const FEET_PER_METER = 3.280839895; 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 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 selectedFeatureIndexes = new Set(yardGeoJSON.features.map((_, index) => index)); let renderedLayerIds = []; let renderedSourceIds = []; let renderedLayerHandlers = []; const visibilityReady = initializeVisibilityTree(); map.on("load", async () => { await visibilityReady; 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, index) => selectedFeatureIndexes.has(index) && featureExistsOnDate(feature, date), ); features.forEach((feature, index) => addFeatureLayer(feature, index)); 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, ]; } async function initializeVisibilityTree() { const container = document.getElementById("visibility-tree"); const manifest = loadManifest( await fetch("./src/data/visibility-tree-manifest.json").then((response) => { if (!response.ok) { throw new Error(`Visibility manifest returned HTTP ${response.status}`); } return response.json(); }), ); const tree = new CheckboxTree(container, { initiallyCollapsed: true, initiallySelected: true, storageKey: "a-plot-of-plants:visibility:v1", manifest, stateEncoding: "tiered", onSelectionChange: ({ selectedNodes }) => { selectedFeatureIndexes = getSelectedFeatureIndexes(selectedNodes); if (map.loaded()) { renderYardForDate(selectedTimelineDate); } pushTreeState(tree); }, }); tree.setData(buildVisibilityNodes(yardGeoJSON)); selectedFeatureIndexes = getSelectedFeatureIndexes(tree.getSelectedNodes()); container.addEventListener("click", (event) => { if (event.target.closest(".checkbox-tree__toggle")) { pushTreeState(tree); } }); window.addEventListener("popstate", () => { tree.restoreStateFromLocation(); selectedFeatureIndexes = getSelectedFeatureIndexes(tree.getSelectedNodes()); if (map.loaded()) { renderYardForDate(selectedTimelineDate); } }); } function pushTreeState(tree) { const url = new URL(window.location.href); url.hash = tree.serializeStateToFragment(); if (url.href !== window.location.href) { history.pushState(null, "", url); } } function getSelectedFeatureIndexes(nodes) { return new Set( nodes .map((node) => node.metadata?.featureIndex) .filter((index) => Number.isInteger(index)), ); } function buildVisibilityNodes(collection) { const typeSchemasByKind = new globalThis.Map( collection.featureTypes.map((type) => [type.kind, type]), ); const categoriesById = new globalThis.Map( collection.featureCategories.map((category) => [ category.id, { id: `visibility:category:${category.id}`, label: category.label, order: category.order, children: [], }, ]), ); const featureEntries = collection.features.map((feature, featureIndex) => ({ feature, featureIndex, })); const groupsById = new globalThis.Map( collection.groups.map((group) => [ group.id, { id: `visibility:${group.id}`, label: group.label, children: [], parentGroupId: group.parentGroupId, parentKind: group.parentKind, categoryId: group.categoryId, }, ]), ); const groupedFeatureIndexes = new Set(); for (const entry of featureEntries) { const group = entry.feature.properties.groupIds.map((id) => groupsById.get(id)).find(Boolean); if (!group) continue; const categoryId = typeSchemasByKind.get(entry.feature.properties.kind)?.categoryId; if (group.categoryId && group.categoryId !== categoryId) { throw new Error(`Visibility group spans multiple feature categories: ${group.label}`); } group.categoryId = categoryId; group.children.push(toVisibilityLeaf(entry)); groupedFeatureIndexes.add(entry.featureIndex); } for (const group of groupsById.values()) { const parent = groupsById.get(group.parentGroupId); if (parent) { if (parent.categoryId !== group.categoryId) { throw new Error(`Nested visibility groups must share a category: ${group.label}`); } parent.children.push(group); } } const rootGroups = [...groupsById.values()].filter( (group) => !groupsById.has(group.parentGroupId) && group.children.length > 0, ); const featuresByKind = new globalThis.Map(); for (const entry of featureEntries) { if (groupedFeatureIndexes.has(entry.featureIndex)) continue; const { kind } = entry.feature.properties; const typeSchema = typeSchemasByKind.get(kind); const categoryId = typeSchema?.categoryId; const typeNode = featuresByKind.get(kind) ?? { id: `visibility:type:${kind}`, label: typeSchema?.collectionLabel ?? entry.feature.properties.label, kind, parentKind: typeSchema?.parentKind, categoryId, children: [], }; typeNode.children.push(toVisibilityLeaf(entry)); featuresByKind.set(kind, typeNode); } for (const typeNode of featuresByKind.values()) { const parentTypeNode = featuresByKind.get(typeNode.parentKind); if (parentTypeNode) { parentTypeNode.children.push(typeNode); } else { categoriesById.get(typeNode.categoryId)?.children.push(typeNode); } } for (const group of rootGroups) { const parentTypeNode = featuresByKind.get(group.parentKind); if (parentTypeNode) { parentTypeNode.children.push(group); } else { categoriesById.get(group.categoryId)?.children.push(group); } } for (const typeNode of featuresByKind.values()) { delete typeNode.kind; delete typeNode.parentKind; delete typeNode.categoryId; } for (const group of groupsById.values()) { delete group.parentGroupId; delete group.parentKind; delete group.categoryId; } return [...categoriesById.values()] .filter((category) => category.children.length > 0) .sort((left, right) => left.order - right.order) .map((category) => { delete category.order; return category; }) .map(sortVisibilityNode); } function toVisibilityLeaf({ feature, featureIndex }) { return { id: `visibility:feature:${featureIndex}`, label: feature.properties.name, metadata: { featureIndex }, }; } function sortVisibilityNode(node) { if (node.children) { node.children = node.children.map(sortVisibilityNode).sort(compareVisibilityNodes); } return node; } function compareVisibilityNodes(left, right) { return left.label.localeCompare(right.label, undefined, { numeric: true }); } 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 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, details), ); 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 ? "" : `
${escapeHtml(label)}
`; const allEntries = [...lifecycleEntries, ...entries, ...relationshipEntries]; const measurementTable = buildMeasurementTable(details?.measurements); if (allEntries.length === 0 && linkEntries.length === 0 && !measurementTable) { return `${title}${subtitle}`; } const detailRows = allEntries .map( ([key, value]) => `
${escapeHtml(formatDetailLabel(key))}: ${escapeHtml(formatDetailValue(key, value))}
`, ) .join(""); const linkRows = linkEntries .map( (link) => `
${escapeHtml(link.label)}
`, ) .join(""); return `${title}${subtitle}
${detailRows}${measurementTable}${linkRows}
`; } function shouldShowDetailField(key, details = {}) { if (key === "measurements") { return false; } if ( hasDatedMeasurements(details.measurements) && (key === "canopyDiameter" || key === "height") ) { return false; } return !key.endsWith("Url") && key !== "gardenOrgId"; } function formatDetailLabel(key) { const labels = { canopyDiameter: "Canopy diameter", height: "Height", measurements: "Measurements", measuredDate: "Measured", plantedDate: "Planted", removedDate: "Removed", commonName: "Common name", genus: "Genus", cultivar: "Cultivar", bloomColor: "Bloom color", bloomSeason: "Bloom season", species: "Species", fenceType: "Fence type", axis: "Axis", offsetFeet: "Offset", spacingFeet: "Spacing", note: "Note", parentFeature: "Parent", groups: "Groups", }; return labels[key] ?? key; } function formatDetailValue(key, value) { if (key === "canopyDiameter" || key === "height") return formatMetersAsFeet(value); if (key === "offsetFeet" || key === "spacingFeet") return `${value} ft`; return String(value); } function formatMetersAsFeet(value) { return `${formatNumber(value * FEET_PER_METER)} ft`; } function formatNumber(value) { return Number(value.toFixed(1)).toString(); } function buildMeasurementTable(measurements) { const datedMeasurements = getDatedMeasurements(measurements); if (!datedMeasurements.length) { return ""; } const rows = datedMeasurements .map( (measurement) => ` ${escapeHtml(measurement.measuredDate)} ${escapeHtml(formatOptionalMeasurement(measurement.canopyDiameter))} ${escapeHtml(formatOptionalMeasurement(measurement.height))} `, ) .join(""); return `
${escapeHtml(formatDetailLabel("measurements"))}:
${rows}
Date Canopy dia. Height
`; } function formatOptionalMeasurement(value) { return value === undefined ? "" : formatMetersAsFeet(value); } function hasDatedMeasurements(measurements) { return getDatedMeasurements(measurements).length > 0; } function getDatedMeasurements(measurements) { if (!Array.isArray(measurements)) { return []; } return measurements.filter((measurement) => measurement.measuredDate); } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); }