592 lines
16 KiB
JavaScript
592 lines
16 KiB
JavaScript
import { yardGeoJSON } from "./data/yard.js";
|
|
import { CheckboxTree } from "../vendor/checkbox-tree/checkbox-tree.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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
|
}).addTo(map);
|
|
|
|
let selectedTimelineDate = TIMELINE_PRESETS.at(-1).date;
|
|
let selectedFeatureIndexes = new Set(yardGeoJSON.features.map((_, index) => index));
|
|
let yardLayer = null;
|
|
const timelineButtons = new Map();
|
|
|
|
initializeVisibilityTree();
|
|
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 = filterFeatureCollection(yardGeoJSON, date, selectedFeatureIndexes);
|
|
|
|
if (yardLayer) {
|
|
yardLayer.remove();
|
|
}
|
|
|
|
meterScaledIconMarkers.length = 0;
|
|
yardLayer = createYardLayer(filteredYard).addTo(map);
|
|
syncTimelineButtons();
|
|
updateMeterScaledIcons();
|
|
}
|
|
|
|
function filterFeatureCollection(collection, date, visibleIndexes) {
|
|
return {
|
|
...collection,
|
|
features: collection.features.filter(
|
|
(feature, index) => visibleIndexes.has(index) && featureExistsOnDate(feature, date),
|
|
),
|
|
};
|
|
}
|
|
|
|
function initializeVisibilityTree() {
|
|
const tree = new CheckboxTree(document.getElementById("visibility-tree"), {
|
|
initiallyCollapsed: true,
|
|
initiallySelected: true,
|
|
storageKey: "a-plot-of-plants:visibility:v1",
|
|
onSelectionChange: ({ selectedNodes }) => {
|
|
selectedFeatureIndexes = getSelectedFeatureIndexes(selectedNodes);
|
|
renderYardForDate(selectedTimelineDate);
|
|
},
|
|
});
|
|
|
|
tree.setData(buildVisibilityNodes(yardGeoJSON));
|
|
selectedFeatureIndexes = getSelectedFeatureIndexes(tree.getSelectedNodes());
|
|
}
|
|
|
|
function getSelectedFeatureIndexes(nodes) {
|
|
return new Set(
|
|
nodes
|
|
.map((node) => node.metadata?.featureIndex)
|
|
.filter((index) => Number.isInteger(index)),
|
|
);
|
|
}
|
|
|
|
function buildVisibilityNodes(collection) {
|
|
const typeSchemasByKind = new Map(collection.featureTypes.map((type) => [type.kind, type]));
|
|
const categoriesById = new Map(
|
|
collection.featureCategories.map((category) => [
|
|
category.id,
|
|
{
|
|
id: `visibility:category:${category.id}`,
|
|
label: category.label,
|
|
order: category.order,
|
|
children: [],
|
|
},
|
|
]),
|
|
);
|
|
const featureEntries = collection.features.map((feature, featureIndex) => ({
|
|
feature,
|
|
featureIndex,
|
|
}));
|
|
const groupsById = new Map(
|
|
collection.groups.map((group) => [
|
|
group.id,
|
|
{
|
|
id: `visibility:${group.id}`,
|
|
label: group.label,
|
|
children: [],
|
|
parentGroupId: group.parentGroupId,
|
|
parentKind: group.parentKind,
|
|
categoryId: group.categoryId,
|
|
},
|
|
]),
|
|
);
|
|
const groupedFeatureIndexes = new Set();
|
|
|
|
for (const entry of featureEntries) {
|
|
const group = entry.feature.properties.groupIds.map((id) => groupsById.get(id)).find(Boolean);
|
|
if (!group) {
|
|
continue;
|
|
}
|
|
const categoryId = typeSchemasByKind.get(entry.feature.properties.kind)?.categoryId;
|
|
if (group.categoryId && group.categoryId !== categoryId) {
|
|
throw new Error(`Visibility group spans multiple feature categories: ${group.label}`);
|
|
}
|
|
group.categoryId = categoryId;
|
|
group.children.push(toVisibilityLeaf(entry));
|
|
groupedFeatureIndexes.add(entry.featureIndex);
|
|
}
|
|
|
|
for (const group of groupsById.values()) {
|
|
const parent = groupsById.get(group.parentGroupId);
|
|
if (parent) {
|
|
if (parent.categoryId !== group.categoryId) {
|
|
throw new Error(`Nested visibility groups must share a category: ${group.label}`);
|
|
}
|
|
parent.children.push(group);
|
|
}
|
|
}
|
|
const rootGroups = [...groupsById.values()].filter(
|
|
(group) => !groupsById.has(group.parentGroupId) && group.children.length > 0,
|
|
);
|
|
|
|
const featuresByKind = new Map();
|
|
for (const entry of featureEntries) {
|
|
if (groupedFeatureIndexes.has(entry.featureIndex)) {
|
|
continue;
|
|
}
|
|
const { kind } = entry.feature.properties;
|
|
const typeSchema = typeSchemasByKind.get(kind);
|
|
const categoryId = typeSchema?.categoryId;
|
|
const typeNode = featuresByKind.get(kind) ?? {
|
|
id: `visibility:type:${kind}`,
|
|
label: typeSchema?.collectionLabel ?? entry.feature.properties.label,
|
|
kind,
|
|
parentKind: typeSchema?.parentKind,
|
|
categoryId,
|
|
children: [],
|
|
};
|
|
typeNode.children.push(toVisibilityLeaf(entry));
|
|
featuresByKind.set(kind, typeNode);
|
|
}
|
|
|
|
for (const typeNode of featuresByKind.values()) {
|
|
const parentTypeNode = featuresByKind.get(typeNode.parentKind);
|
|
if (parentTypeNode) {
|
|
parentTypeNode.children.push(typeNode);
|
|
} else {
|
|
categoriesById.get(typeNode.categoryId)?.children.push(typeNode);
|
|
}
|
|
}
|
|
|
|
for (const group of rootGroups) {
|
|
const parentTypeNode = featuresByKind.get(group.parentKind);
|
|
if (parentTypeNode) {
|
|
parentTypeNode.children.push(group);
|
|
} else {
|
|
categoriesById.get(group.categoryId)?.children.push(group);
|
|
}
|
|
}
|
|
|
|
for (const typeNode of featuresByKind.values()) {
|
|
delete typeNode.kind;
|
|
delete typeNode.parentKind;
|
|
delete typeNode.categoryId;
|
|
}
|
|
for (const group of groupsById.values()) {
|
|
delete group.parentGroupId;
|
|
delete group.parentKind;
|
|
delete group.categoryId;
|
|
}
|
|
|
|
return [...categoriesById.values()]
|
|
.filter((category) => category.children.length > 0)
|
|
.sort((left, right) => left.order - right.order)
|
|
.map((category) => {
|
|
delete category.order;
|
|
return category;
|
|
})
|
|
.map(sortVisibilityNode);
|
|
}
|
|
|
|
function toVisibilityLeaf({ feature, featureIndex }) {
|
|
return {
|
|
id: `visibility:feature:${featureIndex}`,
|
|
label: feature.properties.name,
|
|
metadata: { featureIndex },
|
|
};
|
|
}
|
|
|
|
function sortVisibilityNode(node) {
|
|
if (node.children) {
|
|
node.children = node.children.map(sortVisibilityNode).sort(compareVisibilityNodes);
|
|
}
|
|
return node;
|
|
}
|
|
|
|
function compareVisibilityNodes(left, right) {
|
|
return left.label.localeCompare(right.label, undefined, { numeric: true });
|
|
}
|
|
|
|
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 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 = `<strong>${escapeHtml(name)}</strong>`;
|
|
const subtitle = name === label ? "" : `<div>${escapeHtml(label)}</div>`;
|
|
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]) => `<div><span>${escapeHtml(formatDetailLabel(key))}:</span> ${escapeHtml(formatDetailValue(key, value))}</div>`)
|
|
.join("");
|
|
const linkRows = linkEntries
|
|
.map((link) => `<div><a href="${escapeHtml(link.url)}" target="_blank" rel="noreferrer">Info</a></div>`)
|
|
.join("");
|
|
|
|
return `${title}${subtitle}<div>${detailRows}${measurementTable}${linkRows}</div>`;
|
|
}
|
|
|
|
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) => `
|
|
<tr>
|
|
<td>${escapeHtml(measurement.measuredDate)}</td>
|
|
<td>${escapeHtml(formatOptionalMeasurement(measurement.canopyDiameter))}</td>
|
|
<td>${escapeHtml(formatOptionalMeasurement(measurement.height))}</td>
|
|
</tr>
|
|
`,
|
|
)
|
|
.join("");
|
|
|
|
return `
|
|
<div class="measurement-table-block">
|
|
<div><span>${escapeHtml(formatDetailLabel("measurements"))}:</span></div>
|
|
<table class="measurement-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Date</th>
|
|
<th>Canopy dia.</th>
|
|
<th>Height</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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("'", "'");
|
|
}
|