From 494db48e86cc220dcd36c5cbaf0ccefd78a7efef Mon Sep 17 00:00:00 2001 From: Aaron Axvig Date: Mon, 29 Jun 2026 14:46:51 -0500 Subject: [PATCH] Timeline added --- AGENTS.md | 8 +- README.md | 4 +- src/lib/feature-types.js | 5 - src/lib/yard-features.js | 30 ++--- src/main.js | 244 ++++++++++++++++++++++++++++----------- styles.css | 50 ++++++++ 6 files changed, 248 insertions(+), 93 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d358fb5..c645ce0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,8 +24,8 @@ Core files: Top-level exported data shape from `src/data/yard.js`: - `type: "FeatureCollection"` -- `schemaVersion: 1` -- `lifecycleFields: { startDate: "date", endDate: "date" }` +- `schemaVersion: 2` +- `lifecycleFields: { plantedDate: "date", removedDate: "date" }` - `featureTypes: [...]` - `features: [...]` - `groups: [...]` @@ -36,8 +36,8 @@ Each feature currently exports: - `properties.kind` - `properties.label` - `properties.name` -- `properties.startDate` -- `properties.endDate` +- `properties.plantedDate` +- `properties.removedDate` - `properties.parentId` - `properties.groupIds` - `properties.details` diff --git a/README.md b/README.md index 9c093b0..0624186 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ The typed feature registry lives in `src/lib/feature-types.js`, feature classes Local `north`, `south`, `east`, and `west` offsets are rotated per anchor. In this example, `topLeftLotCorner` in `src/data/anchors.js` carries `assumedNorthClockwiseDegrees` from `src/data/settings.js`. A negative value means that anchor's local north is counterclockwise from true north, so local east drifts a bit toward true north. -Most example geometry was copied from `buildings.gpkg` and preserved as JS-authored coordinates. Tree records also carry custom attributes like canopy diameter, height, and planted date for popup rendering. +Most example geometry was copied from `buildings.gpkg` and preserved as JS-authored coordinates. Tree records also carry custom attributes like canopy diameter, height, and lifecycle dates for popup rendering. -Feature relationships are exported with each GeoJSON feature as stable `id`, `parentId`, and `groupIds` properties. Each feature also exports universal `startDate` and `endDate` lifecycle fields for future time-slider filtering. The collection exposes `schemaVersion`, `lifecycleFields`, `featureTypes`, and `groups` at the top level. Style is resolved from the feature type registry at render time rather than stored on each exported feature. The current sample marks the porch and deck as part of the house and groups the Medora junipers under `Juniper Trees`. +Feature relationships are exported with each GeoJSON feature as stable `id`, `parentId`, and `groupIds` properties. Each feature also exports universal `plantedDate` and `removedDate` lifecycle fields for timeline filtering. The collection exposes `schemaVersion`, `lifecycleFields`, `featureTypes`, and `groups` at the top level. Style is resolved from the feature type registry at render time rather than stored on each exported feature. The current sample marks the porch and deck as part of the house and groups the Medora junipers under `Juniper Trees`. ## Project structure diff --git a/src/lib/feature-types.js b/src/lib/feature-types.js index 1148b75..516dd29 100644 --- a/src/lib/feature-types.js +++ b/src/lib/feature-types.js @@ -128,7 +128,6 @@ export const featureTypes = { species: "string", cultivar: "string", gardenOrgId: "string", - plantedDate: "date", note: "string", }, externalLinks: [ @@ -159,7 +158,6 @@ export const featureTypes = { cultivar: "string", bloomColor: "string", bloomSeason: "string", - plantedDate: "date", note: "string", }, style: { @@ -181,7 +179,6 @@ export const featureTypes = { cultivar: "string", bloomColor: "string", bloomSeason: "string", - plantedDate: "date", note: "string", }, style: { @@ -205,7 +202,6 @@ export const featureTypes = { cultivar: "string", canopyDiameterMeters: "number", heightMeters: "number", - plantedDate: "date", note: "string", }, style: (details) => ({ @@ -227,7 +223,6 @@ export const featureTypes = { cultivar: "string", canopyDiameterMeters: "number", heightMeters: "number", - plantedDate: "date", note: "string", }, style: (details) => ({ diff --git a/src/lib/yard-features.js b/src/lib/yard-features.js index b6d2858..17880fd 100644 --- a/src/lib/yard-features.js +++ b/src/lib/yard-features.js @@ -1,7 +1,7 @@ import { collectGeoJSON, offset, pointsFromLngLat } from "./geometry.js"; import { getFeatureType, listFeatureTypeSchemas } from "./feature-types.js"; -export const YARD_SCHEMA_VERSION = 1; +export const YARD_SCHEMA_VERSION = 2; class YardFeature { constructor(kind, geometryType, name, details = {}) { @@ -16,9 +16,9 @@ class YardFeature { this.geometryType = geometryType; this.name = name ?? featureType.label; this.id = buildFeatureId(kind, this.name); - const { startDate = null, endDate = null, ...restDetails } = details; - this.startDate = startDate; - this.endDate = endDate; + const { plantedDate = null, removedDate = null, ...restDetails } = details; + this.plantedDate = plantedDate; + this.removedDate = removedDate; this.details = { ...restDetails }; this.coordinates = []; this.parentId = null; @@ -48,19 +48,19 @@ class YardFeature { return this; } - activeFrom(startDate) { - this.startDate = startDate; + plantedOn(plantedDate) { + this.plantedDate = plantedDate; return this; } - activeUntil(endDate) { - this.endDate = endDate; + removedOn(removedDate) { + this.removedDate = removedDate; return this; } - withDateRange(startDate, endDate = null) { - this.startDate = startDate; - this.endDate = endDate; + withLifecycleDates(plantedDate, removedDate = null) { + this.plantedDate = plantedDate; + this.removedDate = removedDate; return this; } @@ -82,8 +82,8 @@ class YardFeature { kind: this.kind, label, name: this.name, - startDate: this.startDate, - endDate: this.endDate, + plantedDate: this.plantedDate, + removedDate: this.removedDate, parentId: this.parentId, groupIds: this.groupIds, details: this.details, @@ -334,8 +334,8 @@ export function collectYardData(features, groups = []) { const collection = collectGeoJSON(features); collection.schemaVersion = YARD_SCHEMA_VERSION; collection.lifecycleFields = { - startDate: "date", - endDate: "date", + plantedDate: "date", + removedDate: "date", }; collection.featureTypes = listFeatureTypeSchemas(); collection.groups = groups.map((group) => ({ diff --git a/src/main.js b/src/main.js index 9f32044..adc62d6 100644 --- a/src/main.js +++ b/src/main.js @@ -5,6 +5,23 @@ 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 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, @@ -27,76 +44,163 @@ L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: '© OpenStreetMap', }).addTo(map); -L.geoJSON(yardGeoJSON, { - 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)); - }, -}).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); -const legend = document.getElementById("legend"); -const legendEntries = summarizeTypes(yardGeoJSON.features); +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, + }; -legend.replaceChildren( - ...legendEntries.map((entry) => { - const row = document.createElement("div"); - row.className = "legend-item"; + if (style.iconUrl) { + const marker = L.marker(latlng, { + icon: buildIcon(style, latlng), + }); - const swatch = document.createElement("span"); - swatch.className = "legend-swatch"; - swatch.style.background = entry.color; + if (typeof style.iconDiameterMeters === "number") { + meterScaledIconMarkers.push({ marker, style, latlng }); + } - const label = document.createElement("span"); - label.textContent = `${entry.label} (${entry.count})`; + return marker; + } - row.append(swatch, label); - return row; - }), -); + 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(); @@ -169,6 +273,13 @@ 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(); @@ -192,7 +303,7 @@ function extendBounds(bounds, coordinates) { } function buildPopupContent(properties) { - const { kind, label, name, startDate, endDate, details, parentId, groupIds } = 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), ); @@ -200,12 +311,12 @@ function buildPopupContent(properties) { const relationshipEntries = []; const linkEntries = resolveFeatureLinks(kind, details); - if (startDate) { - lifecycleEntries.push(["startDate", startDate]); + if (plantedDate) { + lifecycleEntries.push(["plantedDate", plantedDate]); } - if (endDate) { - lifecycleEntries.push(["endDate", endDate]); + if (removedDate) { + lifecycleEntries.push(["removedDate", removedDate]); } if (parentId) { @@ -242,8 +353,7 @@ function formatDetailLabel(key) { canopyDiameterMeters: "Canopy diameter", heightMeters: "Height", plantedDate: "Planted", - startDate: "Start", - endDate: "End", + removedDate: "Removed", commonName: "Common name", genus: "Genus", cultivar: "Cultivar", diff --git a/styles.css b/styles.css index 89152a8..f2ca1bc 100644 --- a/styles.css +++ b/styles.css @@ -125,6 +125,56 @@ main { font-family: "Iowan Old Style", "Palatino Linotype", "URW Palladio L", serif; } +.timeline-control { + display: grid; + gap: 0.45rem; + min-width: 12rem; + padding: 0.7rem; + border: 1px solid var(--panel-border); + border-radius: 8px; + background: rgba(253, 250, 240, 0.94); + box-shadow: 0 12px 36px rgba(60, 44, 22, 0.16); + color: var(--ink); + font-family: "Iowan Old Style", "Palatino Linotype", "URW Palladio L", serif; +} + +.timeline-control-title { + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--accent); +} + +.timeline-buttons { + display: grid; + gap: 0.35rem; +} + +.timeline-buttons button { + width: 100%; + border: 1px solid rgba(70, 107, 69, 0.28); + border-radius: 6px; + padding: 0.42rem 0.55rem; + background: #fffdf6; + color: var(--ink); + font: inherit; + text-align: left; + cursor: pointer; +} + +.timeline-buttons button:hover, +.timeline-buttons button:focus-visible { + border-color: rgba(70, 107, 69, 0.58); + outline: none; +} + +.timeline-buttons button.is-selected { + border-color: var(--accent); + background: var(--accent-soft); + font-weight: 700; +} + .error-overlay { position: fixed; right: 1rem;