277 lines
7.0 KiB
JavaScript
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: '© <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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|