Different tree state URL encoding
This commit is contained in:
@@ -0,0 +1,142 @@
|
|||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"nodes": [
|
||||||
|
"visibility:category:plants",
|
||||||
|
"visibility:category:plants>visibility:type:flower",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:feature:83",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:feature:84",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:feature:85",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:feature:86",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:feature:87",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:feature:88",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:feature:89",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:feature:90",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:feature:91",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:53",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:54",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:55",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:56",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:57",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:58",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:59",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:60",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:61",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:62",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:63",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:64",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:65",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:66",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:67",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:68",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:69",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:70",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:71",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:72",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:73",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:74",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:75",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:76",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:77",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:78",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:79",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:80",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:81",
|
||||||
|
"visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:82",
|
||||||
|
"visibility:category:plants>visibility:type:tree",
|
||||||
|
"visibility:category:plants>visibility:type:tree>visibility:feature:92",
|
||||||
|
"visibility:category:plants>visibility:type:tree>visibility:feature:93",
|
||||||
|
"visibility:category:plants>visibility:type:tree>visibility:feature:94",
|
||||||
|
"visibility:category:plants>visibility:type:tree>visibility:feature:95",
|
||||||
|
"visibility:category:plants>visibility:type:tree>visibility:feature:96",
|
||||||
|
"visibility:category:plants>visibility:type:tree>visibility:feature:97",
|
||||||
|
"visibility:category:plants>visibility:type:tree>visibility:feature:98",
|
||||||
|
"visibility:category:plants>visibility:type:tree>visibility:feature:99",
|
||||||
|
"visibility:category:plants>visibility:type:tree>visibility:feature:100",
|
||||||
|
"visibility:category:plants>visibility:type:tree>visibility:feature:101",
|
||||||
|
"visibility:category:plants>visibility:type:tree>visibility:feature:102",
|
||||||
|
"visibility:category:plants>visibility:type:tree>visibility:feature:103",
|
||||||
|
"visibility:category:plants>visibility:type:tree>visibility:feature:114",
|
||||||
|
"visibility:category:plants>visibility:type:tree>visibility:feature:115",
|
||||||
|
"visibility:category:plants>visibility:type:tree>visibility:feature:116",
|
||||||
|
"visibility:category:plants>visibility:type:tree>visibility:feature:117",
|
||||||
|
"visibility:category:plants>visibility:type:bush",
|
||||||
|
"visibility:category:plants>visibility:type:bush>visibility:feature:109",
|
||||||
|
"visibility:category:plants>visibility:type:bush>visibility:feature:110",
|
||||||
|
"visibility:category:plants>visibility:type:bush>visibility:feature:111",
|
||||||
|
"visibility:category:plants>visibility:type:bush>visibility:feature:112",
|
||||||
|
"visibility:category:plants>visibility:type:bush>visibility:feature:113",
|
||||||
|
"visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees",
|
||||||
|
"visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:104",
|
||||||
|
"visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:105",
|
||||||
|
"visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:106",
|
||||||
|
"visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:107",
|
||||||
|
"visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:108",
|
||||||
|
"visibility:category:structures",
|
||||||
|
"visibility:category:structures>visibility:type:fence",
|
||||||
|
"visibility:category:structures>visibility:type:fence>visibility:feature:1",
|
||||||
|
"visibility:category:structures>visibility:type:flowerBed",
|
||||||
|
"visibility:category:structures>visibility:type:flowerBed>visibility:feature:48",
|
||||||
|
"visibility:category:structures>visibility:type:flowerBed>visibility:feature:49",
|
||||||
|
"visibility:category:structures>visibility:type:flowerBed>visibility:feature:50",
|
||||||
|
"visibility:category:structures>visibility:type:flowerBed>visibility:feature:51",
|
||||||
|
"visibility:category:structures>visibility:type:flowerBed>visibility:feature:52",
|
||||||
|
"visibility:category:structures>visibility:group:buildings",
|
||||||
|
"visibility:category:structures>visibility:group:buildings>visibility:feature:40",
|
||||||
|
"visibility:category:structures>visibility:group:buildings>visibility:feature:41",
|
||||||
|
"visibility:category:structures>visibility:group:buildings>visibility:group:house-parts",
|
||||||
|
"visibility:category:structures>visibility:group:buildings>visibility:group:house-parts>visibility:feature:42",
|
||||||
|
"visibility:category:structures>visibility:group:buildings>visibility:group:house-parts>visibility:feature:43",
|
||||||
|
"visibility:category:structures>visibility:group:paved-surfaces",
|
||||||
|
"visibility:category:structures>visibility:group:paved-surfaces>visibility:feature:44",
|
||||||
|
"visibility:category:structures>visibility:group:paved-surfaces>visibility:feature:45",
|
||||||
|
"visibility:category:structures>visibility:group:paved-surfaces>visibility:feature:46",
|
||||||
|
"visibility:category:structures>visibility:group:paved-surfaces>visibility:feature:47",
|
||||||
|
"visibility:category:infrastructure",
|
||||||
|
"visibility:category:infrastructure>visibility:type:sprinkler",
|
||||||
|
"visibility:category:infrastructure>visibility:type:sprinkler>visibility:feature:118",
|
||||||
|
"visibility:category:infrastructure>visibility:type:sprinkler>visibility:feature:119",
|
||||||
|
"visibility:category:reference",
|
||||||
|
"visibility:category:reference>visibility:type:lotBoundary",
|
||||||
|
"visibility:category:reference>visibility:type:lotBoundary>visibility:feature:0",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:2",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:3",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:4",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:5",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:6",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:7",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:8",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:9",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:10",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:11",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:12",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:13",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:14",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:15",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:16",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:17",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:18",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:19",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:20",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:21",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:22",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:23",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:24",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:25",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:26",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:27",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:28",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:29",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:30",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:31",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:32",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:33",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:34",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:35",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:36",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:37",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:38",
|
||||||
|
"visibility:category:reference>visibility:group:yard-grid>visibility:feature:39"
|
||||||
|
]
|
||||||
|
}
|
||||||
+29
-4
@@ -1,5 +1,5 @@
|
|||||||
import { yardGeoJSON } from "./data/yard.js";
|
import { yardGeoJSON } from "./data/yard.js";
|
||||||
import { CheckboxTree } from "../vendor/checkbox-tree/checkbox-tree.js";
|
import { CheckboxTree, loadManifest } from "../vendor/checkbox-tree/checkbox-tree.js";
|
||||||
import { resolveFeatureLinks, resolveFeatureStyle } from "./lib/feature-types.js";
|
import { resolveFeatureLinks, resolveFeatureStyle } from "./lib/feature-types.js";
|
||||||
|
|
||||||
const TILE_MAX_NATIVE_ZOOM = 19;
|
const TILE_MAX_NATIVE_ZOOM = 19;
|
||||||
@@ -51,7 +51,7 @@ let selectedFeatureIndexes = new Set(yardGeoJSON.features.map((_, index) => inde
|
|||||||
let yardLayer = null;
|
let yardLayer = null;
|
||||||
const timelineButtons = new Map();
|
const timelineButtons = new Map();
|
||||||
|
|
||||||
initializeVisibilityTree();
|
await initializeVisibilityTree();
|
||||||
renderYardForDate(selectedTimelineDate);
|
renderYardForDate(selectedTimelineDate);
|
||||||
addTimelineControl();
|
addTimelineControl();
|
||||||
map.fitBounds(getGeoJSONBounds(yardGeoJSON).pad(0.2), { maxZoom: MAP_MAX_ZOOM });
|
map.fitBounds(getGeoJSONBounds(yardGeoJSON).pad(0.2), { maxZoom: MAP_MAX_ZOOM });
|
||||||
@@ -129,19 +129,44 @@ function filterFeatureCollection(collection, date, visibleIndexes) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeVisibilityTree() {
|
async function initializeVisibilityTree() {
|
||||||
const tree = new CheckboxTree(document.getElementById("visibility-tree"), {
|
const container = document.getElementById("visibility-tree");
|
||||||
|
const manifest = loadManifest(
|
||||||
|
await fetch("./src/data/visibility-tree-manifest.json").then((response) => response.json()),
|
||||||
|
);
|
||||||
|
const tree = new CheckboxTree(container, {
|
||||||
initiallyCollapsed: true,
|
initiallyCollapsed: true,
|
||||||
initiallySelected: true,
|
initiallySelected: true,
|
||||||
storageKey: "a-plot-of-plants:visibility:v1",
|
storageKey: "a-plot-of-plants:visibility:v1",
|
||||||
|
manifest,
|
||||||
onSelectionChange: ({ selectedNodes }) => {
|
onSelectionChange: ({ selectedNodes }) => {
|
||||||
selectedFeatureIndexes = getSelectedFeatureIndexes(selectedNodes);
|
selectedFeatureIndexes = getSelectedFeatureIndexes(selectedNodes);
|
||||||
renderYardForDate(selectedTimelineDate);
|
renderYardForDate(selectedTimelineDate);
|
||||||
|
pushTreeState(tree);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
tree.setData(buildVisibilityNodes(yardGeoJSON));
|
tree.setData(buildVisibilityNodes(yardGeoJSON));
|
||||||
selectedFeatureIndexes = getSelectedFeatureIndexes(tree.getSelectedNodes());
|
selectedFeatureIndexes = getSelectedFeatureIndexes(tree.getSelectedNodes());
|
||||||
|
|
||||||
|
container.addEventListener("click", (event) => {
|
||||||
|
if (event.target.closest(".checkbox-tree__toggle")) {
|
||||||
|
pushTreeState(tree);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener("popstate", () => {
|
||||||
|
tree.restoreStateFromLocation();
|
||||||
|
selectedFeatureIndexes = getSelectedFeatureIndexes(tree.getSelectedNodes());
|
||||||
|
renderYardForDate(selectedTimelineDate);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushTreeState(tree) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.hash = tree.serializeStateToFragment();
|
||||||
|
if (url.href !== window.location.href) {
|
||||||
|
history.pushState(null, "", url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectedFeatureIndexes(nodes) {
|
function getSelectedFeatureIndexes(nodes) {
|
||||||
|
|||||||
Vendored
+124
-4
@@ -1,5 +1,125 @@
|
|||||||
# Checkbox Tree runtime snapshot
|
# Checkbox Tree
|
||||||
|
|
||||||
Vendored from `@aaronaxvig/checkbox-tree` version `0.1.0` while
|
A dependency-free, instance-based checkbox tree for browser ES modules. It
|
||||||
`git.axvig.com` is unavailable. Do not edit these runtime files directly; update
|
supports cascading selection, indeterminate parent states, expandable branches,
|
||||||
the canonical `checkbox-tree` repository and refresh this snapshot.
|
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 two files in `src/` and
|
||||||
|
use relative URLs:
|
||||||
|
|
||||||
|
```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()`.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
+332
-3
@@ -2,9 +2,310 @@ const DEFAULT_OPTIONS = {
|
|||||||
initiallyCollapsed: true,
|
initiallyCollapsed: true,
|
||||||
initiallySelected: false,
|
initiallySelected: false,
|
||||||
storageKey: null,
|
storageKey: null,
|
||||||
onSelectionChange: null
|
onSelectionChange: null,
|
||||||
|
manifest: null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SERIALIZATION_VERSION = 1;
|
||||||
|
const PATH_DELIMITER = '>';
|
||||||
|
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function asManifest(manifest) {
|
||||||
|
return manifest?.pathToSlot instanceof Map ? manifest : loadManifest(manifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeTreeState(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');
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkedValue = bytesToBase64Url(encodeAdaptiveBitset(checkedValues, normalized.slots.length));
|
||||||
|
const expandedValue = bytesToBase64Url(encodeAdaptiveBitset(expandedValues, normalized.slots.length));
|
||||||
|
return { version: SERIALIZATION_VERSION, checked: checkedValue || null, expanded: expandedValue || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeTreeState(fragment, manifest) {
|
||||||
|
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'), warnings: ['Missing or unsupported serialization version.'] };
|
||||||
|
}
|
||||||
|
const maximumLength = Math.max(
|
||||||
|
Math.ceil(normalized.slots.length / 8) + 1,
|
||||||
|
normalized.slots.length * 2 + 10
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const checked = base64UrlToBytes(params.get('c') ?? '', maximumLength);
|
||||||
|
const expanded = base64UrlToBytes(params.get('x') ?? '', maximumLength);
|
||||||
|
decodeAdaptiveBitset(checked, normalized.slots.length);
|
||||||
|
decodeAdaptiveBitset(expanded, normalized.slots.length);
|
||||||
|
return {
|
||||||
|
applied: true,
|
||||||
|
version: SERIALIZATION_VERSION,
|
||||||
|
checked,
|
||||||
|
expanded,
|
||||||
|
warnings: []
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { applied: false, version: SERIALIZATION_VERSION, warnings: [error.message] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTreeState(tree, manifest, state) {
|
||||||
|
if (!state?.applied) return false;
|
||||||
|
const normalized = asManifest(manifest);
|
||||||
|
let isChecked;
|
||||||
|
let isExpanded;
|
||||||
|
try {
|
||||||
|
isChecked = decodeAdaptiveBitset(state.checked, normalized.slots.length);
|
||||||
|
isExpanded = decodeAdaptiveBitset(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeStateToFragment(tree, manifest) {
|
||||||
|
const state = encodeTreeState(tree, manifest);
|
||||||
|
const params = new URLSearchParams({ v: String(state.version) });
|
||||||
|
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) {
|
||||||
|
const state = decodeTreeState(location.hash ?? '', manifest);
|
||||||
|
if (state.applied) applyTreeState(tree, manifest, state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
export class CheckboxTree {
|
export class CheckboxTree {
|
||||||
constructor(container, options = {}) {
|
constructor(container, options = {}) {
|
||||||
if (!(container instanceof Element)) {
|
if (!(container instanceof Element)) {
|
||||||
@@ -14,6 +315,7 @@ export class CheckboxTree {
|
|||||||
this.container = container;
|
this.container = container;
|
||||||
this.options = { ...DEFAULT_OPTIONS, ...options };
|
this.options = { ...DEFAULT_OPTIONS, ...options };
|
||||||
this.nodesById = new Map();
|
this.nodesById = new Map();
|
||||||
|
this.nodesByPath = new Map();
|
||||||
this.rootNodes = [];
|
this.rootNodes = [];
|
||||||
this.handleChange = this.handleChange.bind(this);
|
this.handleChange = this.handleChange.bind(this);
|
||||||
this.handleClick = this.handleClick.bind(this);
|
this.handleClick = this.handleClick.bind(this);
|
||||||
@@ -29,11 +331,13 @@ export class CheckboxTree {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.nodesById.clear();
|
this.nodesById.clear();
|
||||||
|
this.nodesByPath.clear();
|
||||||
this.rootNodes = nodes;
|
this.rootNodes = nodes;
|
||||||
this.indexNodes(nodes);
|
this.indexNodes(nodes);
|
||||||
this.render();
|
this.render();
|
||||||
this.updateAllParentCheckboxes();
|
this.updateAllParentCheckboxes();
|
||||||
this.restoreState();
|
this.restoreState();
|
||||||
|
if (this.options.manifest) this.restoreStateFromLocation();
|
||||||
}
|
}
|
||||||
|
|
||||||
getSelectedIds() {
|
getSelectedIds() {
|
||||||
@@ -66,9 +370,10 @@ export class CheckboxTree {
|
|||||||
this.container.classList.remove('checkbox-tree');
|
this.container.classList.remove('checkbox-tree');
|
||||||
this.container.replaceChildren();
|
this.container.replaceChildren();
|
||||||
this.nodesById.clear();
|
this.nodesById.clear();
|
||||||
|
this.nodesByPath.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
indexNodes(nodes) {
|
indexNodes(nodes, parentPath = '') {
|
||||||
nodes.forEach(node => {
|
nodes.forEach(node => {
|
||||||
if (!node || typeof node.id !== 'string' || !node.id || typeof node.label !== 'string') {
|
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.');
|
throw new TypeError('Each CheckboxTree node requires non-empty string id and label properties.');
|
||||||
@@ -77,12 +382,18 @@ export class CheckboxTree {
|
|||||||
throw new Error(`Duplicate CheckboxTree node id: ${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);
|
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 (node.children !== undefined) {
|
||||||
if (!Array.isArray(node.children)) {
|
if (!Array.isArray(node.children)) {
|
||||||
throw new TypeError(`CheckboxTree node children must be an array: ${node.id}`);
|
throw new TypeError(`CheckboxTree node children must be an array: ${node.id}`);
|
||||||
}
|
}
|
||||||
this.indexNodes(node.children);
|
this.indexNodes(node.children, path);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -191,6 +502,7 @@ export class CheckboxTree {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setExpanded(item, expanded) {
|
setExpanded(item, expanded) {
|
||||||
|
if (!item) return;
|
||||||
const toggle = item.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle');
|
const toggle = item.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle');
|
||||||
const children = this.directChildrenContainer(item);
|
const children = this.directChildrenContainer(item);
|
||||||
if (!toggle || !children) {
|
if (!toggle || !children) {
|
||||||
@@ -211,6 +523,11 @@ export class CheckboxTree {
|
|||||||
return item.querySelector(':scope > .checkbox-tree__row .checkbox-tree__checkbox');
|
return item.querySelector(':scope > .checkbox-tree__row .checkbox-tree__checkbox');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
itemForNode(id) {
|
||||||
|
return Array.from(this.container.querySelectorAll('.checkbox-tree__item'))
|
||||||
|
.find(item => item.dataset.nodeId === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
updateAncestorCheckboxes(item) {
|
updateAncestorCheckboxes(item) {
|
||||||
let parentItem = item.parentElement?.closest('.checkbox-tree__item');
|
let parentItem = item.parentElement?.closest('.checkbox-tree__item');
|
||||||
while (parentItem && this.container.contains(parentItem)) {
|
while (parentItem && this.container.contains(parentItem)) {
|
||||||
@@ -286,4 +603,16 @@ export class CheckboxTree {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user