Using checkbox tree control
This commit is contained in:
+10
-4
@@ -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
@@ -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
@@ -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];
|
||||
|
||||
+15
-12
@@ -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))),
|
||||
|
||||
+2
-1
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
{},
|
||||
|
||||
@@ -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;
|
||||
|
||||
+168
-45
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user