Timeline added
This commit is contained in:
@@ -24,8 +24,8 @@ Core files:
|
|||||||
Top-level exported data shape from `src/data/yard.js`:
|
Top-level exported data shape from `src/data/yard.js`:
|
||||||
|
|
||||||
- `type: "FeatureCollection"`
|
- `type: "FeatureCollection"`
|
||||||
- `schemaVersion: 1`
|
- `schemaVersion: 2`
|
||||||
- `lifecycleFields: { startDate: "date", endDate: "date" }`
|
- `lifecycleFields: { plantedDate: "date", removedDate: "date" }`
|
||||||
- `featureTypes: [...]`
|
- `featureTypes: [...]`
|
||||||
- `features: [...]`
|
- `features: [...]`
|
||||||
- `groups: [...]`
|
- `groups: [...]`
|
||||||
@@ -36,8 +36,8 @@ Each feature currently exports:
|
|||||||
- `properties.kind`
|
- `properties.kind`
|
||||||
- `properties.label`
|
- `properties.label`
|
||||||
- `properties.name`
|
- `properties.name`
|
||||||
- `properties.startDate`
|
- `properties.plantedDate`
|
||||||
- `properties.endDate`
|
- `properties.removedDate`
|
||||||
- `properties.parentId`
|
- `properties.parentId`
|
||||||
- `properties.groupIds`
|
- `properties.groupIds`
|
||||||
- `properties.details`
|
- `properties.details`
|
||||||
|
|||||||
@@ -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.
|
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
|
## Project structure
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,6 @@ export const featureTypes = {
|
|||||||
species: "string",
|
species: "string",
|
||||||
cultivar: "string",
|
cultivar: "string",
|
||||||
gardenOrgId: "string",
|
gardenOrgId: "string",
|
||||||
plantedDate: "date",
|
|
||||||
note: "string",
|
note: "string",
|
||||||
},
|
},
|
||||||
externalLinks: [
|
externalLinks: [
|
||||||
@@ -159,7 +158,6 @@ export const featureTypes = {
|
|||||||
cultivar: "string",
|
cultivar: "string",
|
||||||
bloomColor: "string",
|
bloomColor: "string",
|
||||||
bloomSeason: "string",
|
bloomSeason: "string",
|
||||||
plantedDate: "date",
|
|
||||||
note: "string",
|
note: "string",
|
||||||
},
|
},
|
||||||
style: {
|
style: {
|
||||||
@@ -181,7 +179,6 @@ export const featureTypes = {
|
|||||||
cultivar: "string",
|
cultivar: "string",
|
||||||
bloomColor: "string",
|
bloomColor: "string",
|
||||||
bloomSeason: "string",
|
bloomSeason: "string",
|
||||||
plantedDate: "date",
|
|
||||||
note: "string",
|
note: "string",
|
||||||
},
|
},
|
||||||
style: {
|
style: {
|
||||||
@@ -205,7 +202,6 @@ export const featureTypes = {
|
|||||||
cultivar: "string",
|
cultivar: "string",
|
||||||
canopyDiameterMeters: "number",
|
canopyDiameterMeters: "number",
|
||||||
heightMeters: "number",
|
heightMeters: "number",
|
||||||
plantedDate: "date",
|
|
||||||
note: "string",
|
note: "string",
|
||||||
},
|
},
|
||||||
style: (details) => ({
|
style: (details) => ({
|
||||||
@@ -227,7 +223,6 @@ export const featureTypes = {
|
|||||||
cultivar: "string",
|
cultivar: "string",
|
||||||
canopyDiameterMeters: "number",
|
canopyDiameterMeters: "number",
|
||||||
heightMeters: "number",
|
heightMeters: "number",
|
||||||
plantedDate: "date",
|
|
||||||
note: "string",
|
note: "string",
|
||||||
},
|
},
|
||||||
style: (details) => ({
|
style: (details) => ({
|
||||||
|
|||||||
+15
-15
@@ -1,7 +1,7 @@
|
|||||||
import { collectGeoJSON, offset, pointsFromLngLat } from "./geometry.js";
|
import { collectGeoJSON, offset, pointsFromLngLat } from "./geometry.js";
|
||||||
import { getFeatureType, listFeatureTypeSchemas } from "./feature-types.js";
|
import { getFeatureType, listFeatureTypeSchemas } from "./feature-types.js";
|
||||||
|
|
||||||
export const YARD_SCHEMA_VERSION = 1;
|
export const YARD_SCHEMA_VERSION = 2;
|
||||||
|
|
||||||
class YardFeature {
|
class YardFeature {
|
||||||
constructor(kind, geometryType, name, details = {}) {
|
constructor(kind, geometryType, name, details = {}) {
|
||||||
@@ -16,9 +16,9 @@ class YardFeature {
|
|||||||
this.geometryType = geometryType;
|
this.geometryType = geometryType;
|
||||||
this.name = name ?? featureType.label;
|
this.name = name ?? featureType.label;
|
||||||
this.id = buildFeatureId(kind, this.name);
|
this.id = buildFeatureId(kind, this.name);
|
||||||
const { startDate = null, endDate = null, ...restDetails } = details;
|
const { plantedDate = null, removedDate = null, ...restDetails } = details;
|
||||||
this.startDate = startDate;
|
this.plantedDate = plantedDate;
|
||||||
this.endDate = endDate;
|
this.removedDate = removedDate;
|
||||||
this.details = { ...restDetails };
|
this.details = { ...restDetails };
|
||||||
this.coordinates = [];
|
this.coordinates = [];
|
||||||
this.parentId = null;
|
this.parentId = null;
|
||||||
@@ -48,19 +48,19 @@ class YardFeature {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
activeFrom(startDate) {
|
plantedOn(plantedDate) {
|
||||||
this.startDate = startDate;
|
this.plantedDate = plantedDate;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
activeUntil(endDate) {
|
removedOn(removedDate) {
|
||||||
this.endDate = endDate;
|
this.removedDate = removedDate;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
withDateRange(startDate, endDate = null) {
|
withLifecycleDates(plantedDate, removedDate = null) {
|
||||||
this.startDate = startDate;
|
this.plantedDate = plantedDate;
|
||||||
this.endDate = endDate;
|
this.removedDate = removedDate;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,8 +82,8 @@ class YardFeature {
|
|||||||
kind: this.kind,
|
kind: this.kind,
|
||||||
label,
|
label,
|
||||||
name: this.name,
|
name: this.name,
|
||||||
startDate: this.startDate,
|
plantedDate: this.plantedDate,
|
||||||
endDate: this.endDate,
|
removedDate: this.removedDate,
|
||||||
parentId: this.parentId,
|
parentId: this.parentId,
|
||||||
groupIds: this.groupIds,
|
groupIds: this.groupIds,
|
||||||
details: this.details,
|
details: this.details,
|
||||||
@@ -334,8 +334,8 @@ export function collectYardData(features, groups = []) {
|
|||||||
const collection = collectGeoJSON(features);
|
const collection = collectGeoJSON(features);
|
||||||
collection.schemaVersion = YARD_SCHEMA_VERSION;
|
collection.schemaVersion = YARD_SCHEMA_VERSION;
|
||||||
collection.lifecycleFields = {
|
collection.lifecycleFields = {
|
||||||
startDate: "date",
|
plantedDate: "date",
|
||||||
endDate: "date",
|
removedDate: "date",
|
||||||
};
|
};
|
||||||
collection.featureTypes = listFeatureTypeSchemas();
|
collection.featureTypes = listFeatureTypeSchemas();
|
||||||
collection.groups = groups.map((group) => ({
|
collection.groups = groups.map((group) => ({
|
||||||
|
|||||||
+127
-17
@@ -5,6 +5,23 @@ const TILE_MAX_NATIVE_ZOOM = 19;
|
|||||||
const MAP_MAX_ZOOM = 24;
|
const MAP_MAX_ZOOM = 24;
|
||||||
const MAP_ZOOM_STEP = 0.25;
|
const MAP_ZOOM_STEP = 0.25;
|
||||||
const WHEEL_PIXELS_PER_ZOOM_LEVEL = 240;
|
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", {
|
const map = L.map("map", {
|
||||||
zoomControl: false,
|
zoomControl: false,
|
||||||
@@ -27,7 +44,20 @@ L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
L.geoJSON(yardGeoJSON, {
|
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) {
|
pointToLayer(feature, latlng) {
|
||||||
const style = getStyle(feature);
|
const style = getStyle(feature);
|
||||||
const options = {
|
const options = {
|
||||||
@@ -71,17 +101,90 @@ L.geoJSON(yardGeoJSON, {
|
|||||||
onEachFeature(feature, layer) {
|
onEachFeature(feature, layer) {
|
||||||
layer.bindPopup(buildPopupContent(feature.properties));
|
layer.bindPopup(buildPopupContent(feature.properties));
|
||||||
},
|
},
|
||||||
}).addTo(map);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
map.fitBounds(getGeoJSONBounds(yardGeoJSON).pad(0.2), { maxZoom: MAP_MAX_ZOOM });
|
function renderYardForDate(date) {
|
||||||
updateMeterScaledIcons();
|
const filteredYard = filterFeatureCollectionByDate(yardGeoJSON, date);
|
||||||
map.on("zoom", updateMeterScaledIcons);
|
|
||||||
map.on("zoomend", updateMeterScaledIcons);
|
|
||||||
|
|
||||||
const legend = document.getElementById("legend");
|
if (yardLayer) {
|
||||||
const legendEntries = summarizeTypes(yardGeoJSON.features);
|
yardLayer.remove();
|
||||||
|
}
|
||||||
|
|
||||||
legend.replaceChildren(
|
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) => {
|
...legendEntries.map((entry) => {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "legend-item";
|
row.className = "legend-item";
|
||||||
@@ -96,7 +199,8 @@ legend.replaceChildren(
|
|||||||
row.append(swatch, label);
|
row.append(swatch, label);
|
||||||
return row;
|
return row;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function summarizeTypes(features) {
|
function summarizeTypes(features) {
|
||||||
const summary = new Map();
|
const summary = new Map();
|
||||||
@@ -169,6 +273,13 @@ function getMetersPerPixel(lat) {
|
|||||||
return (156543.03392 * Math.cos((lat * Math.PI) / 180)) / 2 ** map.getZoom();
|
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) {
|
function getGeoJSONBounds(collection) {
|
||||||
const bounds = L.latLngBounds();
|
const bounds = L.latLngBounds();
|
||||||
|
|
||||||
@@ -192,7 +303,7 @@ function extendBounds(bounds, coordinates) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildPopupContent(properties) {
|
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(
|
const entries = Object.entries(details ?? {}).filter(
|
||||||
([key, value]) => value !== null && value !== undefined && shouldShowDetailField(key),
|
([key, value]) => value !== null && value !== undefined && shouldShowDetailField(key),
|
||||||
);
|
);
|
||||||
@@ -200,12 +311,12 @@ function buildPopupContent(properties) {
|
|||||||
const relationshipEntries = [];
|
const relationshipEntries = [];
|
||||||
const linkEntries = resolveFeatureLinks(kind, details);
|
const linkEntries = resolveFeatureLinks(kind, details);
|
||||||
|
|
||||||
if (startDate) {
|
if (plantedDate) {
|
||||||
lifecycleEntries.push(["startDate", startDate]);
|
lifecycleEntries.push(["plantedDate", plantedDate]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (endDate) {
|
if (removedDate) {
|
||||||
lifecycleEntries.push(["endDate", endDate]);
|
lifecycleEntries.push(["removedDate", removedDate]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
@@ -242,8 +353,7 @@ function formatDetailLabel(key) {
|
|||||||
canopyDiameterMeters: "Canopy diameter",
|
canopyDiameterMeters: "Canopy diameter",
|
||||||
heightMeters: "Height",
|
heightMeters: "Height",
|
||||||
plantedDate: "Planted",
|
plantedDate: "Planted",
|
||||||
startDate: "Start",
|
removedDate: "Removed",
|
||||||
endDate: "End",
|
|
||||||
commonName: "Common name",
|
commonName: "Common name",
|
||||||
genus: "Genus",
|
genus: "Genus",
|
||||||
cultivar: "Cultivar",
|
cultivar: "Cultivar",
|
||||||
|
|||||||
+50
@@ -125,6 +125,56 @@ main {
|
|||||||
font-family: "Iowan Old Style", "Palatino Linotype", "URW Palladio L", serif;
|
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 {
|
.error-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user