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 / search.js
search.js 6.7K
1import { elements } from "./dom.js";2import { saveSession } from "./session.js";3import { state } from "./state.js";4import { applyCategoryVisual, applyCategoryGlyph, initials } from "./theme.js";5import { formatNumber } from "./util.js";6import { filtersApply, listableFeatures } from "./visibility.js";78// The dock lists at most this many rows. Every annotation still renders on9// the canvas; the list is a shortlist, and it says when it is one.10const dockListLimit = 100;1112// The dock's results pane: exactly the features the map is drawing, in13// alphabetical order and capped -- named shape features listed beside the14// locations, since ground is as findable as anything standing on it. It used15// to list everything the legend allowed, which is a longer answer than the map16// gives whenever ground or a cell is holding the view: the panel offered sixty17// places while one stood on the chart. What is listed is what is out there.18export function renderSearchResults() {19 if (!elements.searchResults) return;20 const eligible = listableFeatures();21 const shown = eligible.slice(0, dockListLimit);22 const fragment = document.createDocumentFragment();23 if (!shown.length) {24 const empty = document.createElement("p");25 empty.className = "search-empty";26 empty.textContent = state.search27 ? "No visible features match."28 : state.highlightedZones.size29 ? "Nothing stands inside the highlighted ground."30 : "Nothing is shown. Bring a collection back to list its features.";31 fragment.append(empty);32 }33 for (const entry of shown) {34 fragment.append(entry.pin ? pinResult(entry.pin) : zoneResult(entry.record));35 }36 if (eligible.length > shown.length) {37 const note = document.createElement("p");38 note.className = "dock-note";39 note.textContent = `First ${shown.length} of ${formatNumber(eligible.length)} — ` +40 "search or filter to narrow the list.";41 fragment.append(note);42 }43 elements.searchResults.replaceChildren(fragment);44 updateDockReadout(eligible.length);45 updateDockRail();46}4748function pinResult(pin) {49 const button = document.createElement("button");50 button.type = "button";51 button.className = "search-result";52 if (state.selectedPin === pin) button.classList.add("is-selected");53 button.dataset.location = String(pin.location.id);54 applyCategoryVisual(button, pin.category);55 const dot = document.createElement("span");56 dot.className = "result-dot";57 applyCategoryGlyph(dot, pin.category, initials(pin.category.title));58 const copy = document.createElement("span");59 const title = document.createElement("strong");60 title.textContent = pin.location.title;61 const category = document.createElement("small");62 category.textContent = [pin.category.title, pin.category.group].filter(Boolean).join(" · ");63 copy.append(title, category);64 button.append(dot, copy);65 return button;66}6768// A shape feature's row wears a swatch of the zone's own colour where a pin69// wears its category glyph, and jumps the way its legend row does.70function zoneResult(record) {71 const button = document.createElement("button");72 button.type = "button";73 button.className = "search-result";74 if (state.selectedZone === record.zone) button.classList.add("is-selected");75 button.dataset.zone = String(record.zone.id);76 const dot = document.createElement("span");77 dot.className = "result-dot";78 dot.style.setProperty("background", record.color);79 const copy = document.createElement("span");80 const title = document.createElement("strong");81 title.textContent = record.zone.title;82 const collection = document.createElement("small");83 collection.textContent = record.collection?.title || "Zone";84 copy.append(title, collection);85 button.append(dot, copy);86 return button;87}8889// Folded, the dock is a rail with one word on it, and that word read the same90// whether or not a location was open: the map had a pin picked out and the91// only panel saying which one was the one just put away. The rail carries the92// name until the pin is closed, so folding the panel costs the view of the map93// and nothing else.94const railNameLimit = 28;9596export function updateDockRail() {97 const title = state.selectedPin?.location.title || "";98 // The rail runs the height of the window, and a long name would run past the99 // bottom of it. Cut it where the reader can still recognise the place.100 const shown = title.length > railNameLimit101 ? `${title.slice(0, railNameLimit - 1).trimEnd()}…`102 : title;103 elements.dockRailName.textContent = shown ? ` · ${shown}` : "";104 elements.dockRailName.hidden = !shown;105}106107// The header counts the list under it and says why it is as short as it is:108// the search in the reader's own words, or the flag for the filters they set109// on the other side of the map. Every filter that can move the number raises110// the flag -- a collection put away, ground highlighted, a cell holding the111// view -- so a count that drops always has its reason on screen beside it.112function updateDockReadout(count) {113 elements.dockCount.textContent =114 `${formatNumber(count)} ${count === 1 ? "feature" : "features"}`;115 if (state.search) {116 elements.dockFlag.textContent = `“${state.search}”`;117 elements.dockFlag.hidden = false;118 } else if (filtersApply()) {119 elements.dockFlag.textContent = "filtered";120 elements.dockFlag.hidden = false;121 } else {122 elements.dockFlag.hidden = true;123 }124}125126// The panel starts out of the way, because the map is the reason the window is127// open, and comes out by itself the first time it has something to say -- a pin128// opened, or a search with results in it. Once, and only until the reader has129// answered the question by putting it away: after that it stays where they put130// it and the map is theirs.131export function revealDock() {132 if (!state.dockFolded || state.dockDismissed) return;133 setDockFolded(false);134}135136// The reader working the control themselves is what settles it. Folding it by137// hand is the answer that stops it opening again; unfolding by hand withdraws138// that answer, so a panel put away and brought back is once more a panel that139// keeps up with what is selected.140export function foldDockByHand(folded) {141 state.dockDismissed = folded;142 setDockFolded(folded);143}144145// Put away rather than closed: the rail keeps the count's place and the way146// back. Like the overview, the choice is remembered with the volume.147export function setDockFolded(folded, remember = true) {148 state.dockFolded = folded;149 elements.dock.classList.toggle("is-folded", folded);150 elements.dockFold.setAttribute("aria-expanded", String(!folded));151 const label = folded ? "Bring the panel back (⌘⌥B)" : "Put the panel away (⌘⌥B)";152 elements.dockFold.setAttribute("aria-label", label);153 elements.dockFold.setAttribute("title", label);154 if (remember) saveSession();155}