import { yardGeoJSON } from "./data/yard.js"; import { resolveFeatureLinks, resolveFeatureStyle } from "./lib/feature-types.js"; const TILE_MAX_NATIVE_ZOOM = 19; const MAP_MAX_ZOOM = 24; const MAP_ZOOM_STEP = 0.25; const WHEEL_PIXELS_PER_ZOOM_LEVEL = 240; const FEET_PER_METER = 3.280839895; 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 map = L.map("map", { zoomControl: false, maxZoom: MAP_MAX_ZOOM, zoomSnap: MAP_ZOOM_STEP, zoomDelta: MAP_ZOOM_STEP, wheelPxPerZoomLevel: WHEEL_PIXELS_PER_ZOOM_LEVEL, }); const meterScaledIconMarkers = []; L.control .zoom({ position: "bottomright", }) .addTo(map); L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxNativeZoom: TILE_MAX_NATIVE_ZOOM, maxZoom: MAP_MAX_ZOOM, attribution: '© OpenStreetMap', }).addTo(map); let selectedTimelineDate = TIMELINE_PRESETS.at(-1).date; let yardLayer = null; const timelineButtons = new Map(); const legend = document.getElementById("legend"); renderYardForDate(selectedTimelineDate); addTimelineControl(); map.fitBounds(getGeoJSONBounds(yardGeoJSON).pad(0.2), { maxZoom: MAP_MAX_ZOOM }); updateMeterScaledIcons(); map.on("zoom", updateMeterScaledIcons); map.on("zoomend", updateMeterScaledIcons); function createYardLayer(collection) { return L.geoJSON(collection, { pointToLayer(feature, latlng) { const style = getStyle(feature); const options = { color: style.color ?? "#333333", fillColor: style.fillColor ?? style.color ?? "#333333", fillOpacity: style.fillOpacity ?? 0.8, weight: style.weight ?? 2, }; if (style.iconUrl) { const marker = L.marker(latlng, { icon: buildIcon(style, latlng), }); if (typeof style.iconDiameterMeters === "number") { meterScaledIconMarkers.push({ marker, style, latlng }); } return marker; } if (typeof style.radiusMeters === "number") { return L.circle(latlng, { ...options, radius: style.radiusMeters, }); } return L.circleMarker(latlng, { ...options, radius: style.radius ?? 6, }); }, style(feature) { if (feature.geometry.type === "Point") { return getPathStyle(feature); } return getStyle(feature); }, onEachFeature(feature, layer) { layer.bindPopup(buildPopupContent(feature.properties)); }, }); } function renderYardForDate(date) { const filteredYard = filterFeatureCollectionByDate(yardGeoJSON, date); if (yardLayer) { yardLayer.remove(); } meterScaledIconMarkers.length = 0; yardLayer = createYardLayer(filteredYard).addTo(map); renderLegend(filteredYard.features); syncTimelineButtons(); updateMeterScaledIcons(); } function filterFeatureCollectionByDate(collection, date) { return { ...collection, features: collection.features.filter((feature) => featureExistsOnDate(feature, date)), }; } function featureExistsOnDate(feature, date) { const { plantedDate, removedDate } = feature.properties; return (!plantedDate || plantedDate <= date) && (!removedDate || date <= removedDate); } function addTimelineControl() { const timelineControl = L.control({ position: "topright" }); timelineControl.onAdd = () => { const container = L.DomUtil.create("div", "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); L.DomEvent.disableClickPropagation(container); L.DomEvent.disableScrollPropagation(container); return container; }; timelineControl.addTo(map); 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 legendEntries = summarizeTypes(features); legend.replaceChildren( ...legendEntries.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 summarizeTypes(features) { const summary = new Map(); for (const feature of features) { const key = feature.properties.kind; const style = getStyle(feature); const existing = summary.get(key) ?? { label: feature.properties.label, count: 0, color: style.color ?? "#333333", }; existing.count += 1; summary.set(key, existing); } return [...summary.values()]; } function getStyle(feature) { return resolveFeatureStyle(feature.properties.kind, feature.properties.details); } function getPathStyle(feature) { const { iconAnchor, iconDiameterMeters, iconSize, iconUrl, popupAnchor, radius, radiusMeters, ...style } = getStyle(feature); return style; } function buildIcon(style, latlng) { const iconSize = getIconSize(style, latlng); return L.icon({ iconUrl: style.iconUrl, iconSize, iconAnchor: [iconSize[0] / 2, iconSize[1] / 2], popupAnchor: [0, -iconSize[1] / 2], }); } function updateMeterScaledIcons() { for (const { marker, style, latlng } of meterScaledIconMarkers) { marker.setIcon(buildIcon(style, latlng)); } } function getIconSize(style, latlng) { if (typeof style.iconDiameterMeters !== "number") { return style.iconSize ?? [24, 24]; } if (typeof map.getZoom() !== "number") { return style.iconSize ?? [24, 24]; } const diameterPixels = style.iconDiameterMeters / getMetersPerPixel(latlng.lat); return [diameterPixels, diameterPixels]; } function getMetersPerPixel(lat) { return (156543.03392 * Math.cos((lat * Math.PI) / 180)) / 2 ** map.getZoom(); } 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) { const bounds = L.latLngBounds(); for (const feature of collection.features) { extendBounds(bounds, feature.geometry.coordinates); } return bounds; } function extendBounds(bounds, coordinates) { if (typeof coordinates[0] === "number") { const [lon, lat] = coordinates; bounds.extend([lat, lon]); return; } for (const coordinate of coordinates) { extendBounds(bounds, coordinate); } } 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) => `
Info
`) .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("'", "'"); }