Files
a-plot-of-plants/src/main.js
T
2026-06-26 15:48:12 -05:00

277 lines
7.0 KiB
JavaScript

import { yardGeoJSON } from "./data/yard.js";
import { resolveFeatureLinks, resolveFeatureStyle } from "./lib/feature-types.js";
const TILE_MAX_NATIVE_ZOOM = 19;
const MAP_MAX_ZOOM = 24;
const map = L.map("map", {
zoomControl: false,
maxZoom: MAP_MAX_ZOOM,
});
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: '&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);
map.fitBounds(getGeoJSONBounds(yardGeoJSON).pad(0.2), { maxZoom: MAP_MAX_ZOOM });
updateMeterScaledIcons();
map.on("zoomend", updateMeterScaledIcons);
const legend = document.getElementById("legend");
const legendEntries = summarizeTypes(yardGeoJSON.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();
for (const feature of features) {
const key = feature.properties.kind;
const style = getStyle(feature);
const existing = summary.get(key) ?? {
label: feature.properties.label,
count: 0,
color: style.color ?? "#333333",
};
existing.count += 1;
summary.set(key, existing);
}
return [...summary.values()];
}
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 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, startDate, endDate, details, parentId, groupIds } = properties;
const entries = Object.entries(details ?? {}).filter(
([key, value]) => value !== null && value !== undefined && !key.endsWith("Url"),
);
const lifecycleEntries = [];
const relationshipEntries = [];
const linkEntries = resolveFeatureLinks(kind, details);
if (startDate) {
lifecycleEntries.push(["startDate", startDate]);
}
if (endDate) {
lifecycleEntries.push(["endDate", endDate]);
}
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];
if (allEntries.length === 0 && linkEntries.length === 0) {
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><span>${escapeHtml(link.label)}:</span> <a href="${escapeHtml(link.url)}" target="_blank" rel="noreferrer">${escapeHtml(link.url)}</a></div>`)
.join("");
return `${title}${subtitle}<div>${detailRows}${linkRows}</div>`;
}
function formatDetailLabel(key) {
const labels = {
canopyDiameterMeters: "Canopy diameter",
heightMeters: "Height",
plantedDate: "Planted",
startDate: "Start",
endDate: "End",
commonName: "Common name",
genus: "Genus",
cultivar: "Cultivar",
bloomColor: "Bloom color",
bloomSeason: "Bloom season",
daylilyDatabaseId: "Daylily database ID",
daylilyDatabaseUrl: "Daylily database URL",
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 === "canopyDiameterMeters" || key === "heightMeters") {
return `${value} m`;
}
if (key === "offsetFeet" || key === "spacingFeet") {
return `${value} ft`;
}
return String(value);
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}