Timeline added
This commit is contained in:
@@ -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
@@ -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
@@ -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: '© <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",
|
||||
|
||||
Reference in New Issue
Block a user