clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

archive/golden-reference
atlas / frontend / src / legend.js
legend.js 19.1K
1import { elements } from "./dom.js";2import { state } from "./state.js";3import { saveSession } from "./session.js";4import { applyPinFilters } from "./features.js";5import { curatedLabelPolicy, labelPolicy, renderAs } from "./semconv.js";6import { recountZoneTitles, syncZoneLayers } from "./areas.js";7import { applyCategoryVisual, applyCategoryGlyph, initials } from "./theme.js";8import { formatNumber } from "./util.js";910// The sidebar is one legend tree: shape collections, text labels, and pin11// groups are all sections of the same list, each holding rows of the same12// shape. Sections come from the wire's group strings, in order of first13// appearance; an ungrouped shape collection files under the viewer's own14// Zones section, which keeps the pre-collapse behaviour a place to land.15export function legendSections(collections) {16 const sections = [];17 const at = new Map();18 const place = (key, title, collection) => {19 if (!at.has(key)) {20 at.set(key, sections.length);21 sections.push({ key, title, collections: [] });22 }23 sections[at.get(key)].collections.push(collection);24 };25 const zones = [];26 for (const collection of collections) {27 if (collection.kind !== "point" && !collection.group) zones.push(collection);28 else place(`group-${collection.group}`, collection.group, collection);29 }30 // Text is how a point collection draws, not what it is: a collection of31 // cities rendered as floating names sits in its own group beside the32 // markers, wearing the toggle that flips it either way.33 if (zones.length) sections.unshift({ key: "zones", title: "Zones", collections: zones });34 return sections;35}3637// A collection either packs its members as pin locations or carries them38// inline as shape features; either way the row's count is how many there are.39export function collectionKind(collection) {40 return collection.kind || "point";41}4243export function collectionCount(collection) {44 return (collection.features || collection.locations || []).length;45}4647// Collection ids ride the DOM as strings, but point categories are known48// everywhere by number. One reader turns the attribute back into the id the49// state sets actually hold.50export function collectionID(value) {51 return /^\d+$/.test(value) ? Number(value) : value;52}5354export function findCollection(collectionID) {55 for (const section of state.world?.sections || []) {56 for (const collection of section.collections) {57 if (collection.id === collectionID) return collection;58 }59 }60 return null;61}6263export function renderLegend() {64 const fragment = document.createDocumentFragment();65 for (const section of state.world.sections) {66 const element = document.createElement("section");67 element.className = "layer-section";68 element.dataset.layerSection = section.key;69 const members = section.collections.reduce((total, collection) => total + collectionCount(collection), 0);70 element.append(layerHeader(section.key, section.title, members));71 const toggles = document.createElement("div");72 toggles.className = "category-toggles";73 for (const collection of section.collections) {74 toggles.append(collectionRow(collection));75 // A shape row unfolds into its feature index. The container is laid76 // down empty here and filled by renderZoneIndex once the zone records77 // exist -- the legend renders before the zones do.78 if (collectionKind(collection) !== "point") {79 const index = document.createElement("div");80 index.className = "feature-index";81 index.dataset.featureIndex = String(collection.id);82 toggles.append(index);83 }84 }85 element.append(toggles);86 fragment.append(element);87 }88 elements.legend.replaceChildren(fragment);89 syncSectionCollapse();90 syncCollectionExpansion();91 syncSectionSwitches();92}9394// Mirrors the markup every layer section reads the same by: disclosure on the95// left, one switch on the right.96export function layerHeader(key, title, count) {97 const header = document.createElement("div");98 header.className = "layer-header";99100 const disclosure = document.createElement("button");101 disclosure.type = "button";102 disclosure.className = "layer-title";103 disclosure.dataset.section = key;104 const chevron = document.createElement("span");105 chevron.className = "layer-chevron";106 chevron.setAttribute("aria-hidden", "true");107 chevron.innerHTML = '<svg viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg>';108 const name = document.createElement("span");109 name.textContent = title;110 const total = document.createElement("span");111 total.className = "layer-count";112 total.textContent = formatNumber(count);113 disclosure.append(chevron, name, total);114115 const only = onlyButton(`Exclusively ${title}`);116 only.dataset.onlySection = key;117118 const toggle = document.createElement("label");119 toggle.className = "layer-switch";120 const checkbox = document.createElement("input");121 checkbox.type = "checkbox";122 checkbox.dataset.sectionToggle = key;123 checkbox.setAttribute("aria-label", `Show ${title}`);124 const knob = document.createElement("span");125 knob.setAttribute("aria-hidden", "true");126 toggle.append(checkbox, knob);127128 header.append(disclosure, only, toggle);129 return header;130}131132// A target rather than the word "only": the legend's remaining words are then133// all things the map is actually about. The label arrives on rest, for anyone134// meeting the icon for the first time.135export function onlyButton(label) {136 const button = document.createElement("button");137 button.type = "button";138 button.className = "only-button";139 button.dataset.label = label;140 button.setAttribute("aria-label", label);141 button.innerHTML =142 '<svg viewBox="0 0 16 16" aria-hidden="true">' +143 '<circle cx="8" cy="8" r="4.6"/><circle cx="8" cy="8" r="1.3" fill="currentColor" stroke="none"/>' +144 '<path d="M8 1v2M8 13v2M1 8h2M13 8h2"/></svg>';145 return button;146}147148// One row for every kind of collection: checkbox, an icon cell -- the149// category's glyph for points, an unfolding chevron for shapes -- the name,150// the isolate target, the label-policy toggle where names are drawn on the151// ground, and the count. The columns are fixed so every row lines up whether152// or not it uses them all.153export function collectionRow(collection) {154 const kind = collectionKind(collection);155 const row = document.createElement("label");156 row.className = "category-row";157 const checkbox = document.createElement("input");158 checkbox.type = "checkbox";159 checkbox.dataset.collection = String(collection.id);160 checkbox.checked = !state.hiddenCollections.has(collection.id);161 let icon;162 if (kind !== "point") {163 icon = document.createElement("button");164 icon.type = "button";165 icon.className = "collection-expand";166 icon.dataset.expandCollection = String(collection.id);167 icon.setAttribute("aria-label", `Unfold the ${collection.title} index`);168 icon.innerHTML =169 '<svg class="collection-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg>' +170 (kind === "path"171 ? '<svg class="collection-kind" viewBox="0 0 16 16" aria-hidden="true"><path d="M2.5 12.5c3-6 8-2 11-9"/></svg>'172 : '<svg class="collection-kind collection-kind-area" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 4.2 12.6 11.6H3.4z"/></svg>');173 } else {174 icon = document.createElement("span");175 icon.className = "category-icon";176 applyCategoryVisual(row, collection);177 applyCategoryGlyph(icon, collection, initials(collection.title));178 icon.title = collection.icon || collection.title;179 }180 const name = document.createElement("span");181 name.className = "category-name";182 name.textContent = collection.title;183 // Overlaid on the count rather than appended: these pills wrap, and a row184 // that grows on hover would shove the one under the cursor somewhere else.185 const only = onlyButton(`Exclusively ${collection.title}`);186 only.dataset.onlyCollection = String(collection.id);187 // Areas draw their names on the ground, and whether they speak unasked is188 // the reader's to override. A point collection's override is which shape189 // it takes at all -- markers, or the floating names text categories used190 // to be set apart for. Paths keep the column as space, so the count stays191 // a column no row disagrees about.192 let labels;193 if (kind === "area") {194 labels = document.createElement("button");195 labels.type = "button";196 labels.className = "label-toggle";197 labels.dataset.labelToggle = String(collection.id);198 labels.innerHTML =199 '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 3.5h9M8 3.5v9"/></svg>';200 syncLabelToggle(labels, collection);201 } else if (kind === "point" && renderAs(collection) === "text") {202 // Only a collection whose curation granted it names has names to offer:203 // the toggle appears where the capability does, and a plain pin row204 // simply has no button to press.205 labels = document.createElement("button");206 labels.type = "button";207 labels.className = "label-toggle";208 labels.dataset.labelToggle = String(collection.id);209 labels.innerHTML =210 '<svg viewBox="0 0 16 16" aria-hidden="true">' +211 '<path d="M8.6 2.5H2.5v6.1l5.4 5.4a1.2 1.2 0 0 0 1.7 0l4.4-4.4a1.2 1.2 0 0 0 0-1.7z"/>' +212 '<path d="M5.6 5.6h.01"/></svg>';213 syncLabelToggle(labels, collection);214 } else {215 labels = document.createElement("span");216 labels.className = "label-toggle-spacer";217 }218 const count = document.createElement("span");219 count.className = "category-count";220 count.textContent = formatNumber(collectionCount(collection));221 // The label toggle sits inside the isolate target, so Only keeps one222 // column down the whole legend whether or not a row has labels to offer.223 row.append(checkbox, icon, name, labels, only, count);224 return row;225}226227// There is one level of nesting per move here -- sections hold rows, rows may228// hold a feature index -- so folding by a depth and folding entirely are the229// same move, and only the one exists.230export function setAllSectionsCollapsed(collapsed) {231 state.collapsedSections.clear();232 if (collapsed) {233 for (const button of elements.layers.querySelectorAll("[data-section]")) {234 state.collapsedSections.add(button.dataset.section);235 }236 }237 syncSectionCollapse();238}239240export function syncSectionCollapse() {241 for (const button of elements.layers.querySelectorAll("[data-section]")) {242 const collapsed = state.collapsedSections.has(button.dataset.section);243 button.setAttribute("aria-expanded", String(!collapsed));244 button.closest(".layer-section").classList.toggle("is-collapsed", collapsed);245 }246 saveSession();247}248249export function toggleCollectionExpanded(collectionID) {250 if (state.expandedCollections.has(collectionID)) state.expandedCollections.delete(collectionID);251 else state.expandedCollections.add(collectionID);252 syncCollectionExpansion();253}254255export function syncCollectionExpansion() {256 for (const button of elements.legend.querySelectorAll("[data-expand-collection]")) {257 const expanded = state.expandedCollections.has(collectionID(button.dataset.expandCollection));258 button.setAttribute("aria-expanded", String(expanded));259 button.closest(".category-row").classList.toggle("is-expanded", expanded);260 }261 for (const index of elements.legend.querySelectorAll(".feature-index")) {262 index.hidden = !state.expandedCollections.has(collectionID(index.dataset.featureIndex));263 }264 saveSession();265}266267// The label toggle is a two-state affair around the effective policy: press268// it and the collection's names flip to the other word; if the other word is269// what the producer curated anyway, the override has nothing left to say and270// is dropped rather than stored.271export function toggleLabelPolicy(id) {272 const collection = findCollection(id);273 if (!collection) return;274 const flipped = labelPolicy(null, collection) === "always" ? "quiet" : "always";275 if (flipped === curatedLabelPolicy(collection)) state.labelOverrides.delete(collection.id);276 else state.labelOverrides.set(collection.id, flipped);277 syncLabelToggles();278 if (collection.kind === "point") {279 // A point collection's names ride the pin-label layer.280 state.layers.pinLabels.changed();281 } else {282 // The crowd-thinning threshold counts spoken names only, and the toggle283 // just changed which names speak; ground names are drawn by the title284 // layers, at either zoom depth.285 recountZoneTitles();286 state.layers.zoneTitles.changed();287 state.layers.zoneTitleDetail.changed();288 }289 saveSession();290}291292// One toggle, one language: the button says what pressing it would do,293// "Hide labels" while the names are speaking and "Show labels" while they294// wait, whatever the kind of collection behind it. The chip is the whole295// tooltip -- no native title, which would say the same thing a second time296// a few pixels away -- and the row already names the collection, so the297// spoken label carries the name for ears alone.298function syncLabelToggle(button, collection) {299 const speaking = labelPolicy(null, collection) === "always";300 button.dataset.label = speaking ? "Hide labels" : "Show labels";301 button.setAttribute("aria-label",302 `${speaking ? "Hide" : "Show"} ${collection.title} labels`);303 button.setAttribute("aria-pressed", String(speaking));304}305306export function syncLabelToggles() {307 for (const button of elements.legend.querySelectorAll("[data-label-toggle]")) {308 const collection = findCollection(collectionID(button.dataset.labelToggle));309 if (collection) syncLabelToggle(button, collection);310 }311}312313export function setAllCollections(visible) {314 state.hiddenCollections.clear();315 if (!visible) {316 for (const section of state.world.sections) {317 for (const collection of section.collections) state.hiddenCollections.add(collection.id);318 }319 }320 syncLegendCheckboxes();321 syncZoneLayers();322 // applyPinFilters relists the dock along with the canvas.323 applyPinFilters();324 syncSectionSwitches();325}326327export function syncLegendCheckboxes() {328 for (const checkbox of document.querySelectorAll("[data-collection]")) {329 checkbox.checked = !state.hiddenCollections.has(collectionID(checkbox.dataset.collection));330 }331}332333export function toggleSection(key) {334 const section = state.world.sections.find((item) => item.key === key);335 if (!section) return;336 const hasVisible = section.collections.some((collection) => !state.hiddenCollections.has(collection.id));337 for (const collection of section.collections) {338 if (hasVisible) state.hiddenCollections.add(collection.id);339 else state.hiddenCollections.delete(collection.id);340 }341 syncLegendCheckboxes();342 syncZoneLayers();343 applyPinFilters();344 syncSectionSwitches();345}346347// Isolating is the common request of a long legend: "just the Korok Seeds",348// out of a hundred and sixty categories. It stays inside its own domain --349// point collections isolate against point collections, ground against350// ground -- so highlighting a region and then asking for only one resource351// leaves the region standing with the resource inside it. Everything else352// in the domain is hidden rather than remembered; Show all is the single,353// obvious way back.354function collectionDomain(collection) {355 return collection.kind === "point" ? "features" : "zones";356}357358// soloDomains names the ground a target's isolation may touch: the target359// collection's own domain, or every domain a section holds.360function soloDomains(target) {361 const domains = new Set();362 for (const section of state.world.sections) {363 for (const collection of section.collections) {364 if (target.section === section.key || target.collection === collection.id) {365 domains.add(collectionDomain(collection));366 }367 }368 }369 return domains;370}371372export function showOnly(target) {373 if (!state.world) return;374 // Asking to isolate what is already isolated means the reader is done with375 // it, so the same control lets them back out -- of this domain alone.376 const domains = soloDomains(target);377 if (isOnly(target)) {378 for (const section of state.world.sections) {379 for (const collection of section.collections) {380 if (domains.has(collectionDomain(collection))) {381 state.hiddenCollections.delete(collection.id);382 }383 }384 }385 } else {386 for (const section of state.world.sections) {387 const wanted = target.section === section.key;388 for (const collection of section.collections) {389 if (!domains.has(collectionDomain(collection))) continue;390 if (wanted || target.collection === collection.id) {391 state.hiddenCollections.delete(collection.id);392 } else {393 state.hiddenCollections.add(collection.id);394 }395 }396 }397 }398 syncLegendCheckboxes();399 syncZoneLayers();400 // applyPinFilters relists the dock along with the canvas.401 applyPinFilters();402 syncSectionSwitches();403}404405// True when this target's own domain already shows exactly what isolating406// it would show. Other domains are none of the target's business.407export function isOnly(target) {408 const domains = soloDomains(target);409 for (const section of state.world.sections) {410 for (const collection of section.collections) {411 if (!domains.has(collectionDomain(collection))) continue;412 const wanted = target.section === section.key || target.collection === collection.id;413 if (wanted === state.hiddenCollections.has(collection.id)) return false;414 }415 }416 return true;417}418419// Derived rather than remembered, so the chip is right however the state was420// reached -- including by switching collections off one at a time. Each421// domain speaks for itself, and the chip reads them together: a region422// isolated among the zones and a resource among the features is423// "only: Regions · Ore", and one click shows everything again.424export function updateSoloChip() {425 const chip = elements.soloChip;426 if (!state.world) {427 chip.hidden = true;428 return;429 }430 const labels = [];431 for (const domain of ["zones", "features"]) {432 let onlyVisible = null;433 let visibleCount = 0;434 let hiddenCount = 0;435 let soleSection = null;436 let sectionsShowing = 0;437 for (const section of state.world.sections) {438 let shown = 0;439 let held = 0;440 for (const collection of section.collections) {441 if (collectionDomain(collection) !== domain) continue;442 held++;443 if (state.hiddenCollections.has(collection.id)) {444 hiddenCount++;445 continue;446 }447 shown++;448 visibleCount++;449 onlyVisible = collection;450 }451 if (shown > 0) {452 sectionsShowing++;453 soleSection = shown === held ? section : null;454 }455 }456 if (hiddenCount === 0) continue;457 if (visibleCount === 1 && onlyVisible) labels.push(onlyVisible.title);458 else if (visibleCount > 1 && sectionsShowing === 1 && soleSection) {459 labels.push(soleSection.title);460 }461 }462 const label = labels.join(" · ");463 chip.hidden = !label;464 chip.textContent = label ? `exclusively: ${label}` : "";465 chip.title = label ? `Showing exclusively ${label} — click to show everything` : "";466}467468export function syncSectionSwitches() {469 if (!state.world) return;470 for (const section of state.world.sections) {471 const input = elements.legend.querySelector(`input[data-section-toggle="${section.key}"]`);472 if (input) {473 input.checked = section.collections.some((collection) => !state.hiddenCollections.has(collection.id));474 }475 }476 updateSoloChip();477 saveSession();478}