Prototype

This commit is contained in:
2026-06-26 11:23:09 -05:00
commit 2a52e0546e
19 changed files with 1406 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
import { anchor } from "../lib/geometry.js";
import { lotAssumedNorthClockwiseDegrees } from "./settings.js";
export const topLeftLotCorner = anchor(-100.89895832874616, 46.82679973173392, {
assumedNorthClockwiseDegrees: lotAssumedNorthClockwiseDegrees,
});
export const lotCorner = topLeftLotCorner;
+19
View File
@@ -0,0 +1,19 @@
import { Fence, LotBoundary } from "../lib/yard-features.js";
const lotBoundary = new LotBoundary().trace([
[-100.89895832874616, 46.82679973173392],
[-100.89839320971627, 46.8268766687031],
[-100.89835104203355, 46.82673956300183],
[-100.89891868391628, 46.82666533836659],
]);
const picketFence = new Fence("Short picket fence", {
fenceType: "Short picket",
}).trace([
[-100.89860584453643, 46.82671142438681],
[-100.89891903869233, 46.82666876370629],
[-100.89895733199393, 46.82679939652137],
[-100.89883731628171, 46.82681591825169],
]);
export const boundaryFeatures = [lotBoundary, picketFence];
+42
View File
@@ -0,0 +1,42 @@
import { Deck, defineGroup, Garage, House, Porch } from "../lib/yard-features.js";
export const housePartsGroup = defineGroup("group:house-parts", "House Parts");
const house = new House().withId("feature:house-main").trace([
[-100.89883475400929, 46.826808023423844],
[-100.8986668040935, 46.826830709973976],
[-100.89866031675769, 46.826807530237865],
[-100.89869131180652, 46.82680358474988],
[-100.89867545387456, 46.82675525249843],
[-100.89881168792644, 46.82673552503637],
]);
const garage = new Garage().trace([
[-100.89850173743805, 46.82679569377302],
[-100.89839289435957, 46.82681098253964],
[-100.89837054909181, 46.82674095008918],
[-100.8984801129854, 46.826727140862815],
]);
const porch = new Porch().childOf(house).inGroup(housePartsGroup).trace([
[-100.89886286579778, 46.8268040779359],
[-100.8988346639074, 46.8268079617756],
[-100.8988116428755, 46.8267355250364],
[-100.89875969913811, 46.826743015307995],
[-100.89875316675139, 46.826722208995406],
[-100.8988050654378, 46.82671481119343],
[-100.89881515684904, 46.8267162907539],
[-100.89882614927917, 46.8267201129516],
[-100.89883678130174, 46.82672738745619],
[-100.89884281812813, 46.82673191860895],
]);
const deck = new Deck().childOf(house).inGroup(housePartsGroup).trace([
[-100.89869090634811, 46.826803553925714],
[-100.89864495438617, 46.826809472157606],
[-100.89863017767685, 46.8267616330979],
[-100.89867522861992, 46.82675522167426],
]);
export const buildingFeatures = [house, garage, porch, deck];
export const buildingGroups = [housePartsGroup];
+8
View File
@@ -0,0 +1,8 @@
import { east, ft, offset, south } from "../lib/geometry.js";
import { Sprinkler } from "../lib/yard-features.js";
import { lotCorner } from "./anchors.js";
export const irrigationFeatures = [
new Sprinkler("Front bed sprinkler").add(offset(lotCorner), south(ft(15)), east(ft(10))),
new Sprinkler("Garage side sprinkler").add(offset(lotCorner), south(ft(92)), east(ft(40))),
];
+51
View File
@@ -0,0 +1,51 @@
import { Driveway, Walkway } from "../lib/yard-features.js";
const frontWalk = new Walkway("Front walk").trace([
[-100.89894913835376, 46.82676927748655],
[-100.89887345276941, 46.82677975769509],
[-100.89887849847504, 46.82679985503042],
[-100.89887651623354, 46.82680417040822],
[-100.8988709299166, 46.826808732378645],
[-100.89886462278457, 46.82681082841896],
[-100.89883254651308, 46.82681514379587],
[-100.89883047416976, 46.826808562845976],
[-100.89886426237707, 46.82680492559929],
[-100.89886660502609, 46.826802891207],
[-100.89886716816289, 46.82679990126662],
[-100.89886270811955, 46.82678063618305],
[-100.89885982485919, 46.82678254727965],
[-100.89885694159882, 46.82678341035552],
[-100.89885243650448, 46.826766457077134],
[-100.89885784261767, 46.82676596389077],
[-100.89886288832331, 46.82676682696687],
[-100.89886694290817, 46.82676836817423],
[-100.8989459622624, 46.826758196205],
]);
const driveway = new Driveway().trace([
[-100.89851482473702, 46.82685818966149],
[-100.8984941013032, 46.826796541436565],
[-100.89837336477572, 46.82681355635369],
[-100.89839320971627, 46.8268766687031],
]);
const garageWalk = new Walkway("Garage walk").trace([
[-100.89862628077022, 46.82677780036217],
[-100.8986050167251, 46.82677755376906],
[-100.89853023215954, 46.82678544474858],
[-100.89851239198609, 46.82679937725647],
[-100.89850049853712, 46.82681602228328],
[-100.8984941013032, 46.826796541436565],
[-100.89850173743805, 46.82679569377302],
[-100.89849635385036, 46.82677854014156],
[-100.89862267669479, 46.82676559400101],
]);
const stepPad = new Walkway("Step pad").trace([
[-100.89837579752665, 46.82675708653554],
[-100.89835705633432, 46.826759675764286],
[-100.89835309185132, 46.82674722280572],
[-100.89837111222856, 46.82674500346631],
]);
export const pavementFeatures = [frontWalk, driveway, garageWalk, stepPad];
+106
View File
@@ -0,0 +1,106 @@
import { defineGroup, FlowerBed, Tree } from "../lib/yard-features.js";
import { east, ft, lngLat, offset } from "../lib/geometry.js";
import { topLeftLotCorner } from "./anchors.js";
export const juniperTreesGroup = defineGroup("group:juniper-trees", "Juniper Trees");
const flowerBeds = [
new FlowerBed("Herbs").trace([
[-100.89863446807426, 46.82677625460535],
[-100.89862628077022, 46.82677780036216],
[-100.89863677743469, 46.82681615158733],
[-100.89864678466313, 46.826814966528865],
]),
new FlowerBed("Garden").trace([
[-100.89865910125202, 46.82684024777001],
[-100.89865409763779, 46.82682773882406],
[-100.89853785983037, 46.82684209119338],
[-100.89854344078469, 46.82685618021274],
]),
new FlowerBed("Back day lily").trace([
[-100.89854344078469, 46.82685618021274],
[-100.89852458100802, 46.82685815530862],
[-100.89850764569832, 46.82680482769446],
[-100.89851239198609, 46.82679937725647],
[-100.89851803782018, 46.82680087749872],
[-100.89852554324152, 46.826804564348095],
]),
new FlowerBed("Coneflowers").trace([
[-100.89852458100802, 46.82685815530862],
[-100.89851482473702, 46.82685818966149],
[-100.89850049853712, 46.82681602228328],
[-100.89850764569832, 46.82680482769446],
]),
new FlowerBed("Pathway border").trace([
[-100.89881515684904, 46.82671629075389],
[-100.89881830278543, 46.82671252471132],
[-100.89882138193263, 46.82670673108083],
[-100.89881445385141, 46.8266828981853],
[-100.89877269291729, 46.82668908683872],
[-100.89877211557717, 46.8266923786754],
[-100.89876884398328, 46.82669501214458],
[-100.89876326302891, 46.82669764561363],
[-100.89875979898829, 46.82670304422476],
[-100.89875979898829, 46.826708574508785],
[-100.89876461015585, 46.826720556788906],
[-100.8988050654378, 46.82671481119343],
]),
];
const trees = [
new Tree("Japanese Empress Elm", {
species: "Japanese Empress Elm",
canopyDiameterMeters: 6,
heightMeters: 7,
}).add(lngLat(-100.89889018162828, 46.82675624026659)),
new Tree("Boulevard Linden", {
species: "Boulevard Linden",
canopyDiameterMeters: 3,
heightMeters: 6,
plantedDate: "2021-06-08",
}).add(lngLat(-100.8990003556445, 46.826776648309774)),
new Tree("Boulevard Linden 2", {
species: "Boulevard Linden",
canopyDiameterMeters: 3,
heightMeters: 6,
}).add(lngLat(-100.89898269567485, 46.826709328342595)),
new Tree("Evergreen", {
species: "Evergreen",
canopyDiameterMeters: 8,
heightMeters: 15,
}).add(lngLat(-100.89888268258135, 46.826680107011924)),
new Tree("Medora Juniper 1", {
species: "Medora Juniper",
canopyDiameterMeters: 1.5,
heightMeters: 2.5,
}).inGroup(juniperTreesGroup).add(lngLat(-100.89880933964611, 46.826705259550664)),
new Tree("Medora Juniper 2", {
species: "Medora Juniper",
canopyDiameterMeters: 1.5,
heightMeters: 2.5,
}).inGroup(juniperTreesGroup).add(lngLat(-100.89877004677517, 46.82670396593881)),
new Tree("Medora Juniper 3", {
species: "Medora Juniper",
canopyDiameterMeters: 1.5,
heightMeters: 2.5,
}).inGroup(juniperTreesGroup).add(lngLat(-100.898799106227, 46.82669830398086)),
new Tree("Medora Juniper 4", {
species: "Medora Juniper",
canopyDiameterMeters: 1.5,
heightMeters: 2.5,
}).inGroup(juniperTreesGroup).add(lngLat(-100.89878871410515, 46.82669251034885)),
new Tree("Medora Juniper 5", {
species: "Medora Juniper",
canopyDiameterMeters: 1.5,
heightMeters: 2.5,
}).inGroup(juniperTreesGroup).add(lngLat(-100.8987812086838, 46.82671120797719)),
new Tree("Offset Test Tree", {
species: "Test tree",
canopyDiameterMeters: 4,
heightMeters: 5,
note: "70 feet east of top-left lot corner using assumedNorth",
}).add(offset(topLeftLotCorner), east(ft(70))),
];
export const plantFeatures = [...flowerBeds, ...trees];
export const plantGroups = [juniperTreesGroup];
+1
View File
@@ -0,0 +1 @@
export const lotAssumedNorthClockwiseDegrees = -11.25369719051117;
+20
View File
@@ -0,0 +1,20 @@
import { collectYardData } from "../lib/yard-features.js";
import { boundaryFeatures } from "./boundaries.js";
import { buildingFeatures, buildingGroups } from "./buildings.js";
import { irrigationFeatures } from "./irrigation.js";
import { pavementFeatures } from "./pavement.js";
import { plantFeatures, plantGroups } from "./plants.js";
export const yardGeoJSON = collectYardData(
[
...boundaryFeatures,
...buildingFeatures,
...pavementFeatures,
...plantFeatures,
...irrigationFeatures,
],
[
...buildingGroups,
...plantGroups,
],
);
+125
View File
@@ -0,0 +1,125 @@
const treeRadiusRange = {
min: 5,
max: 14,
};
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
export const featureTypes = {
lotBoundary: {
label: "Lot Boundary",
style: {
color: "#7b684d",
weight: 2,
dashArray: "8 5",
fillColor: "#dccaab",
fillOpacity: 0.08,
},
},
fence: {
label: "Fence",
style: {
color: "#705033",
weight: 4,
opacity: 0.95,
},
},
house: {
label: "House",
style: {
color: "#8d5a35",
weight: 2,
fillColor: "#c98d58",
fillOpacity: 0.35,
},
},
garage: {
label: "Garage",
style: {
color: "#60636b",
weight: 2,
fillColor: "#a4acb9",
fillOpacity: 0.45,
},
},
porch: {
label: "Porch",
style: {
color: "#aa7148",
weight: 2,
fillColor: "#deb588",
fillOpacity: 0.38,
},
},
deck: {
label: "Deck",
style: {
color: "#7f5a3c",
weight: 2,
fillColor: "#b98c66",
fillOpacity: 0.32,
},
},
walkway: {
label: "Walkway",
style: {
color: "#b8b3a6",
weight: 2,
fillColor: "#d6d1c3",
fillOpacity: 0.75,
},
},
driveway: {
label: "Driveway",
style: {
color: "#9c9890",
weight: 2,
fillColor: "#bbb7af",
fillOpacity: 0.8,
},
},
flowerBed: {
label: "Flower Bed",
style: {
color: "#7f7f31",
weight: 2,
fillColor: "#bfc97b",
fillOpacity: 0.38,
},
},
tree: {
label: "Tree",
style: (details) => ({
color: "#2f6b3d",
weight: 2,
radius: clamp(4 + (details.canopyDiameterMeters ?? 2), treeRadiusRange.min, treeRadiusRange.max),
fillColor: "#5c9e64",
fillOpacity: 0.85,
}),
},
sprinkler: {
label: "Sprinkler",
style: {
color: "#2d7dd2",
radius: 5,
fillColor: "#88bdf2",
fillOpacity: 0.95,
},
},
};
export function getFeatureType(kind) {
const definition = featureTypes[kind];
if (!definition) {
throw new Error(`Unknown feature type: ${kind}`);
}
return definition;
}
export function resolveFeatureStyle(kind, details = {}) {
const { style } = getFeatureType(kind);
return typeof style === "function" ? style(details) : style;
}
+204
View File
@@ -0,0 +1,204 @@
const FEET_PER_METER = 3.280839895;
const METERS_PER_FOOT = 1 / FEET_PER_METER;
const EARTH_METERS_PER_DEGREE_LAT = 111320;
function toRadians(value) {
return (value * Math.PI) / 180;
}
function rotateLocalToTrueNorthEast(northMeters, eastMeters, assumedNorthClockwiseDegrees = 0) {
const clockwiseRadians = toRadians(assumedNorthClockwiseDegrees);
const trueNorthMeters =
northMeters * Math.cos(clockwiseRadians) - eastMeters * Math.sin(clockwiseRadians);
const trueEastMeters =
northMeters * Math.sin(clockwiseRadians) + eastMeters * Math.cos(clockwiseRadians);
return {
northMeters: trueNorthMeters,
eastMeters: trueEastMeters,
};
}
export function point(lat, lon) {
return { lat, lon };
}
export function lngLat(lon, lat) {
return point(lat, lon);
}
export function anchor(lon, lat, options = {}) {
return {
...lngLat(lon, lat),
...options,
};
}
export function pointsFromLngLat(coordinates) {
return coordinates.map(([lon, lat]) => lngLat(lon, lat));
}
export function ft(value) {
return value * METERS_PER_FOOT;
}
function isPoint(value) {
return Boolean(value) && typeof value.lat === "number" && typeof value.lon === "number";
}
function isMove(value) {
return (
Boolean(value) &&
typeof value.northMeters === "number" &&
typeof value.eastMeters === "number"
);
}
function isOffsetAnchor(value) {
return Boolean(value) && value.kind === "offset-anchor" && isPoint(value.origin);
}
function translate(origin, northMeters, eastMeters) {
const latDelta = northMeters / EARTH_METERS_PER_DEGREE_LAT;
const lonDelta = eastMeters / (EARTH_METERS_PER_DEGREE_LAT * Math.cos(toRadians(origin.lat)));
return point(origin.lat + latDelta, origin.lon + lonDelta);
}
export function offset(origin, ...moves) {
if (moves.length === 0) {
return { kind: "offset-anchor", origin };
}
const net = moves.reduce(
(accumulator, move) => ({
northMeters: accumulator.northMeters + move.northMeters,
eastMeters: accumulator.eastMeters + move.eastMeters,
}),
{ northMeters: 0, eastMeters: 0 },
);
const adjusted = rotateLocalToTrueNorthEast(
net.northMeters,
net.eastMeters,
origin.assumedNorthClockwiseDegrees ?? 0,
);
return translate(origin, adjusted.northMeters, adjusted.eastMeters);
}
export function north(distanceMeters) {
return { northMeters: distanceMeters, eastMeters: 0 };
}
export function south(distanceMeters) {
return { northMeters: -distanceMeters, eastMeters: 0 };
}
export function east(distanceMeters) {
return { northMeters: 0, eastMeters: distanceMeters };
}
export function west(distanceMeters) {
return { northMeters: 0, eastMeters: -distanceMeters };
}
function buildFeature(type, geometryType, style = {}) {
const coordinates = [];
return {
add(...values) {
coordinates.push(resolveCoordinate(values));
return this;
},
toGeoJSON() {
if (geometryType === "Point") {
const [first] = coordinates;
return {
type: "Feature",
properties: { type, style },
geometry: {
type: "Point",
coordinates: [first.lon, first.lat],
},
};
}
const ring = coordinates.map((coordinate) => [coordinate.lon, coordinate.lat]);
const geometry =
geometryType === "LineString"
? { type: "LineString", coordinates: ring }
: { type: "Polygon", coordinates: [closeRing(ring)] };
return {
type: "Feature",
properties: { type, style },
geometry,
};
},
};
}
function resolveCoordinate(values) {
if (values.length === 1 && isPoint(values[0])) {
return values[0];
}
const [first, ...rest] = values;
if (isOffsetAnchor(first) && rest.every(isMove)) {
return offset(first.origin, ...rest);
}
throw new Error("add(...) expects a point, or offset(origin) followed by directional moves");
}
function closeRing(coordinates) {
if (coordinates.length === 0) {
return coordinates;
}
const [firstLon, firstLat] = coordinates[0];
const [lastLon, lastLat] = coordinates[coordinates.length - 1];
if (firstLon === lastLon && firstLat === lastLat) {
return coordinates;
}
return [...coordinates, coordinates[0]];
}
export function polygon(type, style) {
return buildFeature(type, "Polygon", style);
}
export function path(type, style) {
return buildFeature(type, "LineString", style);
}
export function marker(type, style) {
return buildFeature(type, "Point", style);
}
export function sprinkler(name) {
return marker(name, {
color: "#2d7dd2",
radius: 5,
fillColor: "#88bdf2",
fillOpacity: 0.95,
});
}
export function tree(name) {
return marker(name, {
color: "#2f6b3d",
radius: 9,
fillColor: "#5c9e64",
fillOpacity: 0.85,
});
}
export function collectGeoJSON(...collections) {
return {
type: "FeatureCollection",
features: collections.flat().map((entry) => entry.toGeoJSON()),
};
}
+279
View File
@@ -0,0 +1,279 @@
import { collectGeoJSON, offset, pointsFromLngLat } from "./geometry.js";
import { getFeatureType, resolveFeatureStyle } from "./feature-types.js";
class YardFeature {
constructor(kind, geometryType, name, details = {}) {
this.kind = kind;
this.geometryType = geometryType;
this.name = name ?? getFeatureType(kind).label;
this.id = buildFeatureId(kind, this.name);
this.details = { ...details };
this.coordinates = [];
this.parentId = null;
this.groupIds = [];
}
add(...values) {
this.coordinates.push(resolveCoordinate(values));
return this;
}
trace(coordinates) {
for (const coordinate of pointsFromLngLat(coordinates)) {
this.add(coordinate);
}
return this;
}
withDetails(details) {
Object.assign(this.details, details);
return this;
}
withId(id) {
this.id = id;
return this;
}
childOf(parent) {
this.parentId = resolveFeatureReference(parent);
return this;
}
inGroup(...groups) {
this.groupIds.push(...groups.map(resolveGroupReference));
this.groupIds = [...new Set(this.groupIds)];
return this;
}
toGeoJSON() {
const label = getFeatureType(this.kind).label;
const style = resolveFeatureStyle(this.kind, this.details);
if (this.geometryType === "Point") {
const [first] = this.coordinates;
return {
type: "Feature",
properties: {
id: this.id,
kind: this.kind,
label,
name: this.name,
parentId: this.parentId,
groupIds: this.groupIds,
details: this.details,
style,
},
geometry: {
type: "Point",
coordinates: [first.lon, first.lat],
},
};
}
const coordinates = this.coordinates.map((coordinate) => [coordinate.lon, coordinate.lat]);
const geometry =
this.geometryType === "LineString"
? { type: "LineString", coordinates }
: { type: "Polygon", coordinates: [closeRing(coordinates)] };
return {
type: "Feature",
properties: {
id: this.id,
kind: this.kind,
label,
name: this.name,
parentId: this.parentId,
groupIds: this.groupIds,
details: this.details,
style,
},
geometry,
};
}
}
function buildFeatureId(kind, name) {
return `${kind}:${slugify(name)}`;
}
function slugify(value) {
return String(value)
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/^-+|-+$/g, "")
.replaceAll(/-{2,}/g, "-");
}
function resolveFeatureReference(featureOrId) {
if (typeof featureOrId === "string") {
return featureOrId;
}
if (featureOrId && typeof featureOrId.id === "string") {
return featureOrId.id;
}
throw new Error("Expected a feature or feature id");
}
function resolveGroupReference(groupOrId) {
if (typeof groupOrId === "string") {
return groupOrId;
}
if (groupOrId && typeof groupOrId.id === "string") {
return groupOrId.id;
}
throw new Error("Expected a group or group id");
}
export function defineGroup(id, label, options = {}) {
return {
id,
label,
parentGroupId: options.parentGroupId ?? null,
};
}
class PolygonFeature extends YardFeature {
constructor(kind, name, details) {
super(kind, "Polygon", name, details);
}
}
class LineFeature extends YardFeature {
constructor(kind, name, details) {
super(kind, "LineString", name, details);
}
}
class PointFeature extends YardFeature {
constructor(kind, name, details) {
super(kind, "Point", name, details);
}
}
function closeRing(coordinates) {
if (coordinates.length === 0) {
return coordinates;
}
const [firstLon, firstLat] = coordinates[0];
const [lastLon, lastLat] = coordinates[coordinates.length - 1];
if (firstLon === lastLon && firstLat === lastLat) {
return coordinates;
}
return [...coordinates, coordinates[0]];
}
function isPoint(value) {
return Boolean(value) && typeof value.lat === "number" && typeof value.lon === "number";
}
function isMove(value) {
return (
Boolean(value) &&
typeof value.northMeters === "number" &&
typeof value.eastMeters === "number"
);
}
function isOffsetAnchor(value) {
return Boolean(value) && value.kind === "offset-anchor" && isPoint(value.origin);
}
function resolveCoordinate(values) {
if (values.length === 1 && isPoint(values[0])) {
return values[0];
}
const [first, ...rest] = values;
if (isOffsetAnchor(first) && rest.every(isMove)) {
return offset(first.origin, ...rest);
}
throw new Error("add(...) expects a point, or offset(origin) followed by directional moves");
}
export class LotBoundary extends PolygonFeature {
constructor(name = "Lot Boundary", details = {}) {
super("lotBoundary", name, details);
}
}
export class Fence extends LineFeature {
constructor(name = "Fence", details = {}) {
super("fence", name, details);
}
}
export class House extends PolygonFeature {
constructor(name = "House", details = {}) {
super("house", name, details);
}
}
export class Garage extends PolygonFeature {
constructor(name = "Garage", details = {}) {
super("garage", name, details);
}
}
export class Porch extends PolygonFeature {
constructor(name = "Porch", details = {}) {
super("porch", name, details);
}
}
export class Deck extends PolygonFeature {
constructor(name = "Deck", details = {}) {
super("deck", name, details);
}
}
export class Walkway extends PolygonFeature {
constructor(name = "Walkway", details = {}) {
super("walkway", name, details);
}
}
export class Driveway extends PolygonFeature {
constructor(name = "Driveway", details = {}) {
super("driveway", name, details);
}
}
export class FlowerBed extends PolygonFeature {
constructor(name = "Flower Bed", details = {}) {
super("flowerBed", name, details);
}
}
export class Tree extends PointFeature {
constructor(name = "Tree", details = {}) {
super("tree", name, details);
}
}
export class Sprinkler extends PointFeature {
constructor(name = "Sprinkler", details = {}) {
super("sprinkler", name, details);
}
}
export function collectYardData(features, groups = []) {
const collection = collectGeoJSON(features);
collection.groups = groups.map((group) => ({
id: group.id,
label: group.label,
parentGroupId: group.parentGroupId ?? null,
}));
return collection;
}
export { collectGeoJSON };
+139
View File
@@ -0,0 +1,139 @@
import { yardGeoJSON } from "./data/yard.js";
const TILE_MAX_NATIVE_ZOOM = 19;
const MAP_MAX_ZOOM = 24;
const map = L.map("map", {
zoomControl: false,
maxZoom: MAP_MAX_ZOOM,
});
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);
const geoJsonLayer = L.geoJSON(yardGeoJSON, {
pointToLayer(feature, latlng) {
const style = feature.properties.style;
return L.circleMarker(latlng, {
radius: style.radius ?? 6,
color: style.color ?? "#333333",
fillColor: style.fillColor ?? style.color ?? "#333333",
fillOpacity: style.fillOpacity ?? 0.8,
weight: style.weight ?? 2,
});
},
style(feature) {
return feature.properties.style;
},
onEachFeature(feature, layer) {
layer.bindPopup(buildPopupContent(feature.properties));
},
}).addTo(map);
map.fitBounds(geoJsonLayer.getBounds().pad(0.2), { maxZoom: MAP_MAX_ZOOM });
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 existing = summary.get(key) ?? {
label: feature.properties.label,
count: 0,
color: feature.properties.style.color ?? "#333333",
};
existing.count += 1;
summary.set(key, existing);
}
return [...summary.values()];
}
function buildPopupContent(properties) {
const { label, name, details, parentId, groupIds } = properties;
const entries = Object.entries(details ?? {}).filter(([, value]) => value !== null && value !== undefined);
const relationshipEntries = [];
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 = [...entries, ...relationshipEntries];
if (allEntries.length === 0) {
return `${title}${subtitle}`;
}
const detailRows = allEntries
.map(([key, value]) => `<div><span>${escapeHtml(formatDetailLabel(key))}:</span> ${escapeHtml(formatDetailValue(key, value))}</div>`)
.join("");
return `${title}${subtitle}<div>${detailRows}</div>`;
}
function formatDetailLabel(key) {
const labels = {
canopyDiameterMeters: "Canopy diameter",
heightMeters: "Height",
plantedDate: "Planted",
species: "Species",
fenceType: "Fence type",
note: "Note",
parentFeature: "Parent",
groups: "Groups",
};
return labels[key] ?? key;
}
function formatDetailValue(key, value) {
if (key === "canopyDiameterMeters" || key === "heightMeters") {
return `${value} m`;
}
return String(value);
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}