Merge remote-tracking branch 'origin/main'

# Conflicts:
#	AGENTS.md
#	README.md
#	index.html
#	package.json
#	src/data/buildings.js
#	src/data/grid.js
#	src/data/pavement.js
#	src/data/plants.js
#	src/data/yard.js
#	src/lib/feature-types.js
#	src/lib/yard-features.js
#	src/main.js
#	styles.css
This commit is contained in:
2026-07-25 21:59:24 -05:00
20 changed files with 2031 additions and 191 deletions
+10 -4
View File
@@ -1,8 +1,14 @@
import { Deck, defineGroup, Garage, House, Porch } from "../lib/yard-features.js";
export const housePartsGroup = defineGroup("group:house-parts", "House Parts");
export const buildingsGroup = defineGroup("group:buildings", "Buildings", {
categoryId: "structures",
});
export const housePartsGroup = defineGroup("group:house-parts", "House Parts", {
categoryId: "structures",
parentGroupId: buildingsGroup.id,
});
const house = new House().withId("feature:house-main").trace([
const house = new House().withId("feature:house-main").inGroup(buildingsGroup).trace([
[-100.89883475400929, 46.826808023423844],
[-100.8986668040935, 46.826830709973976],
[-100.89866031675769, 46.826807530237865],
@@ -11,7 +17,7 @@ const house = new House().withId("feature:house-main").trace([
[-100.89881168792644, 46.82673552503637],
]);
const garage = new Garage().trace([
const garage = new Garage().inGroup(buildingsGroup).trace([
[-100.89850173743805, 46.82679569377302],
[-100.89839289435957, 46.82681098253964],
[-100.89837054909181, 46.82674095008918],
@@ -39,4 +45,4 @@ const deck = new Deck().childOf(house).inGroup(housePartsGroup).trace([
]);
export const buildingFeatures = [house, garage, porch, deck];
export const buildingGroups = [housePartsGroup];
export const buildingGroups = [buildingsGroup, housePartsGroup];
+3 -1
View File
@@ -8,7 +8,9 @@ const MAX_EAST_FEET = 180;
const MIN_NORTH_FEET = -140;
const MAX_NORTH_FEET = 20;
export const yardGridGroup = defineGroup("group:yard-grid", "Yard Grid");
export const yardGridGroup = defineGroup("group:yard-grid", "Yard Grid", {
categoryId: "reference",
});
function localPoint(eastFeet, northFeet) {
return geo.offset(
+11 -6
View File
@@ -1,6 +1,10 @@
import { Driveway, Walkway } from "../lib/yard-features.js";
import { defineGroup, Driveway, Walkway } from "../lib/yard-features.js";
const frontWalk = new Walkway("Front walk").trace([
export const pavedSurfacesGroup = defineGroup("group:paved-surfaces", "Paved Surfaces", {
categoryId: "structures",
});
const frontWalk = new Walkway("Front walk").inGroup(pavedSurfacesGroup).trace([
[-100.89894913835376, 46.82676927748655],
[-100.89887345276941, 46.82677975769509],
[-100.89887849847504, 46.82679985503042],
@@ -22,14 +26,14 @@ const frontWalk = new Walkway("Front walk").trace([
[-100.8989459622624, 46.826758196205],
]);
const driveway = new Driveway().trace([
const driveway = new Driveway().inGroup(pavedSurfacesGroup).trace([
[-100.89851482473702, 46.82685818966149],
[-100.8984941013032, 46.826796541436565],
[-100.89837336477572, 46.82681355635369],
[-100.89839320971627, 46.8268766687031],
]);
const garageWalk = new Walkway("Garage walk").trace([
const garageWalk = new Walkway("Garage walk").inGroup(pavedSurfacesGroup).trace([
[-100.89862628077022, 46.82677780036217],
[-100.8986050167251, 46.82677755376906],
[-100.89853023215954, 46.82678544474858],
@@ -41,11 +45,12 @@ const garageWalk = new Walkway("Garage walk").trace([
[-100.89862267669479, 46.82676559400101],
]);
const stepPad = new Walkway("Step pad").trace([
const stepPad = new Walkway("Step pad").inGroup(pavedSurfacesGroup).trace([
[-100.89837579752665, 46.82675708653554],
[-100.89835705633432, 46.826759675764286],
[-100.89835309185132, 46.82674722280572],
[-100.89837111222856, 46.82674500346631],
]);
export const pavementFeatures = [frontWalk, driveway, garageWalk, stepPad];
export const pavementFeatures = [frontWalk, driveway, garageWalk, stepPad];
export const pavementGroups = [pavedSurfacesGroup];
+161 -74
View File
@@ -1,8 +1,11 @@
import { Daylily, defineGroup, Flower, FlowerBed, Tree } from "../lib/yard-features.js";
import { Bush, Daylily, defineGroup, Flower, FlowerBed, Tree } from "../lib/yard-features.js";
import * as geo from "../lib/geometry.js";
import { lotCorner } from "./anchors.js";
export const juniperTreesGroup = defineGroup("group:juniper-trees", "Juniper Trees");
export const juniperTreesGroup = defineGroup("group:juniper-trees", "Junipers", {
categoryId: "plants",
parentKind: "bush",
});
const flowerBeds = [
new FlowerBed("Herbs").trace([
@@ -48,25 +51,47 @@ const flowerBeds = [
];
const daylilies = [
new Daylily("Not sure", {
gardenOrgId: "4765",
}).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(2))),
new Daylily("Trahlyta", {
gardenOrgId: "18613",
}).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(1))),
new Daylily("Wayside King Royale", {
gardenOrgId: "5090",
}).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(4))),
}).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(3))),
new Daylily("Night Stalker", {
gardenOrgId: "58861",
plantedDate: "2023-05-01",
pricePaid: 16,
}).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(5))),
new Daylily("Lemon Fringed Lemons", {
gardenOrgId: "185536",
plantedDate: "2023-05-01",
pricePaid: 15,
}).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(7))),
new Daylily("Joan Derifield", {
gardenOrgId: "4765",
}).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(6))),
plantedDate: "2023-05-01",
pricePaid: 15,
}).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(9))),
new Daylily("Predatory Flamingo", {
gardenOrgId: "61201",
}).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(8))),
new Daylily("Unknown", {
plantedDate: "2023-05-01",
pricePaid: 6,
}).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(11))),
new Daylily("Double Me", {
gardenOrgId: "7872",
plantedDate: "2022-05-01",
}).add(geo.offset(lotCorner), geo.east(geo.ft(109)), geo.south(geo.ft(7))),
new Daylily("Unknown", {
new Daylily("Lipstick Spitfire", {
gardenOrgId: "708897",
plantedDate: "2022-05-01",
}).add(geo.offset(lotCorner), geo.east(geo.ft(109)), geo.south(geo.ft(9))),
new Daylily("Unknown", {
new Daylily("Jerry Hyatt", {
gardenOrgId: "32921",
plantedDate: "2022-05-01",
}).add(geo.offset(lotCorner), geo.east(geo.ft(107)), geo.south(geo.ft(8))),
new Daylily("Unknown", {
new Daylily("Take Me Home", {
gardenOrgId: "791256",
plantedDate: "2022-05-01",
}).add(geo.offset(lotCorner), geo.east(geo.ft(107)), geo.south(geo.ft(10))),
new Daylily("Orange Daylily", {
gardenOrgId: "48484",
@@ -92,13 +117,48 @@ const daylilies = [
new Daylily("Orange Daylily", {
gardenOrgId: "48484",
}).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(38))),
new Daylily("Extra daylily", {
gardenOrgId: "",
}).add(geo.offset(lotCorner), geo.east(geo.ft(0.8)), geo.south(geo.ft(3.2))),
new Daylily("Extra daylily", {
gardenOrgId: "",
}).add(geo.offset(lotCorner), geo.east(geo.ft(0.8)), geo.south(geo.ft(3.9))),
new Daylily("Extra daylily", {
gardenOrgId: "",
}).add(geo.offset(lotCorner), geo.east(geo.ft(1.3)), geo.south(geo.ft(3.6))),
new Daylily("Extra daylily", {
gardenOrgId: "",
}).add(geo.offset(lotCorner), geo.east(geo.ft(1)), geo.south(geo.ft(9))),
new Daylily("Extra daylily", {
gardenOrgId: "",
}).add(geo.offset(lotCorner), geo.east(geo.ft(1)), geo.south(geo.ft(17))),
new Daylily("Can't read", {
gardenOrgId: "",
}).add(geo.offset(lotCorner), geo.east(geo.ft(1)), geo.south(geo.ft(25))),
new Daylily("J.T. Davis", {
gardenOrgId: "29649",
plantedDate: "2023-05-01",
pricePaid: 21,
}).add(geo.offset(lotCorner), geo.east(geo.ft(1)), geo.south(geo.ft(31))),
new Daylily("Tea for Teagan", {
gardenOrgId: "787574",
plantedDate: "2023-05-01",
pricePaid: 15,
}).add(geo.offset(lotCorner), geo.east(geo.ft(1)), geo.south(geo.ft(34))),
new Daylily("Burning So Brightly", {
gardenOrgId: "610042",
plantedDate: "2023-05-01",
pricePaid: 17,
}).add(geo.offset(lotCorner), geo.east(geo.ft(2)), geo.south(geo.ft(32.5))),
new Daylily("You Had Me at Hello", {
gardenOrgId: "235117",
}).add(geo.offset(lotCorner), geo.east(geo.ft(28)), geo.south(geo.ft(45))),
new Daylily("Mapping North Dakota", {
gardenOrgId: "56003",
}).add(geo.offset(lotCorner), geo.east(geo.ft(28)), geo.south(geo.ft(47))),
new Daylily("Daylily").add(geo.offset(lotCorner), geo.east(geo.ft(31)), geo.south(geo.ft(47))),
new Daylily("Monster", {
gardenOrgId: "15661",
}).add(geo.offset(lotCorner), geo.east(geo.ft(31)), geo.south(geo.ft(47))),
];
const flowers = [
@@ -121,14 +181,14 @@ const flowers = [
new Flower("Tiny Tortuga Turtlehead", {
gardenOrgId: "697986",
plantedDate: "2021-09-21",
}).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(8))),
}).add(geo.offset(lotCorner), geo.east(geo.ft(2.2)), geo.south(geo.ft(8.2))),
new Flower("Tiny Tortuga Turtlehead", {
gardenOrgId: "697986",
plantedDate: "2021-09-21",
}).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(17))),
new Flower("Peony", {
gardenOrgId: "697986",
}).add(geo.offset(lotCorner), geo.east(geo.ft(19)), geo.south(geo.ft(1.5))),
}).add(geo.offset(lotCorner), geo.east(geo.ft(18.7)), geo.south(geo.ft(1.6))),
new Flower("Peony", {
gardenOrgId: "697986",
}).add(geo.offset(lotCorner), geo.east(geo.ft(75)), geo.south(geo.ft(2))),
@@ -142,23 +202,19 @@ const houseplants = [
const cutDownTrees = [
new Tree("Maybe elm", {
canopyDiameterMeters: 15,
heightMeters: 15,
measurements: [{ canopyDiameter: geo.ft(49.21), height: geo.ft(49.21) }],
removedDate: "2021-05-05",
}).add(geo.offset(lotCorner), geo.east(geo.ft(65)), geo.south(geo.ft(-2))),
new Tree("Big pine tree", {
canopyDiameterMeters: 8,
heightMeters: 15,
measurements: [{ canopyDiameter: geo.ft(26.25), height: geo.ft(49.21) }],
removedDate: "2021-05-06",
}).add(geo.offset(lotCorner), geo.east(geo.ft(98)), geo.south(geo.ft(-15))),
new Tree("Walnut by house", {
canopyDiameterMeters: 6,
heightMeters: 7,
measurements: [{ canopyDiameter: geo.ft(19.69), height: geo.ft(22.97) }],
removedDate: "2021-05-01",
}).add(geo.offset(lotCorner), geo.east(geo.ft(32)), geo.south(geo.ft(40))),
new Tree("Walnut by shed", {
canopyDiameterMeters: 7,
heightMeters: 7,
measurements: [{ canopyDiameter: geo.ft(22.97), height: geo.ft(22.97) }],
removedDate: "2021-05-01",
}).add(geo.offset(lotCorner), geo.east(geo.ft(100)), geo.south(geo.ft(40))),
];
@@ -166,121 +222,152 @@ const cutDownTrees = [
const trees = [
new Tree("Northern Empress Japanese Elm", {
species: "Northern Empress Japanese Elm",
canopyDiameterMeters: 6,
heightMeters: 7,
measurements: [{ canopyDiameter: geo.ft(19.69), height: geo.ft(22.97) }],
plantedDate: "2021-09-21",
pricePaid: 312,
}).add(geo.offset(lotCorner), geo.east(geo.ft(9)), geo.south(geo.ft(16))),
new Tree("Hot Wings Maple", {
species: "Hot Wings Maple",
canopyDiameterMeters: 5,
heightMeters: 5,
measurements: [{ canopyDiameter: geo.ft(16.4), height: geo.ft(16.4) }],
gardenOrgId: "536120",
plantedDate: "2021-09-21",
pricePaid: 186,
}).add(geo.offset(lotCorner), geo.east(geo.ft(60)), geo.south(geo.ft(40))),
new Tree("Prairie Expedition American Elm", {
species: "Prairie Expedition American Elm",
canopyDiameterMeters: 5,
heightMeters: 5,
measurements: [{ canopyDiameter: geo.ft(16.4), height: geo.ft(16.4) }],
gardenOrgId: "736267",
plantedDate: "2021-09-21",
pricePaid: 280,
}).add(geo.offset(lotCorner), geo.east(geo.ft(95)), geo.south(geo.ft(15))),
new Tree("Dwarf Korean Lilac", {
species: "Dwarf Korean Lilac",
canopyDiameterMeters: 1.75,
heightMeters: 2.5,
measurements: [
{ measuredDate: "2026-07-08", canopyDiameter: geo.ft(5.5), height: geo.ft(6.7) },
],
gardenOrgId: "79137",
plantedDate: "2021-09-21",
}).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(3))),
pricePaid: 218,
}).add(geo.offset(lotCorner), geo.east(geo.ft(3.5)), geo.south(geo.ft(4.8))),
new Tree("Dwarf Korean Lilac", {
species: "Dwarf Korean Lilac",
canopyDiameterMeters: 1.75,
heightMeters: 2.5,
measurements: [
{ measuredDate: "2026-07-08", canopyDiameter: geo.ft(5.0), height: geo.ft(6.8) },
],
gardenOrgId: "79137",
plantedDate: "2021-09-21",
}).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(20))),
pricePaid: 218,
}).add(geo.offset(lotCorner), geo.east(geo.ft(3.7)), geo.south(geo.ft(21.4))),
new Tree("Dwarf Korean Lilac", {
species: "Dwarf Korean Lilac",
canopyDiameterMeters: 1.75,
heightMeters: 2.5,
measurements: [
{ measuredDate: "2026-07-08", canopyDiameter: geo.ft(5.5), height: geo.ft(7.5) },
],
gardenOrgId: "79137",
plantedDate: "2021-09-21",
}).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(37))),
pricePaid: 218,
}).add(geo.offset(lotCorner), geo.east(geo.ft(3.8)), geo.south(geo.ft(33.1))),
new Tree("Boulevard Linden", {
species: "Boulevard Linden",
canopyDiameterMeters: 3,
heightMeters: 6,
measurements: [{ canopyDiameter: geo.ft(9.84), height: geo.ft(19.69) }],
plantedDate: "2021-09-21",
pricePaid: 327,
}).add(geo.lngLat(-100.8990003556445, 46.826776648309774)),
new Tree("Boulevard Linden 2", {
species: "Boulevard Linden",
canopyDiameterMeters: 3,
heightMeters: 6,
measurements: [{ canopyDiameter: geo.ft(9.84), height: geo.ft(19.69) }],
plantedDate: "2021-09-21",
pricePaid: 327,
}).add(geo.lngLat(-100.89898269567485, 46.826709328342595)),
new Tree("Evergreen", {
species: "Evergreen",
canopyDiameterMeters: 8,
heightMeters: 15,
measurements: [{ canopyDiameter: geo.ft(26.25), height: geo.ft(49.21) }],
}).add(geo.lngLat(-100.89888268258135, 46.826680107011924)),
new Tree("Black Walnut from Jean", {
species: "Black Walnut",
measurements: [
{ measuredDate: "2026-07-08", canopyDiameter: geo.ft(2.7), height: geo.ft(2.9) }
],
plantedDate: "2024-10-01",
}).add(geo.offset(lotCorner), geo.east(geo.ft(2.8)), geo.south(geo.ft(9.2))),
new Tree("Black Walnut from Jean", {
species: "Black Walnut",
measurements: [
{ measuredDate: "2026-07-08", canopyDiameter: geo.ft(2.5), height: geo.ft(3.5) }
],
plantedDate: "2024-10-01",
}).add(geo.offset(lotCorner), geo.east(geo.ft(2.6)), geo.south(geo.ft(11.3))),
new Tree("Black Walnut from Jean", {
species: "Black Walnut",
measurements: [
{ measuredDate: "2026-07-08", canopyDiameter: geo.ft(4), height: geo.ft(4.5) }
],
plantedDate: "2024-10-01",
}).add(geo.offset(lotCorner), geo.east(geo.ft(1.6)), geo.south(geo.ft(12.7))),
];
const bushes = [
new Tree("Medora Juniper 1", {
new Bush("Medora Juniper 1", {
species: "Medora Juniper",
canopyDiameterMeters: 1.5,
heightMeters: 2.5,
measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(8.2) }],
plantedDate: "2021-09-21",
pricePaid: 96.50,
}).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89880933964611, 46.826705259550664)),
new Tree("Medora Juniper 2", {
new Bush("Medora Juniper 2", {
species: "Medora Juniper",
canopyDiameterMeters: 1.5,
heightMeters: 2.5,
measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(8.2) }],
plantedDate: "2021-09-21",
pricePaid: 96.50,
}).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89877004677517, 46.82670396593881)),
new Tree("Medora Juniper 3", {
new Bush("Medora Juniper 3", {
species: "Medora Juniper",
canopyDiameterMeters: 1.5,
heightMeters: 2.5,
measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(8.2) }],
plantedDate: "2021-09-21",
pricePaid: 96.50,
}).inGroup(juniperTreesGroup).add(geo.lngLat(-100.898799106227, 46.82669830398086)),
new Tree("Medora Juniper 4", {
new Bush("Medora Juniper 4", {
species: "Medora Juniper",
canopyDiameterMeters: 1.5,
heightMeters: 2.5,
measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(8.2) }],
plantedDate: "2021-09-21",
pricePaid: 96.50,
}).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89878871410515, 46.82669251034885)),
new Tree("Medora Juniper 5", {
new Bush("Medora Juniper 5", {
species: "Medora Juniper",
canopyDiameterMeters: 1.5,
heightMeters: 2.5,
measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(8.2) }],
plantedDate: "2021-09-21",
pricePaid: 96.50,
}).inGroup(juniperTreesGroup).add(geo.lngLat(-100.8987812086838, 46.82671120797719)),
new Tree("Black Currant", {
new Bush("Black Currant", {
species: "Black Currant",
canopyDiameterMeters: 2,
heightMeters: 2,
measurements: [{ canopyDiameter: geo.ft(6.56), height: geo.ft(6.56) }],
gardenOrgId: "87767",
}).add(geo.offset(lotCorner), geo.east(geo.ft(107)), geo.south(geo.ft(3))),
new Tree("Elderberry", {
new Bush("Elderberry", {
species: "Elderberry",
canopyDiameterMeters: 3,
heightMeters: 3,
measurements: [{ canopyDiameter: geo.ft(9.84), height: geo.ft(9.84) }],
gardenOrgId: "78882",
}).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(52))),
new Tree("Spirea", {
new Bush("Spirea", {
species: "Spirea",
canopyDiameterMeters: 1.5,
heightMeters: 1,
measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(3.28) }],
gardenOrgId: "79059",
}).add(geo.offset(lotCorner), geo.east(geo.ft(70)), geo.south(geo.ft(31))),
new Tree("Spirea", {
new Bush("Spirea", {
species: "Spirea",
canopyDiameterMeters: 1.5,
heightMeters: 1,
measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(3.28) }],
gardenOrgId: "79059",
}).add(geo.offset(lotCorner), geo.east(geo.ft(74)), geo.south(geo.ft(31))),
new Tree("Spirea", {
new Bush("Spirea", {
species: "Spirea",
canopyDiameterMeters: 1.5,
heightMeters: 1,
measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(3.28) }],
gardenOrgId: "79068",
}).add(geo.offset(lotCorner), geo.east(geo.ft(78)), geo.south(geo.ft(31))),
];
+142
View File
@@ -0,0 +1,142 @@
{
"schema": 1,
"nodes": [
"visibility:category:plants",
"visibility:category:plants>visibility:type:flower",
"visibility:category:plants>visibility:type:flower>visibility:feature:83",
"visibility:category:plants>visibility:type:flower>visibility:feature:84",
"visibility:category:plants>visibility:type:flower>visibility:feature:85",
"visibility:category:plants>visibility:type:flower>visibility:feature:86",
"visibility:category:plants>visibility:type:flower>visibility:feature:87",
"visibility:category:plants>visibility:type:flower>visibility:feature:88",
"visibility:category:plants>visibility:type:flower>visibility:feature:89",
"visibility:category:plants>visibility:type:flower>visibility:feature:90",
"visibility:category:plants>visibility:type:flower>visibility:feature:91",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:53",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:54",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:55",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:56",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:57",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:58",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:59",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:60",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:61",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:62",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:63",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:64",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:65",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:66",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:67",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:68",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:69",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:70",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:71",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:72",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:73",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:74",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:75",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:76",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:77",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:78",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:79",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:80",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:81",
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:82",
"visibility:category:plants>visibility:type:tree",
"visibility:category:plants>visibility:type:tree>visibility:feature:92",
"visibility:category:plants>visibility:type:tree>visibility:feature:93",
"visibility:category:plants>visibility:type:tree>visibility:feature:94",
"visibility:category:plants>visibility:type:tree>visibility:feature:95",
"visibility:category:plants>visibility:type:tree>visibility:feature:96",
"visibility:category:plants>visibility:type:tree>visibility:feature:97",
"visibility:category:plants>visibility:type:tree>visibility:feature:98",
"visibility:category:plants>visibility:type:tree>visibility:feature:99",
"visibility:category:plants>visibility:type:tree>visibility:feature:100",
"visibility:category:plants>visibility:type:tree>visibility:feature:101",
"visibility:category:plants>visibility:type:tree>visibility:feature:102",
"visibility:category:plants>visibility:type:tree>visibility:feature:103",
"visibility:category:plants>visibility:type:tree>visibility:feature:114",
"visibility:category:plants>visibility:type:tree>visibility:feature:115",
"visibility:category:plants>visibility:type:tree>visibility:feature:116",
"visibility:category:plants>visibility:type:tree>visibility:feature:117",
"visibility:category:plants>visibility:type:bush",
"visibility:category:plants>visibility:type:bush>visibility:feature:109",
"visibility:category:plants>visibility:type:bush>visibility:feature:110",
"visibility:category:plants>visibility:type:bush>visibility:feature:111",
"visibility:category:plants>visibility:type:bush>visibility:feature:112",
"visibility:category:plants>visibility:type:bush>visibility:feature:113",
"visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees",
"visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:104",
"visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:105",
"visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:106",
"visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:107",
"visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:108",
"visibility:category:structures",
"visibility:category:structures>visibility:type:fence",
"visibility:category:structures>visibility:type:fence>visibility:feature:1",
"visibility:category:structures>visibility:type:flowerBed",
"visibility:category:structures>visibility:type:flowerBed>visibility:feature:48",
"visibility:category:structures>visibility:type:flowerBed>visibility:feature:49",
"visibility:category:structures>visibility:type:flowerBed>visibility:feature:50",
"visibility:category:structures>visibility:type:flowerBed>visibility:feature:51",
"visibility:category:structures>visibility:type:flowerBed>visibility:feature:52",
"visibility:category:structures>visibility:group:buildings",
"visibility:category:structures>visibility:group:buildings>visibility:feature:40",
"visibility:category:structures>visibility:group:buildings>visibility:feature:41",
"visibility:category:structures>visibility:group:buildings>visibility:group:house-parts",
"visibility:category:structures>visibility:group:buildings>visibility:group:house-parts>visibility:feature:42",
"visibility:category:structures>visibility:group:buildings>visibility:group:house-parts>visibility:feature:43",
"visibility:category:structures>visibility:group:paved-surfaces",
"visibility:category:structures>visibility:group:paved-surfaces>visibility:feature:44",
"visibility:category:structures>visibility:group:paved-surfaces>visibility:feature:45",
"visibility:category:structures>visibility:group:paved-surfaces>visibility:feature:46",
"visibility:category:structures>visibility:group:paved-surfaces>visibility:feature:47",
"visibility:category:infrastructure",
"visibility:category:infrastructure>visibility:type:sprinkler",
"visibility:category:infrastructure>visibility:type:sprinkler>visibility:feature:118",
"visibility:category:infrastructure>visibility:type:sprinkler>visibility:feature:119",
"visibility:category:reference",
"visibility:category:reference>visibility:type:lotBoundary",
"visibility:category:reference>visibility:type:lotBoundary>visibility:feature:0",
"visibility:category:reference>visibility:group:yard-grid",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:2",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:3",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:4",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:5",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:6",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:7",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:8",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:9",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:10",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:11",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:12",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:13",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:14",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:15",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:16",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:17",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:18",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:19",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:20",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:21",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:22",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:23",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:24",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:25",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:26",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:27",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:28",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:29",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:30",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:31",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:32",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:33",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:34",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:35",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:36",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:37",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:38",
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:39"
]
}
+2 -1
View File
@@ -3,7 +3,7 @@ import { boundaryFeatures } from "./boundaries.js";
import { buildingFeatures, buildingGroups } from "./buildings.js";
import { gridFeatures, gridGroups } from "./grid.js";
import { irrigationFeatures } from "./irrigation.js";
import { pavementFeatures } from "./pavement.js";
import { pavementFeatures, pavementGroups } from "./pavement.js";
import { plantFeatures, plantGroups } from "./plants.js";
export const yardGeoJSON = collectYardData(
@@ -18,6 +18,7 @@ export const yardGeoJSON = collectYardData(
[
...buildingGroups,
...gridGroups,
...pavementGroups,
...plantGroups,
],
);
+75 -6
View File
@@ -1,6 +1,14 @@
export const featureCategories = {
plants: { label: "Plants", order: 10 },
structures: { label: "Structures", order: 20 },
infrastructure: { label: "Infrastructure", order: 30 },
reference: { label: "Map Reference", order: 40 },
};
export const featureTypes = {
lotBoundary: {
label: "Lot Boundary",
categoryId: "reference",
geometryType: "Polygon",
detailFields: {},
style: {
@@ -13,6 +21,7 @@ export const featureTypes = {
},
fence: {
label: "Fence",
categoryId: "structures",
geometryType: "LineString",
detailFields: {
fenceType: "string",
@@ -25,6 +34,7 @@ export const featureTypes = {
},
yardGrid: {
label: "Yard Grid",
categoryId: "reference",
geometryType: "LineString",
detailFields: {
axis: "string",
@@ -40,6 +50,8 @@ export const featureTypes = {
},
house: {
label: "House",
collectionLabel: "Houses",
categoryId: "structures",
geometryType: "Polygon",
detailFields: {},
style: {
@@ -51,6 +63,8 @@ export const featureTypes = {
},
garage: {
label: "Garage",
collectionLabel: "Garages",
categoryId: "structures",
geometryType: "Polygon",
detailFields: {},
style: {
@@ -62,6 +76,7 @@ export const featureTypes = {
},
porch: {
label: "Porch",
categoryId: "structures",
geometryType: "Polygon",
detailFields: {},
style: {
@@ -73,6 +88,7 @@ export const featureTypes = {
},
deck: {
label: "Deck",
categoryId: "structures",
geometryType: "Polygon",
detailFields: {},
style: {
@@ -84,6 +100,8 @@ export const featureTypes = {
},
walkway: {
label: "Walkway",
collectionLabel: "Walkways",
categoryId: "structures",
geometryType: "Polygon",
detailFields: {},
style: {
@@ -95,6 +113,8 @@ export const featureTypes = {
},
driveway: {
label: "Driveway",
collectionLabel: "Driveways",
categoryId: "structures",
geometryType: "Polygon",
detailFields: {},
style: {
@@ -106,6 +126,8 @@ export const featureTypes = {
},
flowerBed: {
label: "Flower Bed",
collectionLabel: "Flower Beds",
categoryId: "structures",
geometryType: "Polygon",
detailFields: {
soil: "string",
@@ -121,6 +143,7 @@ export const featureTypes = {
},
plant: {
label: "Plant",
categoryId: "plants",
geometryType: "Point",
detailFields: {
commonName: "string",
@@ -128,6 +151,9 @@ export const featureTypes = {
species: "string",
cultivar: "string",
gardenOrgId: "string",
measurements: "plantMeasurement[]",
canopyDiameter: "number",
height: "number",
note: "string",
},
externalLinks: [
@@ -149,6 +175,7 @@ export const featureTypes = {
},
flower: {
label: "Flower",
collectionLabel: "Flowers",
parentKind: "plant",
geometryType: "Point",
detailFields: {
@@ -158,6 +185,9 @@ export const featureTypes = {
cultivar: "string",
bloomColor: "string",
bloomSeason: "string",
measurements: "plantMeasurement[]",
canopyDiameter: "number",
height: "number",
note: "string",
},
style: {
@@ -171,6 +201,7 @@ export const featureTypes = {
},
daylily: {
label: "Daylily",
collectionLabel: "Daylilies",
parentKind: "flower",
geometryType: "Point",
detailFields: {
@@ -179,6 +210,9 @@ export const featureTypes = {
cultivar: "string",
bloomColor: "string",
bloomSeason: "string",
measurements: "plantMeasurement[]",
canopyDiameter: "number",
height: "number",
note: "string",
},
style: {
@@ -193,6 +227,7 @@ export const featureTypes = {
},
shrub: {
label: "Shrub",
collectionLabel: "Shrubs",
parentKind: "plant",
geometryType: "Point",
detailFields: {
@@ -200,20 +235,22 @@ export const featureTypes = {
genus: "string",
species: "string",
cultivar: "string",
canopyDiameterMeters: "number",
heightMeters: "number",
measurements: "plantMeasurement[]",
canopyDiameter: "number",
height: "number",
note: "string",
},
style: (details) => ({
color: "#426a3d",
weight: 1,
radiusMeters: (details.canopyDiameterMeters ?? 1) / 2,
radiusMeters: (details.canopyDiameter ?? 1) / 2,
fillColor: "#7e9d58",
fillOpacity: 0.62,
}),
},
tree: {
label: "Tree",
collectionLabel: "Trees",
parentKind: "plant",
geometryType: "Point",
detailFields: {
@@ -221,20 +258,22 @@ export const featureTypes = {
genus: "string",
species: "string",
cultivar: "string",
canopyDiameterMeters: "number",
heightMeters: "number",
measurements: "plantMeasurement[]",
canopyDiameter: "number",
height: "number",
note: "string",
},
style: (details) => ({
color: "#2f6b3d",
weight: 1,
radiusMeters: (details.canopyDiameterMeters ?? 2) / 2,
radiusMeters: (details.canopyDiameter ?? 2) / 2,
fillColor: "#5c9e64",
fillOpacity: 0.55,
}),
},
sprinkler: {
label: "Sprinkler",
categoryId: "infrastructure",
geometryType: "Point",
detailFields: {
zone: "string",
@@ -248,6 +287,20 @@ export const featureTypes = {
fillOpacity: 0.95,
},
},
bush: {
label: "Bush",
collectionLabel: "Bushes",
parentKind: "plant",
geometryType: "Point",
detailFields: {},
style: (details) => ({
color: "#426a3d",
weight: 1,
radiusMeters: (details.canopyDiameter ?? 1) / 2,
fillColor: "#7e9d58",
fillOpacity: 0.62,
}),
},
};
export function getFeatureType(kind) {
@@ -263,6 +316,8 @@ export function listFeatureTypeSchemas() {
return Object.entries(featureTypes).map(([kind, definition]) => ({
kind,
label: definition.label,
collectionLabel: definition.collectionLabel ?? definition.label,
categoryId: getFeatureCategoryId(kind),
parentKind: definition.parentKind ?? null,
geometryType: definition.geometryType,
detailFields: getInheritedDetailFields(kind),
@@ -272,6 +327,12 @@ export function listFeatureTypeSchemas() {
}));
}
export function listFeatureCategorySchemas() {
return Object.entries(featureCategories)
.map(([id, definition]) => ({ id, ...definition }))
.sort((left, right) => left.order - right.order);
}
export function resolveFeatureStyle(kind, details = {}) {
const { style } = getFeatureType(kind);
return typeof style === "function" ? style(details) : { ...style };
@@ -299,6 +360,14 @@ function getFeatureTypeLineage(kind) {
return lineage;
}
function getFeatureCategoryId(kind) {
const definition = [...getFeatureTypeLineage(kind)].reverse().find((entry) => entry.categoryId);
if (!definition || !featureCategories[definition.categoryId]) {
throw new Error(`Feature type has an unknown or missing category: ${kind}`);
}
return definition.categoryId;
}
function getInheritedDetailFields(kind) {
return Object.assign(
{},
+79 -6
View File
@@ -1,7 +1,11 @@
import { collectGeoJSON, offset, pointsFromLngLat } from "./geometry.js";
import { getFeatureType, listFeatureTypeSchemas } from "./feature-types.js";
import {
getFeatureType,
listFeatureCategorySchemas,
listFeatureTypeSchemas,
} from "./feature-types.js";
export const YARD_SCHEMA_VERSION = 2;
export const YARD_SCHEMA_VERSION = 4;
class YardFeature {
constructor(kind, geometryType, name, details = {}) {
@@ -155,6 +159,8 @@ export function defineGroup(id, label, options = {}) {
return {
id,
label,
categoryId: options.categoryId ?? null,
parentKind: options.parentKind ?? null,
parentGroupId: options.parentGroupId ?? null,
};
}
@@ -286,7 +292,7 @@ export class FlowerBed extends PolygonFeature {
export class Plant extends PointFeature {
constructor(name = "Plant", details = {}, kind = "plant") {
super(kind, name, details);
super(kind, name, normalizePlantDetails(details));
}
}
@@ -307,14 +313,14 @@ export class Daylily extends Flower {
}
export class Shrub extends Plant {
constructor(name = "Shrub", details = {}) {
super(name, details, "shrub");
constructor(name = "Shrub", details = {}, kind = "shrub") {
super(name, details, kind);
}
}
export class Bush extends Shrub {
constructor(name = "Bush", details = {}) {
super(name, details);
super(name, details, "bush");
}
}
@@ -324,6 +330,65 @@ export class Tree extends Plant {
}
}
function normalizePlantDetails(details) {
const {
canopyDiameter,
height,
measurements = [],
...restDetails
} = details;
const normalizedMeasurements = normalizePlantMeasurements(measurements);
if (!normalizedMeasurements.length && hasPlantSizeMeasurement(details)) {
normalizedMeasurements.push(
normalizePlantMeasurement({
canopyDiameter,
height,
}),
);
}
const latestMeasurement = normalizedMeasurements.at(-1);
return {
...restDetails,
...(normalizedMeasurements.length ? { measurements: normalizedMeasurements } : {}),
...(latestMeasurement?.canopyDiameter !== undefined
? { canopyDiameter: latestMeasurement.canopyDiameter }
: {}),
...(latestMeasurement?.height !== undefined
? { height: latestMeasurement.height }
: {}),
};
}
function normalizePlantMeasurements(measurements) {
if (!Array.isArray(measurements)) {
throw new Error("Plant measurements must be an array");
}
return measurements.map(normalizePlantMeasurement);
}
function normalizePlantMeasurement(measurement) {
if (!measurement || typeof measurement !== "object") {
throw new Error("Plant measurement entries must be objects");
}
const { canopyDiameter, height, measuredDate, ...restMeasurement } = measurement;
return {
...restMeasurement,
...(measuredDate ? { measuredDate } : {}),
...(canopyDiameter !== undefined ? { canopyDiameter } : {}),
...(height !== undefined ? { height } : {}),
};
}
function hasPlantSizeMeasurement(details) {
return details.canopyDiameter !== undefined || details.height !== undefined;
}
export class Sprinkler extends PointFeature {
constructor(name = "Sprinkler", details = {}) {
super("sprinkler", name, details);
@@ -337,10 +402,18 @@ export function collectYardData(features, groups = []) {
plantedDate: "date",
removedDate: "date",
};
collection.measurementFields = {
measuredDate: "date",
canopyDiameter: "number",
height: "number",
};
collection.featureTypes = listFeatureTypeSchemas();
collection.featureCategories = listFeatureCategorySchemas();
collection.groups = groups.map((group) => ({
id: group.id,
label: group.label,
categoryId: group.categoryId,
parentKind: group.parentKind,
parentGroupId: group.parentGroupId ?? null,
}));
return collection;
+284 -40
View File
@@ -5,9 +5,11 @@ import {
addProtocol,
} from "../vendor/maplibre/maplibre-gl.mjs";
import { yardGeoJSON } from "./data/yard.js";
import { CheckboxTree, loadManifest } from "../vendor/checkbox-tree/checkbox-tree.js";
import { resolveFeatureLinks, resolveFeatureStyle } from "./lib/feature-types.js";
const MAP_MAX_ZOOM = 24;
const FEET_PER_METER = 3.280839895;
const MANDAN_BOUNDS = [
[-101.05, 46.74],
[-100.75, 46.93],
@@ -20,7 +22,6 @@ const TIMELINE_PRESETS = [
const mapElement = document.getElementById("map");
const statusElement = document.getElementById("map-status");
const legend = document.getElementById("legend");
const protocol = new globalThis.pmtiles.Protocol();
addProtocol("pmtiles", protocol.tile);
const basemapLayers = globalThis.basemaps
@@ -50,11 +51,14 @@ const map = new MapLibreMap({
map.addControl(new NavigationControl(), "bottom-right");
let selectedTimelineDate = TIMELINE_PRESETS.at(-1).date;
let selectedFeatureIndexes = new Set(yardGeoJSON.features.map((_, index) => index));
let renderedLayerIds = [];
let renderedSourceIds = [];
let renderedLayerHandlers = [];
const visibilityReady = initializeVisibilityTree();
map.on("load", () => {
map.on("load", async () => {
await visibilityReady;
renderYardForDate(selectedTimelineDate);
addTimelineControl();
map.fitBounds(getGeoJSONBounds(yardGeoJSON), {
@@ -78,10 +82,12 @@ map.on("error", (event) => {
function renderYardForDate(date) {
removeRenderedYard();
const features = yardGeoJSON.features.filter((feature) => featureExistsOnDate(feature, date));
const features = yardGeoJSON.features.filter(
(feature, index) =>
selectedFeatureIndexes.has(index) && featureExistsOnDate(feature, date),
);
features.forEach((feature, index) => addFeatureLayer(feature, index));
renderLegend(features);
syncTimelineButtons();
}
@@ -207,6 +213,199 @@ function getCircleRadius(style, latitude) {
];
}
async function initializeVisibilityTree() {
const container = document.getElementById("visibility-tree");
const manifest = loadManifest(
await fetch("./src/data/visibility-tree-manifest.json").then((response) => {
if (!response.ok) {
throw new Error(`Visibility manifest returned HTTP ${response.status}`);
}
return response.json();
}),
);
const tree = new CheckboxTree(container, {
initiallyCollapsed: true,
initiallySelected: true,
storageKey: "a-plot-of-plants:visibility:v1",
manifest,
stateEncoding: "tiered",
onSelectionChange: ({ selectedNodes }) => {
selectedFeatureIndexes = getSelectedFeatureIndexes(selectedNodes);
if (map.loaded()) {
renderYardForDate(selectedTimelineDate);
}
pushTreeState(tree);
},
});
tree.setData(buildVisibilityNodes(yardGeoJSON));
selectedFeatureIndexes = getSelectedFeatureIndexes(tree.getSelectedNodes());
container.addEventListener("click", (event) => {
if (event.target.closest(".checkbox-tree__toggle")) {
pushTreeState(tree);
}
});
window.addEventListener("popstate", () => {
tree.restoreStateFromLocation();
selectedFeatureIndexes = getSelectedFeatureIndexes(tree.getSelectedNodes());
if (map.loaded()) {
renderYardForDate(selectedTimelineDate);
}
});
}
function pushTreeState(tree) {
const url = new URL(window.location.href);
url.hash = tree.serializeStateToFragment();
if (url.href !== window.location.href) {
history.pushState(null, "", url);
}
}
function getSelectedFeatureIndexes(nodes) {
return new Set(
nodes
.map((node) => node.metadata?.featureIndex)
.filter((index) => Number.isInteger(index)),
);
}
function buildVisibilityNodes(collection) {
const typeSchemasByKind = new globalThis.Map(
collection.featureTypes.map((type) => [type.kind, type]),
);
const categoriesById = new globalThis.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 globalThis.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 globalThis.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);
@@ -263,36 +462,6 @@ function syncTimelineButtons() {
}
}
function renderLegend(features) {
const summary = new globalThis.Map();
for (const feature of features) {
const key = feature.properties.kind;
const style = resolveFeatureStyle(key, feature.properties.details);
const existing = summary.get(key) ?? {
label: feature.properties.label,
count: 0,
color: style.fillColor ?? style.color ?? "#333333",
};
existing.count += 1;
summary.set(key, existing);
}
legend.replaceChildren(
...[...summary.values()].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 getTodayDateString(date = new Date()) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
@@ -331,7 +500,8 @@ function visitCoordinates(coordinates, callback) {
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),
([key, value]) =>
value !== null && value !== undefined && shouldShowDetailField(key, details),
);
const lifecycleEntries = [];
const relationshipEntries = [];
@@ -345,6 +515,11 @@ function buildPopupContent(properties) {
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]) =>
@@ -357,17 +532,28 @@ function buildPopupContent(properties) {
`<div><a href="${escapeHtml(link.url)}" target="_blank" rel="noreferrer">${escapeHtml(link.label)}</a></div>`,
)
.join("");
return `${title}${subtitle}<div>${detailRows}${linkRows}</div>`;
return `${title}${subtitle}<div>${detailRows}${measurementTable}${linkRows}</div>`;
}
function shouldShowDetailField(key) {
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 = {
canopyDiameterMeters: "Canopy diameter",
heightMeters: "Height",
canopyDiameter: "Canopy diameter",
height: "Height",
measurements: "Measurements",
measuredDate: "Measured",
plantedDate: "Planted",
removedDate: "Removed",
commonName: "Common name",
@@ -388,11 +574,69 @@ function formatDetailLabel(key) {
}
function formatDetailValue(key, value) {
if (key === "canopyDiameterMeters" || key === "heightMeters") return `${value} m`;
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("&", "&amp;")