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 / features.js
features.js 6.9K
1import Feature from "ol/Feature.js";2import Point from "ol/geom/Point.js";34import { groupByCollection, isCollectionHidden, passesZoneFilters } from "./collections.js";5import { closeDetail } from "./detail.js";6import { elements } from "./dom.js";7import { pinInGridCell } from "./grid.js";8import { renderSearchResults } from "./search.js";9import { updateVisibleCount } from "./navigation.js";10import { state } from "./state.js";11import { prepareMarkerIcon } from "./styles.js";12import { stableRank } from "./util.js";13import { project } from "./areas.js";1415// buildFeatures stands the world's point features up: every point collection's16// locations become pin records in the features registry, each already wearing17// its OpenLayers feature. Shape features are areas.js's to render -- they18// arrive inline with their collections and never pass through here.19export function buildFeatures() {20 state.sources.pins.clear();21 state.sources.priority.clear();22 state.features = [];23 state.featureByID.clear();24 for (const category of state.world.collections) {25 if (category.kind !== "point") continue;26 prepareMarkerIcon(category);27 for (const location of category.locations) {28 const pin = {29 location,30 category,31 coordinate: project(location.lat, location.lng),32 filteredHidden: false,33 priority: pinPriority(category, location),34 feature: null,35 };36 pin.feature = new Feature({37 geometry: new Point(pin.coordinate),38 pin,39 priority: pin.priority,40 });41 state.features.push(pin);42 state.featureByID.set(location.id, pin);43 state.sources.pins.addFeature(pin.feature);44 }45 }46 applyPinFilters();47}4849export function applyPinFilters() {50 for (const pin of state.features) {51 const collectionHidden = isCollectionHidden(pin.category.id);52 const searchHidden = state.search &&53 !pin.location.title.toLocaleLowerCase().includes(state.search);54 pin.filteredHidden = Boolean(collectionHidden || searchHidden);55 }56 updateZonePinFocus();57 refreshPinRendering();58 if (state.selectedPin?.filteredHidden) closeDetail();59 // Anything else drawing the pins -- the globe -- filters the same moment60 // the chart does.61 document.dispatchEvent(new Event("atlas:filters"));62}6364// Highlights conjoin across collections and union within one: a pin stands65// only where every highlighted collection claims it. With the v2 wire's one66// implicit collection that is the plain union, so today's maps read exactly67// as they always have; the second collection arrives with the v3 wire.68export function updateZonePinFocus() {69 if (!state.highlightedZones.size) {70 for (const pin of state.features) pin.passesZoneFilters = false;71 return;72 }73 const groups = groupByCollection([...state.highlightedZones]74 .map((zoneID) => state.zoneRecords.get(zoneID))75 .filter(Boolean));76 for (const pin of state.features) {77 pin.passesZoneFilters = passesZoneFilters(groups, pin.coordinate);78 }79}8081// The one funnel for "what stands has changed": the canvas repaints, the82// footer recounts, and the dock relists, in that order and together. They used83// to be three calls each caller had to remember, and the callers that reached84// for the canvas alone -- highlighting ground, descending into a cell -- left85// the panel beside the map listing features the map had just put away.86export function refreshPinRendering() {87 state.eligibleLocations = state.features.filter((pin) => !pinIsHidden(pin)).length;88 refreshPrioritySource();89 state.layers.pins.changed();90 state.layers.zonePins.changed();91 state.layers.pinLabels.changed();92 state.layers.priority.changed();93 updateVisibleCount();94 renderSearchResults();95}9697export function refreshPrioritySource() {98 state.sources.priority.clear();99 for (const pin of state.features) {100 if (pinIsHidden(pin)) continue;101 const searched = Boolean(state.search) &&102 pin.location.title.toLocaleLowerCase().includes(state.search);103 if (pin === state.selectedPin || pin === state.hoveredPin || searched || pinInGridCell(pin)) {104 state.sources.priority.addFeature(pin.feature);105 }106 }107}108109export function setHoveredPin(pin) {110 if (state.hoveredPin === pin) return;111 state.hoveredPin = pin;112 if (!state.sources) return;113 refreshPrioritySource();114 state.layers.pins.changed();115 state.layers.priority.changed();116}117118// Z is held rather than switched: the reader wants every name for as long as119// they are looking for one, and then wants the map back. Holding it says both120// in one gesture, so there is no state left on by mistake and no zoom to reach121// first -- letting go returns the map to whatever it was drawing before.122export function setLabelsHeld(held) {123 if (state.labelsHeld === held) return;124 state.labelsHeld = held;125 elements.labelsHint.textContent = held ? "Z · labels shown" : "Z · hold for labels";126 state.layers.pinLabels.changed();127 // Quiet zone names answer the same key, so the title layers must hear it128 // the moment the pin labels do.129 state.layers.zoneTitles.changed();130 state.layers.zoneTitleDetail.changed();131 // Anything else writing names beside its pins -- the globe -- holds and132 // releases them the same moment the chart does.133 document.dispatchEvent(new Event("atlas:labels"));134}135136export function isPriorityPin(pin) {137 return pin === state.selectedPin || pin === state.hoveredPin ||138 pinInGridCell(pin) ||139 (Boolean(state.search) && pin.location.title.toLocaleLowerCase().includes(state.search));140}141142export function pinIsHidden(pin) {143 return pin.filteredHidden || pinIsZoneCulled(pin) || pinIsGridCulled(pin) ||144 !onActiveShard(pin.location);145}146147// A map split into layers offers one at a time. Anything belonging to another148// layer is elsewhere in the world, not merely filtered out.149export function onActiveShard(item) {150 const shard = state.lens?.shard;151 return !shard || !item.shard || item.shard === shard;152}153154export function pinIsZoneCulled(pin) {155 if (!state.highlightedZones.size || pin.passesZoneFilters) return false;156 if (pin === state.selectedPin) return false;157 return !(Boolean(state.search) &&158 pin.location.title.toLocaleLowerCase().includes(state.search));159}160161// Descending into a cell narrows the question to what is inside it, the same162// way highlighting a zone does. What lies outside was still being drawn, so the163// answer stayed as crowded as it was before the cell was chosen.164export function pinIsGridCulled(pin) {165 if (!state.gridEnabled || !state.gridCell || pinInGridCell(pin)) return false;166 if (pin === state.selectedPin) return false;167 return !(Boolean(state.search) &&168 pin.location.title.toLocaleLowerCase().includes(state.search));169}170171export function pinPriority(category, location) {172 const rarity = Math.max(0, 1_000_000 - Math.min(category.locations.length, 999) * 1000);173 return rarity + (stableRank(location.id) % 1000);174}175176export function atMaximumNativeZoom() {177 const zoom = state.engine?.getView().getZoom() || 0;178 return zoom >= (state.lens?.maxZoom || 0) - 0.05;179}