From 6bd490087bbab016c564231116543687356e5e8d Mon Sep 17 00:00:00 2001
From: aaronaxvig
Date: Sat, 11 Jul 2026 20:50:43 -0500
Subject: [PATCH] Using checkbox tree control
---
AGENTS.md | 16 +-
README.md | 6 +-
index.html | 6 +-
src/data/buildings.js | 14 +-
src/data/grid.js | 4 +-
src/data/pavement.js | 17 +-
src/data/plants.js | 27 ++-
src/data/yard.js | 3 +-
src/lib/checkbox-tree/checkbox-tree.css | 78 +++++++
src/lib/checkbox-tree/checkbox-tree.js | 290 ++++++++++++++++++++++++
src/lib/feature-types.js | 58 +++++
src/lib/yard-features.js | 19 +-
src/main.js | 213 +++++++++++++----
styles.css | 47 ++--
14 files changed, 680 insertions(+), 118 deletions(-)
create mode 100644 src/lib/checkbox-tree/checkbox-tree.css
create mode 100644 src/lib/checkbox-tree/checkbox-tree.js
diff --git a/AGENTS.md b/AGENTS.md
index a154484..fd64d0e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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`: Leaflet map, legend, popup rendering.
+- `src/main.js`: Leaflet 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. If closer basemap zoom fidelity matters, migrate from raster OSM tiles to a vector tile or self-hosted basemap.
+1. Decide whether parent-child feature relationships should also affect the visibility hierarchy.
+2. If closer basemap zoom fidelity matters, migrate from raster OSM tiles to a vector tile or self-hosted basemap.
## Useful validation commands
diff --git a/README.md b/README.md
index edc1a4f..7610403 100644
--- a/README.md
+++ b/README.md
@@ -34,13 +34,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 Leaflet 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 Leaflet 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 +55,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`: Leaflet rendering, legend generation, and popup rendering.
+- `src/main.js`: Leaflet rendering, visibility controls, and popup rendering.
## Key decisions
diff --git a/index.html b/index.html
index 25ae2c2..ec7816c 100644
--- a/index.html
+++ b/index.html
@@ -5,6 +5,7 @@
A Plot of Plants
+
@@ -17,7 +18,10 @@
a variety of trees, shrubs, and flowers. There are many varieties of
daylilies.
-
+
diff --git a/src/data/buildings.js b/src/data/buildings.js
index 9a7b69d..facf09d 100644
--- a/src/data/buildings.js
+++ b/src/data/buildings.js
@@ -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];
\ No newline at end of file
+export const buildingGroups = [buildingsGroup, housePartsGroup];
diff --git a/src/data/grid.js b/src/data/grid.js
index 3e199dd..c56040a 100644
--- a/src/data/grid.js
+++ b/src/data/grid.js
@@ -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(
diff --git a/src/data/pavement.js b/src/data/pavement.js
index 4008cdd..fd11297 100644
--- a/src/data/pavement.js
+++ b/src/data/pavement.js
@@ -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];
\ No newline at end of file
+export const pavementFeatures = [frontWalk, driveway, garageWalk, stepPad];
+export const pavementGroups = [pavedSurfacesGroup];
diff --git a/src/data/plants.js b/src/data/plants.js
index a8eee41..de07a84 100644
--- a/src/data/plants.js
+++ b/src/data/plants.js
@@ -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([
@@ -311,55 +314,55 @@ const trees = [
];
const bushes = [
- new Tree("Medora Juniper 1", {
+ new Bush("Medora Juniper 1", {
species: "Medora Juniper",
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",
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",
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",
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",
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",
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",
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",
measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(3.28) }],
}).add(geo.offset(lotCorner), geo.east(geo.ft(70)), geo.south(geo.ft(31))),
- new Tree("Spirea", {
+ new Bush("Spirea", {
species: "Spirea",
measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(3.28) }],
}).add(geo.offset(lotCorner), geo.east(geo.ft(74)), geo.south(geo.ft(31))),
- new Tree("Spirea", {
+ new Bush("Spirea", {
species: "Spirea",
measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(3.28) }],
}).add(geo.offset(lotCorner), geo.east(geo.ft(78)), geo.south(geo.ft(31))),
diff --git a/src/data/yard.js b/src/data/yard.js
index 9f4dda3..34fabbe 100644
--- a/src/data/yard.js
+++ b/src/data/yard.js
@@ -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,
],
);
diff --git a/src/lib/checkbox-tree/checkbox-tree.css b/src/lib/checkbox-tree/checkbox-tree.css
new file mode 100644
index 0000000..08a5842
--- /dev/null
+++ b/src/lib/checkbox-tree/checkbox-tree.css
@@ -0,0 +1,78 @@
+.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: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;
+}
diff --git a/src/lib/checkbox-tree/checkbox-tree.js b/src/lib/checkbox-tree/checkbox-tree.js
new file mode 100644
index 0000000..837f210
--- /dev/null
+++ b/src/lib/checkbox-tree/checkbox-tree.js
@@ -0,0 +1,290 @@
+const DEFAULT_OPTIONS = {
+ initiallyCollapsed: true,
+ initiallySelected: false,
+ storageKey: null,
+ onSelectionChange: null
+};
+
+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 };
+ this.nodesById = 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.rootNodes = nodes;
+ this.indexNodes(nodes);
+ this.render();
+ this.updateAllParentCheckboxes();
+ this.restoreState();
+ }
+
+ 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();
+ }
+
+ indexNodes(nodes) {
+ 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}`);
+ }
+
+ this.nodesById.set(node.id, 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);
+ }
+ });
+ }
+
+ 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;
+
+ 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) {
+ 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}`);
+ toggle.textContent = expanded ? '▼' : '▶';
+ }
+
+ directChildrenContainer(item) {
+ return item.querySelector(':scope > .checkbox-tree__children');
+ }
+
+ directCheckbox(item) {
+ return item.querySelector(':scope > .checkbox-tree__row .checkbox-tree__checkbox');
+ }
+
+ 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));
+ }
+ });
+ }
+ }
+}
diff --git a/src/lib/feature-types.js b/src/lib/feature-types.js
index 91b145b..dc0c27f 100644
--- a/src/lib/feature-types.js
+++ b/src/lib/feature-types.js
@@ -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",
@@ -152,6 +175,7 @@ export const featureTypes = {
},
flower: {
label: "Flower",
+ collectionLabel: "Flowers",
parentKind: "plant",
geometryType: "Point",
detailFields: {
@@ -177,6 +201,7 @@ export const featureTypes = {
},
daylily: {
label: "Daylily",
+ collectionLabel: "Daylilies",
parentKind: "flower",
geometryType: "Point",
detailFields: {
@@ -202,6 +227,7 @@ export const featureTypes = {
},
shrub: {
label: "Shrub",
+ collectionLabel: "Shrubs",
parentKind: "plant",
geometryType: "Point",
detailFields: {
@@ -224,6 +250,7 @@ export const featureTypes = {
},
tree: {
label: "Tree",
+ collectionLabel: "Trees",
parentKind: "plant",
geometryType: "Point",
detailFields: {
@@ -246,6 +273,7 @@ export const featureTypes = {
},
sprinkler: {
label: "Sprinkler",
+ categoryId: "infrastructure",
geometryType: "Point",
detailFields: {
zone: "string",
@@ -259,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) {
@@ -274,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),
@@ -283,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 };
@@ -310,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(
{},
diff --git a/src/lib/yard-features.js b/src/lib/yard-features.js
index f113c66..9f2e30a 100644
--- a/src/lib/yard-features.js
+++ b/src/lib/yard-features.js
@@ -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 = 3;
+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,
};
}
@@ -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");
}
}
@@ -402,9 +408,12 @@ export function collectYardData(features, groups = []) {
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;
diff --git a/src/main.js b/src/main.js
index 33373b0..74b6aa3 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,4 +1,5 @@
import { yardGeoJSON } from "./data/yard.js";
+import { CheckboxTree } from "./lib/checkbox-tree/checkbox-tree.js";
import { resolveFeatureLinks, resolveFeatureStyle } from "./lib/feature-types.js";
const TILE_MAX_NATIVE_ZOOM = 19;
@@ -46,10 +47,11 @@ L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
}).addTo(map);
let selectedTimelineDate = TIMELINE_PRESETS.at(-1).date;
+let selectedFeatureIndexes = new Set(yardGeoJSON.features.map((_, index) => index));
let yardLayer = null;
const timelineButtons = new Map();
-const legend = document.getElementById("legend");
+initializeVisibilityTree();
renderYardForDate(selectedTimelineDate);
addTimelineControl();
map.fitBounds(getGeoJSONBounds(yardGeoJSON).pad(0.2), { maxZoom: MAP_MAX_ZOOM });
@@ -106,7 +108,7 @@ function createYardLayer(collection) {
}
function renderYardForDate(date) {
- const filteredYard = filterFeatureCollectionByDate(yardGeoJSON, date);
+ const filteredYard = filterFeatureCollection(yardGeoJSON, date, selectedFeatureIndexes);
if (yardLayer) {
yardLayer.remove();
@@ -114,18 +116,179 @@ function renderYardForDate(date) {
meterScaledIconMarkers.length = 0;
yardLayer = createYardLayer(filteredYard).addTo(map);
- renderLegend(filteredYard.features);
syncTimelineButtons();
updateMeterScaledIcons();
}
-function filterFeatureCollectionByDate(collection, date) {
+function filterFeatureCollection(collection, date, visibleIndexes) {
return {
...collection,
- features: collection.features.filter((feature) => featureExistsOnDate(feature, date)),
+ features: collection.features.filter(
+ (feature, index) => visibleIndexes.has(index) && featureExistsOnDate(feature, date),
+ ),
};
}
+function initializeVisibilityTree() {
+ const tree = new CheckboxTree(document.getElementById("visibility-tree"), {
+ initiallyCollapsed: true,
+ initiallySelected: true,
+ storageKey: "a-plot-of-plants:visibility:v1",
+ onSelectionChange: ({ selectedNodes }) => {
+ selectedFeatureIndexes = getSelectedFeatureIndexes(selectedNodes);
+ renderYardForDate(selectedTimelineDate);
+ },
+ });
+
+ tree.setData(buildVisibilityNodes(yardGeoJSON));
+ selectedFeatureIndexes = getSelectedFeatureIndexes(tree.getSelectedNodes());
+}
+
+function getSelectedFeatureIndexes(nodes) {
+ return new Set(
+ nodes
+ .map((node) => node.metadata?.featureIndex)
+ .filter((index) => Number.isInteger(index)),
+ );
+}
+
+function buildVisibilityNodes(collection) {
+ const typeSchemasByKind = new Map(collection.featureTypes.map((type) => [type.kind, type]));
+ const categoriesById = new 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 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 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);
@@ -182,46 +345,6 @@ function syncTimelineButtons() {
}
}
-function renderLegend(features) {
- const legendEntries = summarizeTypes(features);
-
- legend.replaceChildren(
- ...legendEntries.map((entry) => {
- const row = document.createElement("div");
- row.className = "legend-item";
-
- const swatch = document.createElement("span");
- swatch.className = "legend-swatch";
- swatch.style.background = entry.color;
-
- const label = document.createElement("span");
- label.textContent = `${entry.label} (${entry.count})`;
-
- row.append(swatch, label);
- return row;
- }),
- );
-}
-
-function summarizeTypes(features) {
- const summary = new Map();
-
- for (const feature of features) {
- const key = feature.properties.kind;
- const style = getStyle(feature);
- const existing = summary.get(key) ?? {
- label: feature.properties.label,
- count: 0,
- color: style.color ?? "#333333",
- };
-
- existing.count += 1;
- summary.set(key, existing);
- }
-
- return [...summary.values()];
-}
-
function getStyle(feature) {
return resolveFeatureStyle(feature.properties.kind, feature.properties.details);
}
diff --git a/styles.css b/styles.css
index bf8917d..48fad30 100644
--- a/styles.css
+++ b/styles.css
@@ -67,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 {
@@ -300,23 +300,6 @@ main {
margin-bottom: 0;
}
- .legend {
- grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
- gap: 0.5rem 0.75rem;
- margin: 1rem 0 0;
- }
-
- .legend-item {
- gap: 0.5rem;
- min-width: 0;
- font-size: 0.9rem;
- }
-
- .legend-item span:last-child {
- min-width: 0;
- overflow-wrap: anywhere;
- }
-
#map {
min-height: min(72svh, 46rem);
border-radius: 12px;