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
+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;
}