clone

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

feat: make the geohash grid a way of asking rather than a mesh over the map

Dami committed 9567883
The grid was drawn whether or not it could be read, and scoping by a cell
meant living with the mesh that chose it. Several things follow from taking it
as a way of asking where something is.

A cell is named or it is not drawn. The hash is measured in the font it is
drawn in, and a subdivision too small to carry its names waits for the zoom
that has room for them, rather than laying a mesh of unreadable boxes over the
map. The labels are monospace now, as the field they are typed into already
was: in a proportional face m6w is half again the width of m6j, so a level
lost its labels a few cells at a time instead of as one.

Space, or the button beside the field, puts the subdivision away and leaves
exactly the state the map reaches on its own when the labels no longer fit --
the chosen cell keeps its boundary, the shaded ancestors keep theirs, and the
scope goes on holding the map to a place. Nothing is hidden that says where
the reader is. Opening the grid again divides the cell again, since a reader
who put the subgrid away and then closed the grid is starting over.

Descending into a cell now culls what is outside it, the way highlighting a
zone does. Asking about one cell and being shown the whole map is not an
answer. The selected pin and anything matching a search stay, so neither is
lost mid-question.

The grid divides the ground a map covers rather than the window its raster is
cut from, which is the field the catalog just started carrying. G opens the
grid with the field ready for the rest of what is being typed, so "gm6"
arrives at m6, and Escape leaves the field before it leaves the level.
commit
95678833f793c3da692c1904583d943fd286d3c7
parent
b3439fffd3914c6b4d7a18f00d860123a302db43
author
Dami <dami.etoile@gmail.com>
3 files changed, +240 -39
Massets/index.html8+
Mfrontend/app.css32++++
Mfrontend/app.js239+++++++++++++++++++++++++-----
M assets/index.html +8 -0
M frontend/app.css +32 -0
M frontend/app.js +200 -39
@@ -53,6 +53,7 @@ const elements = {
5353 gridNavigator: $("#grid-navigator"),5454 gridInput: $("#grid-input"),5555 gridBack: $("#grid-back"),56+ subgridToggle: $("#subgrid-toggle"),5657 detail: $("#pin-detail"),5758 detailTitle: $("#detail-title"),5859 detailCategory: $("#detail-category"),
@@ -93,6 +94,9 @@ const state = {
9394 hoveredPin: null,9495 pinLabelsVisible: false,9596 gridEnabled: false,97+ // Whether the cells are drawn, which is a separate question from whether one98+ // of them is holding the map to a place.99+ subgridVisible: true,96100 gridPrefix: "",97101 zonesVisible: true,98102 zoneRecords: new Map(),
@@ -313,29 +317,27 @@ function initializeMap() {
313317 if (state.gridEnabled) {314318 const cell = state.engine.forEachFeatureAtPixel(315319 event.pixel,316- (feature) => feature.get("gridCell") || null,320+ // The cell the reader is already in lies over its own children, and321+ // answering for them would mean the grid could not be descended.322+ (feature) => {323+ const under = feature.get("gridCell");324+ return under && under.role !== "leaf" && under.role !== "scope" ? under : null;325+ },317326 {318327 hitTolerance: 1,319328 layerFilter: (layer) =>320329 layer === state.layers.grid || layer === state.layers.gridContext,321330 },322331 );323- if (cell && cell.role !== "leaf") {332+ if (cell) {324333 selectGridPrefix(cell.hash);325334 return;326335 }327336 }328337 const pin = state.engine.forEachFeatureAtPixel(329338 event.pixel,330- (feature, layer) => {331- if (layer !== state.layers.pins && layer !== state.layers.pinLabels &&332- layer !== state.layers.text && layer !== state.layers.textDetail &&333- layer !== state.layers.priority) {334- return null;335- }336- return feature.get("pin") || null;337- },338- { hitTolerance: 5 },339+ (feature, layer) => (isAnnotationLayer(layer) && feature.get("pin")) || null,340+ { hitTolerance: 5, layerFilter: isAnnotationLayer },339341 );340342 if (pin) showPin(pin);341343 });
@@ -502,6 +504,25 @@ function bindUIEvents() {
502504 elements.gridInput.value = value;503505 selectGridPrefix(value);504506 });507+ elements.subgridToggle.addEventListener("click", () => {508+ setSubgridVisible(!state.subgridVisible);509+ elements.viewport.focus({ preventScroll: true });510+ });511+ // Escape leaves the field before it leaves the level: the first press hands512+ // the keyboard back to the map, and the next one telescopes out as ever.513+ // Space reaches the subgrid from inside the field too, since a hash has no514+ // spaces in it and clearing the mesh is what is most wanted while typing one.515+ elements.gridInput.addEventListener("keydown", (event) => {516+ if (event.key === " ") {517+ event.preventDefault();518+ setSubgridVisible(!state.subgridVisible);519+ return;520+ }521+ if (event.key !== "Escape") return;522+ event.preventDefault();523+ event.stopPropagation();524+ elements.viewport.focus({ preventScroll: true });525+ });505526 506527 elements.viewport.addEventListener("keydown", (event) => {507528 if (isEditableTarget(event.target)) return;
@@ -1092,10 +1113,44 @@ function activeExtent() {
10921113 return [bounds.x, -(bounds.y + bounds.height), bounds.x + bounds.width, -bounds.y];10931114 }10941115 1116+// What the grid divides is the ground the map covers, which is not the window1117+// its raster is cut from: a piece of a sheet keeps a margin so the title drawn1118+// beside it survives the crop. On a big map that margin is nothing; on a small1119+// one it is a fifth of the width, and the cells over it are cells the map never1120+// gets -- b naming blank sheet while m carries what b should have held.1121+function gridExtent() {1122+ const surface = state.variant?.surface;1123+ if (!surface) return activeExtent();1124+ return [1125+ surface.x,1126+ -(surface.y + surface.height),1127+ surface.x + surface.width,1128+ -surface.y,1129+ ];1130+}1131+10951132 function handleGridKey(event) {10961133 if (event.key.toLocaleLowerCase() === "g") {10971134 event.preventDefault();10981135 toggleGrid();1136+ // The grid is opened to go somewhere, so the field that takes a hash is1137+ // ready for the rest of what is being typed: "gm6" arrives at m6. Closing1138+ // hands the keyboard back to the map, where the shortcuts live.1139+ if (state.gridEnabled) {1140+ elements.gridInput.focus();1141+ elements.gridInput.select();1142+ } else {1143+ elements.viewport.focus({ preventScroll: true });1144+ }1145+ return true;1146+ }1147+ // Space is about what the chosen cell is divided into, not where the reader1148+ // is: the cell stays chosen and the pins outside it stay put away, while the1149+ // subdivision inside it gets out of the way of the map underneath.1150+ // The button answers its own space bar, and would otherwise be pressed twice.1151+ if (event.key === " " && state.gridEnabled && event.target !== elements.subgridToggle) {1152+ event.preventDefault();1153+ setSubgridVisible(!state.subgridVisible);10991154 return true;11001155 }11011156 if (event.key === "Escape" && state.gridEnabled) {
@@ -1108,6 +1163,9 @@ function handleGridKey(event) {
11081163 11091164 function toggleGrid(enabled = !state.gridEnabled) {11101165 state.gridEnabled = enabled;1166+ // Opening the grid divides the cell again. A reader who put the subgrid away1167+ // and then closed the grid altogether is starting over, not resuming.1168+ if (enabled) setSubgridVisible(true);11111169 renderGrid();11121170 refreshPrioritySource();11131171 state.layers.pins.changed();
@@ -1115,6 +1173,32 @@ function toggleGrid(enabled = !state.gridEnabled) {
11151173 state.layers.priority.changed();11161174 }11171175 1176+// Dividing the chosen cell and being held to it are two questions. The1177+// subdivision is how a reader picks the next place; once picked, the mesh over1178+// the map is often the last thing they want to look at, and until now the only1179+// way to be rid of it was to give up the scope it was holding. Nothing is1180+// really hidden: the cell keeps its boundary and the ancestors keep theirs.1181+function setSubgridVisible(visible) {1182+ state.subgridVisible = visible;1183+ state.layers.grid.changed();1184+ state.layers.gridContext.changed();1185+ elements.subgridToggle.setAttribute("aria-pressed", String(visible));1186+ elements.subgridToggle.setAttribute("aria-label",1187+ visible ? "Hide the subgrid" : "Show the subgrid");1188+ updateGridHint();1189+}1190+1191+function updateGridHint() {1192+ if (!state.gridEnabled) {1193+ elements.gridHint.textContent = "G · grid off";1194+ return;1195+ }1196+ // Compact while it is carrying a hash: the mode, the place and the state of1197+ // the subdivision are one reading, not three separate ones.1198+ elements.gridHint.textContent = `G-${state.gridPrefix || "root"}` +1199+ (state.subgridVisible ? "" : " no subgrid");1200+}1201+11181202 function selectGridPrefix(prefix) {11191203 const maximum = gridMaxDepth();11201204 const normalized = [...prefix.toLocaleLowerCase()]
@@ -1153,9 +1237,7 @@ function renderGrid() {
11531237 state.sources.grid.clear();11541238 state.sources.gridContext.clear();11551239 elements.gridNavigator.hidden = !state.gridEnabled;1156- elements.gridHint.textContent = state.gridEnabled1157- ? `G · ${state.gridPrefix || "root"}`1158- : "G · grid off";1240+ updateGridHint();11591241 if (!state.gridEnabled || !state.variant) return;11601242 11611243 const maximum = gridMaxDepth();
@@ -1168,6 +1250,12 @@ function renderGrid() {
11681250 addGridFeature(state.gridPrefix, currentGridExtent(), "leaf");11691251 return;11701252 }1253+ // The cell the reader is inside, outlined rather than tiled. It is the one1254+ // part of the grid that survives putting the grid away: what is on screen is1255+ // still a chosen place, and a boundary says so where a bare map does not.1256+ if (state.gridPrefix) {1257+ addGridFeature(state.gridPrefix, currentGridExtent(), "scope");1258+ }11711259 for (const character of geohashAlphabet) {11721260 const hash = state.gridPrefix + character;11731261 addGridFeature(hash, geohashExtent(hash), "child");
@@ -1215,7 +1303,7 @@ function currentGridExtent() {
12151303 }12161304 12171305 function geohashExtent(hash) {1218- const extent = [...activeExtent()];1306+ const extent = [...gridExtent()];12191307 let splitX = true;12201308 for (const character of hash) {12211309 const value = geohashAlphabet.indexOf(character);
@@ -2278,42 +2366,104 @@ function textStyles(pin, selected) {
22782366 return styles;22792367 }22802368 2281-function gridStyle(feature) {2369+function gridStyle(feature, resolution) {22822370 const cell = feature.get("gridCell");22832371 if (!cell) return null;2284- const key = `grid:${cell.hash}:${cell.role}:${cell.contextDistance}`;2372+ const leaf = cell.role === "leaf";2373+ const scope = cell.role === "scope";2374+ const neighbor = cell.role === "neighbor";2375+ // Monospace, as the field these are typed into already is: a hash is a code,2376+ // and in a proportional face m6w is half again the width of m6j, so a level2377+ // lost its labels a few cells at a time as the map shrank. Every hash at a2378+ // level is the same length, so in a fixed pitch they are the same width, and2379+ // the level keeps or drops its labels as one.2380+ const size = leaf || scope ? 15 : neighbor ? 10 : 11;2381+ const weight = leaf || scope ? 900 : neighbor ? 750 : 800;2382+ const font = `${weight} ${size}px ui-monospace, SFMono-Regular, Menlo, monospace`;2383+ const padding = neighbor ? [2, 4, 2, 4] : [3, 5, 3, 5];2384+ const labelled = labelFitsCell(cell, font, size, padding, resolution);2385+ // A subdivision is offered so it can be named and descended into, and one too2386+ // small to carry its names is a mesh laid over the map. It waits for the zoom2387+ // that has room for them, so the cells and their labels arrive together --2388+ // and putting the grid away by hand leaves exactly that state, the one the2389+ // map arrives at on its own. What stays either way is the boundary of the2390+ // chosen cell and the shaded ancestors around it, which say where the reader2391+ // is and dim what is outside.2392+ if (cell.role === "child" && (!labelled || !state.subgridVisible)) return null;2393+ // The scope is drawn two ways: with the subgrid inside it, where a bare2394+ // outline would be lost among its own children, and without, where the2395+ // boundary is what is left of the cell and carries its name.2396+ const bare = scope && state.subgridVisible;2397+ const key = `grid:${cell.hash}:${cell.role}:${cell.contextDistance}:` +2398+ `${labelled ? 1 : 0}:${bare ? 1 : 0}`;22852399 if (state.styleCache.has(key)) return state.styleCache.get(key);22862400 const color = palette[Math.max(0, geohashAlphabet.indexOf(cell.hash[cell.hash.length - 1])) %22872401 palette.length];2288- const leaf = cell.role === "leaf";2289- const neighbor = cell.role === "neighbor";22902402 const style = new Style({2291- fill: new Fill({2292- color: neighbor2293- ? `rgba(3, 5, 3, ${Math.min(0.52, 0.30 + cell.contextDistance * 0.06)})`2294- : hexToRGBA(color, leaf ? 0.14 : 0.055),2295- }),2296- stroke: new Stroke({2297- color: leaf ? "#ffffff" : hexToRGBA(color, neighbor ? 0.44 : 0.82),2298- width: leaf ? 2.5 : neighbor ? 1 : 1.4,2299- }),2300- text: new Text({2301- text: cell.hash,2302- font: `${leaf ? "900 17px" : neighbor ? "750 11px" : "800 13px"} "Arial Narrow", "Roboto Condensed", sans-serif`,2303- fill: new Fill({ color: leaf ? "#ffffff" : neighbor ? hexToRGBA(color, 0.72) : color }),2304- stroke: new Stroke({ color: "rgba(0,0,0,0.96)", width: 4 }),2305- backgroundFill: new Fill({2306- color: neighbor ? "rgba(4,6,4,0.88)" : "rgba(9,12,8,0.76)",2403+ fill: scope2404+ ? undefined2405+ : new Fill({2406+ color: neighbor2407+ ? `rgba(3, 5, 3, ${Math.min(0.52, 0.30 + cell.contextDistance * 0.06)})`2408+ : hexToRGBA(color, leaf ? 0.14 : 0.055),23072409 }),2308- padding: neighbor ? [2, 4, 2, 4] : [3, 5, 3, 5],2309- overflow: true,2410+ stroke: new Stroke({2411+ color: leaf || scope ? "#ffffff" : hexToRGBA(color, neighbor ? 0.44 : 0.82),2412+ width: leaf ? 2.5 : scope ? (bare ? 1.8 : 2.5) : neighbor ? 1 : 1.4,23102413 }),2311- zIndex: leaf ? 100 : feature.get("priority"),2414+ text: labelled && !bare2415+ ? new Text({2416+ text: cell.hash,2417+ font,2418+ fill: new Fill({2419+ color: leaf || scope ? "#ffffff" : neighbor ? hexToRGBA(color, 0.72) : color,2420+ }),2421+ stroke: new Stroke({ color: "rgba(0,0,0,0.96)", width: 4 }),2422+ backgroundFill: new Fill({2423+ color: neighbor ? "rgba(4,6,4,0.88)" : "rgba(9,12,8,0.76)",2424+ }),2425+ padding,2426+ overflow: true,2427+ })2428+ : undefined,2429+ zIndex: leaf || scope ? 100 : feature.get("priority"),23122430 });23132431 state.styleCache.set(key, style);23142432 return style;23152433 }23162434 2435+// A hash names the cell it sits in, so a label wider than its cell names the2436+// neighbours instead -- and at the depth where cells are smallest, that is2437+// every label at once. The cell keeps its outline and colour; only the word2438+// waits for a zoom that has room for it.2439+function labelFitsCell(cell, font, size, padding, resolution) {2440+ if (!resolution) return true;2441+ const width = measureLabel(cell.hash, font) + padding[1] + padding[3];2442+ const height = size + padding[0] + padding[2];2443+ return width <= (cell.extent[2] - cell.extent[0]) / resolution &&2444+ height <= (cell.extent[3] - cell.extent[1]) / resolution;2445+}2446+2447+const labelRuler = document.createElement("canvas").getContext("2d");2448+const labelWidths = new Map();2449+2450+function measureLabel(text, font) {2451+ const key = `${font}|${text}`;2452+ let width = labelWidths.get(key);2453+ if (width === undefined) {2454+ labelRuler.font = font;2455+ width = labelRuler.measureText(text).width;2456+ labelWidths.set(key, width);2457+ }2458+ return width;2459+}2460+2461+const zoneScrimFill = new Style({ fill: new Fill({ color: "rgba(3, 5, 3, 0.62)" }) });2462+2463+function zoneScrimStyle() {2464+ return state.zonesVisible ? zoneScrimFill : null;2465+}2466+23172467 function zoneStyle(feature) {23182468 if (!state.zonesVisible) return null;23192469 const zone = feature.get("zone");
@@ -2453,7 +2603,8 @@ function isPriorityPin(pin) {
24532603 }24542604 24552605 function pinIsHidden(pin) {2456- return pin.filteredHidden || pinIsZoneCulled(pin) || !onActiveShard(pin.location);2606+ return pin.filteredHidden || pinIsZoneCulled(pin) || pinIsGridCulled(pin) ||2607+ !onActiveShard(pin.location);24572608 }24582609 24592610 // A map split into layers offers one at a time. Anything belonging to another
@@ -2470,6 +2621,16 @@ function pinIsZoneCulled(pin) {
24702621 pin.location.title.toLocaleLowerCase().includes(state.search));24712622 }24722623 2624+// Descending into a cell narrows the question to what is inside it, the same2625+// way highlighting a zone does. What lies outside was still being drawn, so the2626+// answer stayed as crowded as it was before the cell was chosen.2627+function pinIsGridCulled(pin) {2628+ if (!state.gridEnabled || !state.gridPrefix || pinInGridCell(pin)) return false;2629+ if (pin === state.selectedPin) return false;2630+ return !(Boolean(state.search) &&2631+ pin.location.title.toLocaleLowerCase().includes(state.search));2632+}2633+24732634 function pinPriority(category, location) {24742635 const rarity = Math.max(0, 1_000_000 - Math.min(category.locations.length, 999) * 1000);24752636 return rarity + (stableRank(location.id) % 1000);