Timeline added

This commit is contained in:
2026-06-29 14:46:51 -05:00
parent 4d8254df90
commit 494db48e86
6 changed files with 248 additions and 93 deletions
-5
View File
@@ -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) => ({
+15 -15
View File
@@ -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) => ({
+177 -67
View File
@@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).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",