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
+8
View File
@@ -0,0 +1,8 @@
{
"chat.tools.terminal.autoApprove": {
"/^python3 -m http\\.server 4173$/": {
"approve": true,
"matchCommandLine": true
}
}
}
+9 -9
View File
@@ -1,4 +1,4 @@
# Yard Map Agent Notes
# A Plot of Plants Agent Notes
This file is a fast handoff for future coding agents working in this repo.
@@ -19,14 +19,15 @@ Core files:
- `src/lib/feature-types.js`: feature-kind labels, geometry constraints, detail field schemas, and style defaults.
- `src/lib/yard-features.js`: typed feature classes, stable ids, groups, parent-child relationships, schema metadata, GeoJSON export.
- `src/data/*.js`: yard content split by domain.
- `src/main.js`: MapLibre map, yard layers, legend, popup rendering.
- `src/main.js`: MapLibre map, visibility controls, popup rendering.
Top-level exported data shape from `src/data/yard.js`:
- `type: "FeatureCollection"`
- `schemaVersion: 2`
- `schemaVersion: 4`
- `lifecycleFields: { plantedDate: "date", removedDate: "date" }`
- `featureTypes: [...]`
- `featureCategories: [...]`
- `features: [...]`
- `groups: [...]`
@@ -53,8 +54,8 @@ Each feature currently exports:
3. Local rotation is per anchor.
`offset(...)` reads `assumedNorthClockwiseDegrees` from the anchor point used as the origin. Do not reintroduce a global lot rotation unless there is a strong reason.
4. Grouping and parent-child metadata are already part of the data model.
Future visibility controls should consume `groupIds` and `parentId` rather than inventing a second relationship system.
4. Feature categories, grouping, and parent-child metadata are part of the data model.
Top-level visibility categories come from `featureCategories` and each feature type's `categoryId`. Visibility controls consume those schemas and `groupIds` rather than inventing a second relationship system.
5. The GPKG was only a seed source.
The app does not depend on GIS tooling at runtime.
@@ -70,14 +71,13 @@ Each feature currently exports:
## What is already modeled
- Porch and deck are children of the main house and also part of the `House Parts` group.
- Medora junipers are part of the `Juniper Trees` group.
- Medora junipers are bushes in the `Junipers` group.
- A test tree exists that is offset from the top-left lot corner using per-anchor rotation.
## Likely next steps
1. Add show/hide controls driven by `yardGeoJSON.groups` and possibly `parentId`.
2. Decide whether toggles should be by feature kind, by group, by parent object, or a combination.
3. The basemap uses local vector tiles and is overzoomed from source zoom 15
1. Decide whether parent-child feature relationships should also affect the visibility hierarchy.
2. The basemap uses local vector tiles and is overzoomed from source zoom 15
through the yard map's maximum zoom of 24. Symbol layers are deliberately
omitted so the map has no remote font or sprite dependency.
+7 -10
View File
@@ -1,6 +1,6 @@
# Yard Map
# A Plot of Plants
Static yard mapping site built with MapLibre, PMTiles, and plain browser modules.
Static garden and yard mapping site built with MapLibre, PMTiles, and plain browser modules.
## Status
@@ -11,11 +11,9 @@ The project currently has:
- Typed feature classes with centralized styling.
- Schema-versioned yard data with a top-level feature type catalog.
- Per-anchor local coordinate rotation for offset-based authoring.
- Feature relationship metadata (`id`, `parentId`, `groupIds`) and a top-level group catalog for future visibility controls.
- Feature relationship metadata (`id`, `parentId`, `groupIds`) and checkbox-tree visibility controls.
- Example yard data mostly copied from `buildings.gpkg` and then preserved as JS modules.
The next likely product direction is map-layer visibility controls driven by the existing feature groups and parent-child relationships.
## Authoring model
Geometry stays in JavaScript, then gets converted to GeoJSON client-side at load time.
@@ -34,13 +32,13 @@ The example yard is now split by concern:
- `src/data/plants.js`
- `src/data/irrigation.js`
The typed feature registry lives in `src/lib/feature-types.js`, feature classes live in `src/lib/yard-features.js`, and shared coordinate helpers live in `src/lib/geometry.js`. Feature type definitions include the label, required geometry type, known detail fields, and default map style.
The typed feature registry lives in `src/lib/feature-types.js`, feature classes live in `src/lib/yard-features.js`, and shared coordinate helpers live in `src/lib/geometry.js`. Feature type definitions include the top-level category, label, required geometry type, known detail fields, and default map style.
Local `north`, `south`, `east`, and `west` offsets are rotated per anchor. In this example, `topLeftLotCorner` in `src/data/anchors.js` carries `assumedNorthClockwiseDegrees` from `src/data/settings.js`. A negative value means that anchor's local north is counterclockwise from true north, so local east drifts a bit toward true north.
Most example geometry was copied from `buildings.gpkg` and preserved as JS-authored coordinates. Tree records also carry custom attributes like canopy diameter, height, and lifecycle dates for popup rendering.
Feature relationships are exported with each GeoJSON feature as stable `id`, `parentId`, and `groupIds` properties. Each feature also exports universal `plantedDate` and `removedDate` lifecycle fields for timeline filtering. The collection exposes `schemaVersion`, `lifecycleFields`, `featureTypes`, and `groups` at the top level. Style is resolved from the feature type registry at render time rather than stored on each exported feature. The current sample marks the porch and deck as part of the house and groups the Medora junipers under `Juniper Trees`.
Feature relationships are exported with each GeoJSON feature as stable `id`, `parentId`, and `groupIds` properties. Each feature also exports universal `plantedDate` and `removedDate` lifecycle fields for timeline filtering. The collection exposes `schemaVersion`, `lifecycleFields`, `featureCategories`, `featureTypes`, and `groups` at the top level. Categories use stable IDs such as `plants`, `structures`, `infrastructure`, and `reference`; feature types and groups refer to them with `categoryId`. Style is resolved from the feature type registry at render time rather than stored on each exported feature. The current sample marks the porch and deck as part of the house and groups the Medora junipers under `Junipers`.
## Project structure
@@ -55,7 +53,7 @@ Feature relationships are exported with each GeoJSON feature as stable `id`, `pa
- `src/data/plants.js`: flower beds, trees, and tree groups.
- `src/data/irrigation.js`: sprinkler features.
- `src/data/yard.js`: top-level yard collection assembly.
- `src/main.js`: MapLibre rendering, legend generation, and popup rendering.
- `src/main.js`: MapLibre rendering, visibility controls, and popup rendering.
- `tiles/mandan.pmtiles`: locally served vector basemap for the Mandan area.
- `scripts/extract-mandan-pmtiles.sh`: reproducible Mandan tile extraction.
- `docs/vector-tiles.md`: tile update and hosting notes.
@@ -107,6 +105,5 @@ of 24.
## Future direction
- Add UI controls for toggling groups and possibly parent-linked feature clusters.
- Decide whether toggles should operate on feature groups, feature kinds, parent-child trees, or all three.
- Decide whether parent-child feature relationships should further affect the visibility hierarchy.
- If more data import is needed from GIS sources, prefer one-time extraction into JS-authored modules rather than making the app depend on runtime GIS tooling.
+10 -17
View File
@@ -3,31 +3,24 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yard Map</title>
<title>A Plot of Plants</title>
<link rel="stylesheet" href="./vendor/maplibre/maplibre-gl.css" />
<link rel="stylesheet" href="./vendor/checkbox-tree/checkbox-tree.css" />
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div class="app-shell">
<aside class="panel">
<p class="eyebrow">Static map</p>
<h1>Yard Atlas</h1>
<p class="eyebrow">A Plot of Plants</p>
<h1>204 6th Avenue NW</h1>
<p class="intro">
MapLibre renders a local PMTiles vector map underneath a client-side
geometry pipeline. Source files stay as JavaScript so you can author
points and shapes with absolute coordinates or offset math.
A medium sized city lot in Mandan, ND. A white picket fence surrounds
a variety of trees, shrubs, and flowers. There are many varieties of
daylilies.
</p>
<div class="legend" id="legend"></div>
<section class="notes">
<h2>Authoring style</h2>
<pre><code>new House()
.trace([
[-100.8988347540, 46.8268080234],
[-100.8986668041, 46.8268307100],
])
new Sprinkler("Front bed sprinkler")
.add(offset(lotCorner), south(ft(15)), east(ft(10)))</code></pre>
<section class="visibility-panel" aria-labelledby="visibility-title">
<h2 id="visibility-title">Map features</h2>
<div id="visibility-tree"></div>
</section>
</aside>
<main>
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "yardmap",
"name": "a-plot-of-plants",
"private": true,
"type": "module",
"scripts": {
+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;")
+139 -16
View File
@@ -27,6 +27,7 @@ body {
body {
min-height: 100vh;
min-height: 100svh;
}
.app-shell {
@@ -66,25 +67,25 @@ h1 {
line-height: 1.5;
}
.legend {
display: grid;
gap: 0.65rem;
margin: 1.5rem 0;
.visibility-panel {
margin-top: 1.5rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.95rem;
.visibility-panel h2 {
margin: 0 0 0.65rem;
color: var(--ink);
font-size: 1rem;
}
.legend-swatch {
width: 0.9rem;
height: 0.9rem;
border-radius: 999px;
flex: none;
border: 1px solid rgba(0, 0, 0, 0.12);
#visibility-tree {
--checkbox-tree-indent: 16px;
--checkbox-tree-hover-color: var(--accent);
max-height: min(45vh, 28rem);
overflow: auto;
padding: 0.65rem;
border: 1px solid var(--panel-border);
border-radius: 12px;
background: rgba(255, 253, 246, 0.62);
}
.notes h2 {
@@ -108,15 +109,19 @@ code {
main,
.map-frame {
display: flex;
min-width: 0;
}
.map-frame {
position: relative;
flex: 1;
}
#map {
flex: 1;
min-height: calc(100vh - 2rem);
min-height: calc(100svh - 2rem);
border-radius: 28px;
overflow: hidden;
box-shadow: var(--shadow);
@@ -147,6 +152,33 @@ main,
display: none;
}
.measurement-table-block {
margin-top: 0.35rem;
}
.measurement-table {
width: 100%;
margin-top: 0.15rem;
border-collapse: collapse;
font-size: 0.92em;
}
.measurement-table th,
.measurement-table td {
padding: 0.12rem 0.35rem 0.12rem 0;
border-bottom: 1px solid rgba(87, 68, 38, 0.18);
text-align: left;
white-space: nowrap;
}
.measurement-table th {
font-weight: 700;
}
.measurement-table tbody tr:last-child td {
border-bottom: 0;
}
.timeline-control {
display: grid;
gap: 0.45rem;
@@ -255,11 +287,102 @@ main,
}
@media (max-width: 900px) {
body {
min-height: auto;
}
.app-shell {
grid-template-columns: 1fr;
gap: 0.75rem;
min-height: auto;
padding: 0.75rem;
padding:
max(0.75rem, env(safe-area-inset-top))
max(0.75rem, env(safe-area-inset-right))
max(0.75rem, env(safe-area-inset-bottom))
max(0.75rem, env(safe-area-inset-left));
}
main {
order: -1;
min-height: min(72svh, 46rem);
}
.panel {
padding: 1rem;
border-radius: 12px;
box-shadow: 0 12px 36px rgba(60, 44, 22, 0.14);
}
h1 {
margin-bottom: 0.65rem;
font-size: clamp(2rem, 12vw, 3rem);
}
.intro {
margin-bottom: 0;
}
#map {
min-height: 65vh;
min-height: min(72svh, 46rem);
border-radius: 12px;
}
.timeline-control {
width: min(18rem, calc(100vw - 2rem));
max-width: calc(100vw - 2rem);
}
.timeline-buttons {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.timeline-buttons button {
min-height: 2.65rem;
padding: 0.38rem 0.45rem;
text-align: center;
overflow-wrap: anywhere;
}
.maplibregl-ctrl-top-left,
.maplibregl-ctrl-top-right {
top: 0.5rem;
}
.maplibregl-ctrl-top-right,
.maplibregl-ctrl-bottom-right {
right: 0.5rem;
}
.maplibregl-ctrl-bottom-left,
.maplibregl-ctrl-bottom-right {
bottom: 0.5rem;
}
.maplibregl-popup-content {
max-width: min(16rem, calc(100vw - 5rem));
}
}
@media (max-width: 520px) {
.app-shell {
padding: 0;
}
main,
#map {
min-height: 68svh;
}
#map {
border-radius: 0;
}
.panel {
margin: 0 0.75rem 0.75rem;
}
.timeline-control {
padding: 0.55rem;
}
}
+137
View File
@@ -0,0 +1,137 @@
# Checkbox Tree
A dependency-free, instance-based checkbox tree for browser ES modules. It
supports cascading selection, indeterminate parent states, expandable branches,
multiple independent instances, optional local-storage persistence, and compact
shareable URL state backed by an append-only node manifest.
## Install
```sh
npm install @aaronaxvig/checkbox-tree
```
The package is not published yet. During local development, install it by path:
```sh
npm install ../checkbox-tree
```
## Usage
```js
import { CheckboxTree } from "@aaronaxvig/checkbox-tree";
import "@aaronaxvig/checkbox-tree/styles.css";
const tree = new CheckboxTree(document.querySelector("#example-tree"), {
initiallySelected: true,
storageKey: "example-tree-state",
onSelectionChange({ selectedIds, selectedNodes }) {
console.log(selectedIds, selectedNodes);
},
});
tree.setData([
{
id: "fruit",
label: "Fruit",
children: [
{ id: "apple", label: "Apple", metadata: { color: "red" } },
{ id: "pear", label: "Pear", metadata: { color: "green" } },
],
},
]);
```
For applications without a bundler, serve or copy the `src/` directory and use
relative URLs. The main module imports its state-codec modules alongside it:
```html
<link rel="stylesheet" href="./checkbox-tree.css">
<script type="module">
import { CheckboxTree } from "./checkbox-tree.js";
</script>
```
Every node requires unique string `id` and `label` properties. Nodes may also
have `children`, application-owned `metadata`, and `selectable` or `disabled`
flags.
## API
- `new CheckboxTree(container, options)` creates an independent instance.
- `setData(nodes)` validates and renders nodes, then restores saved state.
- `getSelectedIds()` returns selected node IDs.
- `getSelectedNodes()` returns selected source node objects.
- `setSelectedIds(ids, { notify })` replaces the selection.
- `restoreState()` restores selection and expansion state.
- `destroy()` removes listeners, markup, and the root CSS class.
- `serializeStateToFragment(manifest)` returns `#v=1&c=...&x=...` state.
- `restoreStateFromLocation(manifest, location)` safely applies URL state.
- `validateManifest(manifest)` reports unregistered and missing paths.
Options:
- `initiallyCollapsed` defaults to `true`.
- `initiallySelected` defaults to `false`.
- `storageKey` defaults to `null`, which disables persistence.
- `onSelectionChange` receives `{ selectedIds, selectedNodes }`.
- `manifest` optionally supplies a parsed manifest and automatically restores
state from `window.location.hash` after `setData()`.
- `stateEncoding` selects `"adaptive"` (the default), `"dense"`, or `"tiered"`.
The website's configured value is the URL-format contract and is not embedded
in the fragment.
## Shareable URL state
Create a JSON manifest whose array positions are permanent slots. Canonical node
paths join ancestor IDs with `>`:
```json
{ "schema": 1, "nodes": ["fruit", "fruit>apple", "fruit>pear", null] }
```
Import `loadManifest`, pass its result as the `manifest` option, and call
`tree.serializeStateToFragment()` when creating a share link. Existing entries
must never be reordered. Append new paths, replace removed paths with `null`, and
edit a path in place for a logical rename or move. Node IDs therefore cannot
contain `>`.
The fragment adaptively encodes checked (`c`) and expanded (`x`) state using the
shortest of a dense bitset, sparse enabled slots, or sparse disabled slots.
Sparse slot numbers are delta-encoded variable-length integers. A coverage value
in the disabled-slot form ensures nodes appended later still default to unchecked
and collapsed. Malformed or unsupported state is ignored.
The helpers `encodeTreeState`, `decodeTreeState`, `applyTreeState`, and
`restoreStateFromLocation` are also exported for custom integrations.
Use `validateManifestEvolution(previous, next)` in a build check to reject
removed slots and tombstone reuse; path changes are reported for deliberate
rename/move review.
With `stateEncoding: "tiered"`, `c1` stores root checked state and each deeper
`cN` field stores nodes whose checked state differs from their parent. Expansion
does not inherit: every `xN` stores the actual expanded branches at that depth,
with collapsed as the baseline. Each tier field uses the adaptive codec. The
`n` field records manifest coverage so nodes appended later still default to
false. A non-selectable parent supplies a false checked baseline. With
`stateEncoding: "dense"`, `c` and `x` contain trimmed
least-significant-bit-first bitsets.
Styling is namespaced under `.checkbox-tree`. Override the custom properties
`--checkbox-tree-indent`, `--checkbox-tree-toggle-size`, and
`--checkbox-tree-hover-color` in the consuming application.
## Development
```sh
npm install
npm test
npm run pack:check
```
## Future demo ideas
- Add population metadata to the country, state, and county nodes and display the
total population represented by the current selection. Define the aggregation
rule carefully so checked ancestors and their checked descendants are not
counted twice; one option is to total only the most specific checked nodes.
+82
View File
@@ -0,0 +1,82 @@
.checkbox-tree {
--checkbox-tree-indent: 18px;
--checkbox-tree-toggle-size: 18px;
--checkbox-tree-hover-color: #2c3e50;
font-size: 14px;
line-height: 1.4;
}
.checkbox-tree__item {
margin: 1px 0;
}
.checkbox-tree__row {
display: flex;
align-items: center;
min-height: 24px;
}
.checkbox-tree__toggle,
.checkbox-tree__toggle-spacer {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 var(--checkbox-tree-toggle-size);
width: var(--checkbox-tree-toggle-size);
height: var(--checkbox-tree-toggle-size);
margin-right: 2px;
}
.checkbox-tree__toggle {
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
}
.checkbox-tree__toggle[aria-expanded="true"] {
transform: rotate(90deg);
}
.checkbox-tree__toggle:focus-visible {
outline: 2px solid currentColor;
outline-offset: 1px;
}
.checkbox-tree__label {
display: inline-flex;
align-items: center;
padding: 2px 0;
cursor: pointer;
user-select: none;
}
.checkbox-tree__item:has(> .checkbox-tree__children) > .checkbox-tree__row > .checkbox-tree__label {
font-weight: 500;
}
.checkbox-tree__label:hover {
color: var(--checkbox-tree-hover-color);
}
.checkbox-tree__checkbox {
flex-shrink: 0;
width: 16px;
height: 16px;
margin: 0 4px 0 0;
cursor: pointer;
}
.checkbox-tree__checkbox:indeterminate {
accent-color: #f39c12;
}
.checkbox-tree__children {
margin-left: var(--checkbox-tree-indent);
}
.checkbox-tree__children[hidden] {
display: none;
}
+355
View File
@@ -0,0 +1,355 @@
// Dependency-free checkbox tree widget. State serialization lives in focused sibling modules.
import {
PATH_DELIMITER,
assertStateEncoding
} from './state-codec.js';
import {
restoreStateFromLocation,
serializeStateToFragment,
validateManifest
} from './tree-state.js';
export {
PATH_DELIMITER,
SERIALIZATION_VERSION,
base64UrlToBytes,
bytesToBase64Url,
decodeAdaptiveBitset,
encodeAdaptiveBitset,
getBit,
loadManifest,
setBit
} from './state-codec.js';
export {
applyTreeState,
decodeTreeState,
encodeTreeState,
restoreStateFromLocation,
serializeStateToFragment,
validateManifest,
validateManifestEvolution
} from './tree-state.js';
const DEFAULT_OPTIONS = {
initiallyCollapsed: true,
initiallySelected: false,
storageKey: null,
onSelectionChange: null,
manifest: null,
stateEncoding: 'adaptive'
};
export class CheckboxTree {
constructor(container, options = {}) {
if (!(container instanceof Element)) {
throw new TypeError('CheckboxTree requires a container element.');
}
this.container = container;
this.options = { ...DEFAULT_OPTIONS, ...options };
assertStateEncoding(this.options.stateEncoding);
this.nodesById = new Map();
this.nodesByPath = new Map();
this.itemsById = new Map();
this.rootNodes = [];
this.handleChange = this.handleChange.bind(this);
this.handleClick = this.handleClick.bind(this);
this.container.classList.add('checkbox-tree');
this.container.addEventListener('change', this.handleChange);
this.container.addEventListener('click', this.handleClick);
}
setData(nodes) {
if (!Array.isArray(nodes)) {
throw new TypeError('CheckboxTree data must be an array of nodes.');
}
this.nodesById.clear();
this.nodesByPath.clear();
this.itemsById.clear();
this.rootNodes = nodes;
this.indexNodes(nodes);
this.render();
this.updateAllParentCheckboxes();
this.restoreState();
if (this.options.manifest) this.restoreStateFromLocation();
}
getSelectedIds() {
return this.getSelectedNodes().map(node => node.id);
}
getSelectedNodes() {
return Array.from(this.container.querySelectorAll(
'.checkbox-tree__checkbox:checked[data-selectable="true"]'
)).map(checkbox => this.nodesById.get(checkbox.dataset.nodeId)).filter(Boolean);
}
setSelectedIds(ids, { notify = false } = {}) {
const selectedIds = new Set(ids);
this.container.querySelectorAll('.checkbox-tree__checkbox').forEach(checkbox => {
checkbox.checked = checkbox.dataset.selectable === 'true' && selectedIds.has(checkbox.dataset.nodeId);
checkbox.indeterminate = false;
});
this.updateAllParentCheckboxes();
this.saveState();
if (notify) {
this.notifySelectionChange();
}
}
destroy() {
this.container.removeEventListener('change', this.handleChange);
this.container.removeEventListener('click', this.handleClick);
this.container.classList.remove('checkbox-tree');
this.container.replaceChildren();
this.nodesById.clear();
this.nodesByPath.clear();
this.itemsById.clear();
}
indexNodes(nodes, parentPath = '') {
nodes.forEach(node => {
if (!node || typeof node.id !== 'string' || !node.id || typeof node.label !== 'string') {
throw new TypeError('Each CheckboxTree node requires non-empty string id and label properties.');
}
if (this.nodesById.has(node.id)) {
throw new Error(`Duplicate CheckboxTree node id: ${node.id}`);
}
if (node.id.includes(PATH_DELIMITER)) {
throw new Error(`CheckboxTree node id cannot contain "${PATH_DELIMITER}": ${node.id}`);
}
this.nodesById.set(node.id, node);
const path = parentPath ? `${parentPath}${PATH_DELIMITER}${node.id}` : node.id;
this.nodesByPath.set(path, node);
if (node.children !== undefined) {
if (!Array.isArray(node.children)) {
throw new TypeError(`CheckboxTree node children must be an array: ${node.id}`);
}
this.indexNodes(node.children, path);
}
});
}
render() {
const fragment = document.createDocumentFragment();
this.rootNodes.forEach(node => fragment.appendChild(this.createNodeElement(node)));
this.container.replaceChildren(fragment);
}
createNodeElement(node) {
const item = document.createElement('div');
item.className = 'checkbox-tree__item';
item.dataset.nodeId = node.id;
this.itemsById.set(node.id, item);
const row = document.createElement('div');
row.className = 'checkbox-tree__row';
const hasChildren = Array.isArray(node.children) && node.children.length > 0;
if (hasChildren) {
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'checkbox-tree__toggle';
toggle.dataset.action = 'toggle';
toggle.setAttribute('aria-label', `Expand ${node.label}`);
toggle.setAttribute('aria-expanded', 'false');
toggle.textContent = '▶';
row.appendChild(toggle);
} else {
const spacer = document.createElement('span');
spacer.className = 'checkbox-tree__toggle-spacer';
spacer.setAttribute('aria-hidden', 'true');
row.appendChild(spacer);
}
const label = document.createElement('label');
label.className = 'checkbox-tree__label';
if (node.selectable !== false) {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'checkbox-tree__checkbox';
checkbox.dataset.nodeId = node.id;
checkbox.dataset.selectable = 'true';
checkbox.disabled = node.disabled === true;
checkbox.checked = this.options.initiallySelected && !checkbox.disabled;
label.appendChild(checkbox);
}
label.appendChild(document.createTextNode(node.label));
row.appendChild(label);
item.appendChild(row);
if (hasChildren) {
const children = document.createElement('div');
children.className = 'checkbox-tree__children';
children.hidden = this.options.initiallyCollapsed;
node.children.forEach(child => children.appendChild(this.createNodeElement(child)));
item.appendChild(children);
if (!this.options.initiallyCollapsed) {
this.setExpanded(item, true);
}
}
return item;
}
handleClick(event) {
const toggle = event.target.closest('.checkbox-tree__toggle[data-action="toggle"]');
if (!toggle || !this.container.contains(toggle)) {
return;
}
const item = toggle.closest('.checkbox-tree__item');
this.setExpanded(item, toggle.getAttribute('aria-expanded') !== 'true');
this.saveState();
}
handleChange(event) {
const checkbox = event.target.closest('.checkbox-tree__checkbox');
if (!checkbox || !this.container.contains(checkbox)) {
return;
}
const item = checkbox.closest('.checkbox-tree__item');
const children = this.directChildrenContainer(item);
if (children) {
children.querySelectorAll('.checkbox-tree__checkbox:not(:disabled)').forEach(child => {
child.checked = checkbox.checked;
child.indeterminate = false;
});
}
checkbox.indeterminate = false;
this.updateAncestorCheckboxes(item);
this.saveState();
this.notifySelectionChange();
}
notifySelectionChange() {
if (typeof this.options.onSelectionChange === 'function') {
this.options.onSelectionChange({
selectedIds: this.getSelectedIds(),
selectedNodes: this.getSelectedNodes()
});
}
}
setExpanded(item, expanded) {
if (!item) return;
const toggle = item.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle');
const children = this.directChildrenContainer(item);
if (!toggle || !children) {
return;
}
children.hidden = !expanded;
toggle.setAttribute('aria-expanded', String(expanded));
const label = this.nodesById.get(item.dataset.nodeId)?.label ?? 'branch';
toggle.setAttribute('aria-label', `${expanded ? 'Collapse' : 'Expand'} ${label}`);
}
directChildrenContainer(item) {
return item.querySelector(':scope > .checkbox-tree__children');
}
directCheckbox(item) {
return item.querySelector(':scope > .checkbox-tree__row .checkbox-tree__checkbox');
}
itemForNode(id) {
return this.itemsById.get(id) ?? null;
}
updateAncestorCheckboxes(item) {
let parentItem = item.parentElement?.closest('.checkbox-tree__item');
while (parentItem && this.container.contains(parentItem)) {
this.updateParentCheckbox(parentItem);
parentItem = parentItem.parentElement?.closest('.checkbox-tree__item');
}
}
updateAllParentCheckboxes() {
const parents = Array.from(this.container.querySelectorAll('.checkbox-tree__item'))
.filter(item => this.directChildrenContainer(item));
parents.reverse().forEach(item => this.updateParentCheckbox(item));
}
updateParentCheckbox(item) {
const checkbox = this.directCheckbox(item);
const children = this.directChildrenContainer(item);
if (!checkbox || !children) {
return;
}
const childCheckboxes = Array.from(children.querySelectorAll(
':scope > .checkbox-tree__item > .checkbox-tree__row .checkbox-tree__checkbox'
)).filter(child => !child.disabled);
const hasSelection = childCheckboxes.some(child => child.checked || child.indeterminate);
const allSelected = childCheckboxes.length > 0 && childCheckboxes.every(child => child.checked);
checkbox.checked = allSelected;
checkbox.indeterminate = hasSelection && !allSelected;
}
saveState() {
if (!this.options.storageKey) {
return;
}
const collapsedIds = Array.from(this.container.querySelectorAll('.checkbox-tree__children[hidden]'))
.map(children => children.parentElement.dataset.nodeId);
try {
localStorage.setItem(this.options.storageKey, JSON.stringify({
version: 1,
selectedIds: this.getSelectedIds(),
collapsedIds
}));
} catch {
// Persistence is optional; storage may be disabled or full.
}
}
restoreState() {
if (!this.options.storageKey) {
return;
}
let state;
try {
state = JSON.parse(localStorage.getItem(this.options.storageKey));
} catch {
return;
}
if (!state || typeof state !== 'object') {
return;
}
if (Array.isArray(state.selectedIds)) {
this.setSelectedIds(state.selectedIds);
}
if (Array.isArray(state.collapsedIds)) {
const collapsedIds = new Set(state.collapsedIds);
this.container.querySelectorAll('.checkbox-tree__item').forEach(item => {
if (this.directChildrenContainer(item)) {
this.setExpanded(item, !collapsedIds.has(item.dataset.nodeId));
}
});
}
}
validateManifest(manifest = this.options.manifest) {
return validateManifest(manifest, this);
}
serializeStateToFragment(manifest = this.options.manifest) {
return serializeStateToFragment(this, manifest);
}
restoreStateFromLocation(manifest = this.options.manifest, location = window.location) {
return restoreStateFromLocation(this, manifest, location);
}
}
+206
View File
@@ -0,0 +1,206 @@
// Manifest parsing and binary codecs. This module has no DOM dependencies.
export const SERIALIZATION_VERSION = 1;
export const PATH_DELIMITER = '>';
const STATE_ENCODINGS = new Set(['adaptive', 'dense', 'tiered']);
export function loadManifest(source) {
let records;
let schema = 1;
if (typeof source === 'string') {
records = source.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'))
.map(line => line === '!deleted' ? null : line);
} else if (source && typeof source === 'object' && Array.isArray(source.nodes)) {
schema = source.schema;
records = source.nodes;
} else if (source && Array.isArray(source.slots)) {
return normalizeManifest(source.schema, source.slots.map(slot => slot?.deleted ? null : slot?.path));
} else {
throw new TypeError('CheckboxTree manifest must be text or an object with a nodes array.');
}
return normalizeManifest(schema, records);
}
function normalizeManifest(schema, records) {
if (schema !== 1) {
throw new Error(`Unsupported CheckboxTree manifest schema: ${schema}`);
}
const pathToSlot = new Map();
const slots = records.map((record, index) => {
if (record === null || record === '!deleted') {
return { path: null, deleted: true };
}
if (typeof record !== 'string' || !record || record.trim() !== record) {
throw new TypeError(`Invalid CheckboxTree manifest entry at slot ${index}.`);
}
if (pathToSlot.has(record)) {
throw new Error(`Duplicate CheckboxTree manifest path: ${record}`);
}
pathToSlot.set(record, index);
return { path: record, deleted: false };
});
return { schema: 1, slots, pathToSlot };
}
export function setBit(bytes, bitIndex, value) {
const byteIndex = Math.floor(bitIndex / 8);
const mask = 1 << (bitIndex % 8);
if (value) bytes[byteIndex] |= mask;
else bytes[byteIndex] &= ~mask;
}
export function getBit(bytes, bitIndex) {
const byteIndex = Math.floor(bitIndex / 8);
return byteIndex < bytes.length && (bytes[byteIndex] & (1 << (bitIndex % 8))) !== 0;
}
export function bytesToBase64Url(bytes) {
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
export function base64UrlToBytes(value, maximumLength = Infinity) {
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
throw new Error('Invalid Base64URL value.');
}
if (Number.isFinite(maximumLength) && value.length > Math.ceil(maximumLength * 4 / 3) + 2) {
throw new Error('Encoded tree state exceeds the manifest size.');
}
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(base64 + '='.repeat((4 - base64.length % 4) % 4));
if (binary.length > maximumLength) throw new Error('Encoded tree state exceeds the manifest size.');
return Uint8Array.from(binary, character => character.charCodeAt(0));
}
function trimTrailingZeroBytes(bytes) {
let end = bytes.length;
while (end && bytes[end - 1] === 0) end--;
return bytes.slice(0, end);
}
function encodeUnsignedVarint(value) {
const bytes = [];
do {
let byte = value & 0x7f;
value = Math.floor(value / 128);
if (value) byte |= 0x80;
bytes.push(byte);
} while (value);
return bytes;
}
function decodeUnsignedVarint(bytes, offset) {
let value = 0;
let multiplier = 1;
for (let index = offset; index < bytes.length && index < offset + 8; index++) {
const byte = bytes[index];
value += (byte & 0x7f) * multiplier;
if ((byte & 0x80) === 0) return { value, offset: index + 1 };
multiplier *= 128;
if (!Number.isSafeInteger(value) || !Number.isSafeInteger(multiplier)) break;
}
throw new Error('Invalid adaptive tree-state integer.');
}
function encodeSparseIndexes(indexes) {
const bytes = [];
let previous = -1;
for (const index of indexes) {
bytes.push(...encodeUnsignedVarint(index - previous - 1));
previous = index;
}
return bytes;
}
function decodeSparseIndexes(bytes, offset, limit) {
const indexes = new Set();
let previous = -1;
while (offset < bytes.length) {
const decoded = decodeUnsignedVarint(bytes, offset);
const index = previous + decoded.value + 1;
if (index >= limit) throw new Error('Sparse tree state contains an out-of-range slot.');
indexes.add(index);
previous = index;
offset = decoded.offset;
}
return indexes;
}
export function encodeAdaptiveBitset(values, slotCount) {
const enabled = [];
const disabled = [];
const denseBytes = new Uint8Array(Math.ceil(slotCount / 8));
for (const [slot, value] of values) {
(value ? enabled : disabled).push(slot);
setBit(denseBytes, slot, value);
}
enabled.sort((left, right) => left - right);
disabled.sort((left, right) => left - right);
if (enabled.length === 0) return new Uint8Array();
const candidates = [
Uint8Array.from([0, ...trimTrailingZeroBytes(denseBytes)]),
Uint8Array.from([1, ...encodeSparseIndexes(enabled)]),
Uint8Array.from([2, ...encodeUnsignedVarint(slotCount), ...encodeSparseIndexes(disabled)])
];
return candidates.reduce((shortest, candidate) =>
candidate.length < shortest.length ? candidate : shortest
);
}
export function decodeAdaptiveBitset(bytes, slotCount) {
if (bytes.length === 0) return () => false;
const mode = bytes[0];
if (mode === 0) {
const dense = bytes.slice(1);
if (dense.length > Math.ceil(slotCount / 8)) {
throw new Error('Dense tree state exceeds the manifest size.');
}
return slot => getBit(dense, slot);
}
if (mode === 1) {
const enabled = decodeSparseIndexes(bytes, 1, slotCount);
return slot => enabled.has(slot);
}
if (mode === 2) {
const coverage = decodeUnsignedVarint(bytes, 1);
if (coverage.value > slotCount) throw new Error('Tree-state coverage exceeds the manifest size.');
const disabled = decodeSparseIndexes(bytes, coverage.offset, coverage.value);
return slot => slot < coverage.value && !disabled.has(slot);
}
throw new Error('Unknown adaptive tree-state mode.');
}
export function encodeDenseBitset(values, slotCount) {
const bytes = new Uint8Array(Math.ceil(slotCount / 8));
for (const [slot, value] of values) setBit(bytes, slot, value);
return trimTrailingZeroBytes(bytes);
}
export function decodeDenseBitset(bytes, slotCount) {
if (bytes.length > Math.ceil(slotCount / 8)) {
throw new Error('Dense tree state exceeds the manifest size.');
}
return slot => getBit(bytes, slot);
}
export function maximumEncodedLength(slotCount) {
return Math.max(Math.ceil(slotCount / 8) + 1, slotCount * 2 + 10);
}
export function assertStateEncoding(encoding) {
if (!STATE_ENCODINGS.has(encoding)) {
throw new Error(`Unsupported CheckboxTree state encoding: ${encoding}`);
}
}
export function asManifestSource(manifest) {
return manifest?.pathToSlot instanceof Map ? manifest : loadManifest(manifest);
}
+310
View File
@@ -0,0 +1,310 @@
// Adapts pure state codecs to CheckboxTree's rendered node model and URL format.
import {
PATH_DELIMITER,
SERIALIZATION_VERSION,
asManifestSource as asManifest,
assertStateEncoding,
base64UrlToBytes,
bytesToBase64Url,
decodeAdaptiveBitset,
decodeDenseBitset,
encodeAdaptiveBitset,
encodeDenseBitset,
maximumEncodedLength
} from './state-codec.js';
export function validateManifest(manifest, tree) {
const normalized = asManifest(manifest);
const treePaths = new Set(tree.nodesByPath.keys());
return {
valid: Array.from(treePaths).every(path => normalized.pathToSlot.has(path)),
unregisteredPaths: Array.from(treePaths).filter(path => !normalized.pathToSlot.has(path)),
missingPaths: normalized.slots
.filter(slot => !slot.deleted && !treePaths.has(slot.path))
.map(slot => slot.path)
};
}
export function validateManifestEvolution(previous, next) {
const oldManifest = asManifest(previous);
const newManifest = asManifest(next);
const errors = [];
if (newManifest.slots.length < oldManifest.slots.length) {
errors.push('Manifest slots cannot be removed; use tombstones.');
}
oldManifest.slots.forEach((oldSlot, index) => {
const newSlot = newManifest.slots[index];
if (!newSlot) return;
if (oldSlot.deleted && !newSlot.deleted) {
errors.push(`Tombstoned slot ${index} cannot be reused.`);
}
if (!oldSlot.deleted && !newSlot.deleted && oldSlot.path !== newSlot.path) {
errors.push(`Slot ${index} changed path; confirm this is a logical rename or move.`);
}
});
return { valid: errors.length === 0, errors };
}
function collectTreeValues(tree, manifest) {
const normalized = asManifest(manifest);
const checkedValues = new Map();
const expandedValues = new Map();
for (const [path, node] of tree.nodesByPath) {
const slot = normalized.pathToSlot.get(path);
if (slot === undefined) continue;
const item = tree.itemForNode(node.id);
const checkbox = item && tree.directCheckbox(item);
const toggle = item?.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle');
checkedValues.set(slot, Boolean(checkbox?.checked));
expandedValues.set(slot, toggle?.getAttribute('aria-expanded') === 'true');
}
return { normalized, checkedValues, expandedValues };
}
function nodeState(tree, node) {
const item = tree.itemForNode(node.id);
const checkbox = item && tree.directCheckbox(item);
const toggle = item?.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle');
return {
checked: Boolean(checkbox?.checked),
hasChecked: Boolean(checkbox),
expanded: toggle?.getAttribute('aria-expanded') === 'true',
hasExpanded: Boolean(toggle)
};
}
function encodeTieredState(tree, normalized) {
const checkedByDepth = new Map();
const expandedByDepth = new Map();
const statesByPath = new Map();
for (const [path, node] of tree.nodesByPath) {
const slot = normalized.pathToSlot.get(path);
if (slot === undefined) continue;
const depth = path.split(PATH_DELIMITER).length;
const parentPath = path.includes(PATH_DELIMITER)
? path.slice(0, path.lastIndexOf(PATH_DELIMITER))
: null;
const parent = statesByPath.get(parentPath) ?? {
checked: false,
hasChecked: false,
expanded: false,
hasExpanded: false
};
const current = nodeState(tree, node);
if (current.hasChecked) {
checkedByDepth.set(depth, checkedByDepth.get(depth) ?? new Map());
checkedByDepth.get(depth).set(slot, current.checked !== (parent.hasChecked ? parent.checked : false));
}
if (current.hasExpanded) {
expandedByDepth.set(depth, expandedByDepth.get(depth) ?? new Map());
expandedByDepth.get(depth).set(slot, current.expanded);
}
statesByPath.set(path, current);
}
const fields = new Map([['n', String(normalized.slots.length)]]);
for (const [depth, values] of checkedByDepth) {
const encoded = bytesToBase64Url(encodeAdaptiveBitset(values, normalized.slots.length));
if (encoded) fields.set(`c${depth}`, encoded);
}
for (const [depth, values] of expandedByDepth) {
const encoded = bytesToBase64Url(encodeAdaptiveBitset(values, normalized.slots.length));
if (encoded) fields.set(`x${depth}`, encoded);
}
return fields;
}
export function encodeTreeState(tree, manifest, encoding = tree.options?.stateEncoding ?? 'adaptive') {
assertStateEncoding(encoding);
const { normalized, checkedValues, expandedValues } = collectTreeValues(tree, manifest);
if (encoding === 'tiered') {
return { version: SERIALIZATION_VERSION, encoding, fields: encodeTieredState(tree, normalized) };
}
const encode = encoding === 'dense' ? encodeDenseBitset : encodeAdaptiveBitset;
const checkedValue = bytesToBase64Url(encode(checkedValues, normalized.slots.length));
const expandedValue = bytesToBase64Url(encode(expandedValues, normalized.slots.length));
return {
version: SERIALIZATION_VERSION,
encoding,
checked: checkedValue || null,
expanded: expandedValue || null
};
}
function decodeField(params, name, normalized, encoding) {
const maximumLength = encoding === 'dense'
? Math.ceil(normalized.slots.length / 8)
: maximumEncodedLength(normalized.slots.length);
const bytes = base64UrlToBytes(params.get(name) ?? '', maximumLength);
const decode = encoding === 'dense' ? decodeDenseBitset : decodeAdaptiveBitset;
decode(bytes, normalized.slots.length);
return bytes;
}
export function decodeTreeState(fragment, manifest, encoding = 'adaptive') {
assertStateEncoding(encoding);
const normalized = asManifest(manifest);
const value = fragment.startsWith('#') ? fragment.slice(1) : fragment;
const params = new URLSearchParams(value);
if (params.get('v') !== String(SERIALIZATION_VERSION)) {
return { applied: false, version: params.get('v'), encoding, warnings: ['Missing or unsupported serialization version.'] };
}
try {
if (encoding === 'tiered') {
if (params.has('c') || params.has('x')) {
throw new Error('Tiered tree state contains non-tiered fields.');
}
const maximumDepth = normalized.slots.reduce((depth, slot) =>
slot.deleted ? depth : Math.max(depth, slot.path.split(PATH_DELIMITER).length)
, 0);
for (const key of params.keys()) {
const match = /^([cx])(\d+)$/.exec(key);
if (match && (Number(match[2]) < 1 || Number(match[2]) > maximumDepth)) {
throw new Error(`Tree state contains an invalid tier: ${key}`);
}
}
const coverage = Number(params.get('n'));
if (!Number.isSafeInteger(coverage) || coverage < 0 || coverage > normalized.slots.length) {
throw new Error('Tiered tree state has invalid manifest coverage.');
}
const checkedTiers = new Map();
const expandedTiers = new Map();
for (let depth = 1; depth <= maximumDepth; depth++) {
checkedTiers.set(depth, decodeField(params, `c${depth}`, normalized, 'adaptive'));
expandedTiers.set(depth, decodeField(params, `x${depth}`, normalized, 'adaptive'));
}
return {
applied: true,
version: SERIALIZATION_VERSION,
encoding,
coverage,
checkedTiers,
expandedTiers,
warnings: []
};
}
for (const key of params.keys()) {
if (key === 'n' || /^[cx]\d+$/.test(key)) {
throw new Error('Tree state contains tiered fields for a non-tiered encoding.');
}
}
const checked = decodeField(params, 'c', normalized, encoding);
const expanded = decodeField(params, 'x', normalized, encoding);
return {
applied: true,
version: SERIALIZATION_VERSION,
encoding,
checked,
expanded,
warnings: []
};
} catch (error) {
return { applied: false, version: SERIALIZATION_VERSION, encoding, warnings: [error.message] };
}
}
export function applyTreeState(tree, manifest, state) {
if (!state?.applied) return false;
const normalized = asManifest(manifest);
if (state.encoding === 'tiered') return applyTieredTreeState(tree, normalized, state);
let isChecked;
let isExpanded;
try {
const decode = state.encoding === 'dense' ? decodeDenseBitset : decodeAdaptiveBitset;
isChecked = decode(state.checked, normalized.slots.length);
isExpanded = decode(state.expanded, normalized.slots.length);
} catch {
return false;
}
for (const [path, node] of tree.nodesByPath) {
const slot = normalized.pathToSlot.get(path);
if (slot === undefined) continue;
const item = tree.itemForNode(node.id);
const checkbox = item && tree.directCheckbox(item);
if (checkbox && !checkbox.disabled) checkbox.checked = isChecked(slot);
tree.setExpanded(item, isExpanded(slot));
}
tree.updateAllParentCheckboxes();
return true;
}
function applyTieredTreeState(tree, normalized, state) {
const resolvedByPath = new Map();
const checkedDecoders = new Map();
const expandedDecoders = new Map();
try {
for (const [depth, bytes] of state.checkedTiers) {
checkedDecoders.set(depth, decodeAdaptiveBitset(bytes, normalized.slots.length));
}
for (const [depth, bytes] of state.expandedTiers) {
expandedDecoders.set(depth, decodeAdaptiveBitset(bytes, normalized.slots.length));
}
} catch {
return false;
}
for (const [path, node] of tree.nodesByPath) {
const slot = normalized.pathToSlot.get(path);
if (slot === undefined) continue;
const depth = path.split(PATH_DELIMITER).length;
const parentPath = path.includes(PATH_DELIMITER)
? path.slice(0, path.lastIndexOf(PATH_DELIMITER))
: null;
const parent = resolvedByPath.get(parentPath) ?? {
checked: false,
hasChecked: false,
expanded: false,
hasExpanded: false
};
const item = tree.itemForNode(node.id);
const checkbox = item && tree.directCheckbox(item);
const toggle = item?.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle');
const isCovered = slot < state.coverage;
const checked = checkbox && isCovered
? (parent.hasChecked ? parent.checked : false) !== checkedDecoders.get(depth)(slot)
: false;
const expanded = Boolean(toggle && isCovered && expandedDecoders.get(depth)(slot));
if (checkbox && !checkbox.disabled) checkbox.checked = checked;
tree.setExpanded(item, expanded);
resolvedByPath.set(path, {
checked,
hasChecked: Boolean(checkbox),
expanded,
hasExpanded: Boolean(toggle)
});
}
tree.updateAllParentCheckboxes();
return true;
}
export function serializeStateToFragment(tree, manifest, encoding = tree.options?.stateEncoding ?? 'adaptive') {
const state = encodeTreeState(tree, manifest, encoding);
const params = new URLSearchParams({ v: String(state.version) });
if (state.encoding === 'tiered') {
for (const [name, value] of state.fields) params.set(name, value);
} else {
if (state.checked) params.set('c', state.checked);
if (state.expanded) params.set('x', state.expanded);
}
return `#${params}`;
}
export function restoreStateFromLocation(
tree,
manifest,
location = window.location,
encoding = tree.options?.stateEncoding ?? 'adaptive'
) {
const state = decodeTreeState(location.hash ?? '', manifest, encoding);
if (state.applied) applyTreeState(tree, manifest, state);
return state;
}