Using checkbox tree control
This commit is contained in:
+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