1// One answer to what the reader is looking at.2//3// Three surfaces used to work this out for themselves — the canvas drew what4// survived every filter, the footer counted what the legend allowed, the dock5// listed what the search left — and one highlight could cull sixty features6// off the map while the panel beside it went on offering all sixty. The7// application counts its own half in Go now, and this is the seam's half: one8// object, computed once per scene, that the chart, the globe, the picks and9// the diagnostics all read. A filter that lands on one of them lands on all.10//11// THE RULES, in the order they apply to a point:12//13// SHARD. A world split into layers offers one at a time. Anything14// belonging to another layer is *elsewhere in the world*, not15// merely filtered out, so it is not drawn, not counted, and not16// picked.17// FILTERED. Its collection is hidden, or the search does not name it.18// HIGHLIGHTS. Highlighted shapes conjoin across collections and unite19// within one: a pin stands only where every collection holding20// a highlight claims it.21// CELL. A held grid cell narrows the question to what is inside it,22// exactly the way a highlight does.23//24// The last two have the same bypass, and it is deliberate: the selected25// feature never disappears out from under the card that is open about it, and26// a searched-for name is what the reader asked to see. Highlighting is a way27// of reading the map, not a way of losing your place.2829import { KEY_STROKE_WIDTH_PX } from "@atlas/analysis/semconv/keys";30import type { Scene } from "../scene/read.ts";31import type { Coordinate, Line, PointRecord, ShapeRecord, WorldModel } from "./model.ts";3233/** Whether a coordinate lies in a cell of whatever system is in play. */34export type CellTest = ((at: Coordinate) => boolean) | null;3536/** What one point's standing is, in the terms the rules are written in. */37export interface PointStanding {38 readonly filteredHidden: boolean;39 readonly passesHighlights: boolean;40 readonly inCell: boolean;41 readonly searched: boolean;42 readonly hidden: boolean;43 /** Drawn above everything: selected, hovered, searched, or inside the cell. */44 readonly promoted: boolean;45}4647/** The standing of everything, for one scene. */48export class Visibility {49 readonly points: readonly PointStanding[];50 readonly shapesShown: readonly ShapeRecord[];51 /**52 * Every shape on the layer this lens draws, whether or not a filter is53 * letting it through. It is the size of the ground the reader is standing54 * on, which is a different question from what is drawn on it: a split world55 * keeps its other layers' ground elsewhere in the world rather than hidden.56 */57 readonly shapesHere: readonly ShapeRecord[];58 readonly eligible: number;59 readonly drawnPoints: number;60 readonly drawn: number;61 readonly listable: number;62 readonly highlightedShapes: readonly ShapeRecord[];63 readonly focusedPins: number;64 readonly priorityPins: number;6566 readonly model: WorldModel;67 readonly scene: Scene;68 readonly shard: number;6970 constructor(71 model: WorldModel,72 scene: Scene,73 shard: number,74 inCell: CellTest,75 hovered: string | null = null,76 ) {77 this.model = model;78 this.scene = scene;79 this.shard = shard;80 const search = scene.search.toLocaleLowerCase();81 const highlighted = model.shapes.filter((shape) => scene.highlighted.has(shape.id));82 this.highlightedShapes = highlighted;83 const groups = new Map<string | number, ShapeRecord[]>();84 for (const shape of highlighted) {85 const held = groups.get(shape.collection.id);86 if (held) held.push(shape);87 else groups.set(shape.collection.id, [shape]);88 }8990 const standings: PointStanding[] = [];91 let eligible = 0;92 let focused = 0;93 let priority = 0;94 for (const point of model.points) {95 const onShard = onActiveShard(point.shard, shard);96 const searched = Boolean(search) && point.title.toLocaleLowerCase().includes(search);97 const filteredHidden = scene.hidden.has(String(point.collection.id)) ||98 (Boolean(search) && !searched);99 const selected = point.id === scene.selected;100 const passesHighlights = groups.size === 0101 ? false102 : passes(groups, point.coordinate);103 const cell = inCell ? inCell(point.coordinate) : true;104 const spared = selected || searched;105 const culled = (groups.size > 0 && !passesHighlights && !spared) ||106 (Boolean(inCell) && !cell && !spared);107 const hidden = !onShard || filteredHidden || culled;108 const promoted = !hidden &&109 (selected || point.id === hovered || searched || (Boolean(inCell) && cell));110 standings.push({ filteredHidden, passesHighlights, inCell: cell, searched, hidden, promoted });111 if (!hidden) eligible++;112 if (!filteredHidden && passesHighlights) focused++;113 if (!filteredHidden && inCell && cell) priority++;114 }115 this.points = standings;116 this.eligible = eligible;117 this.drawnPoints = eligible;118 this.focusedPins = focused;119 this.priorityPins = scene.gridCell ? priority : 0;120121 // A shape answers the legend and the shard and nothing else. Highlighting122 // narrows which points stand rather than which ground is drawn, and123 // searching for a place has never taken the ground out from under it.124 this.shapesHere = model.shapes.filter((shape) => onActiveShard(shape.shard, shard));125 this.shapesShown = this.shapesHere.filter((shape) =>126 !scene.hidden.has(String(shape.collection.id)));127 this.drawn = eligible + this.shapesShown.length;128 this.listable = eligible + this.shapesShown.filter((shape) =>129 shape.title && (!search || shape.title.toLocaleLowerCase().includes(search))).length;130 }131132 /** The standing of one point, by its place in the model's registry. */133 at(index: number): PointStanding {134 return this.points[index] ?? {135 filteredHidden: true, passesHighlights: false, inCell: false,136 searched: false, hidden: true, promoted: false,137 };138 }139140 /** Every point the chart is drawing, in the model's own order. */141 *standing(): Generator<PointRecord> {142 for (let i = 0; i < this.model.points.length; i++) {143 const point = this.model.points[i];144 if (point && !this.at(i).hidden) yield point;145 }146 }147148 /**149 * The same set, asked before the held cell narrowed it.150 *151 * A grid is a picture of where things are, so the number on a neighbouring152 * cell has to be what that cell holds — not zero, which is all the held153 * cell has left standing anywhere else. Every other filter still applies:154 * a hidden collection holds nothing anywhere.155 */156 *withoutCell(): Generator<PointRecord> {157 for (let i = 0; i < this.model.points.length; i++) {158 const point = this.model.points[i];159 const standing = this.at(i);160 // Only the filters that are about the feature itself. A cell narrows161 // where the reader is looking, and a grid that answered its own162 // narrowing would put a zero on every neighbour it drew.163 if (!point || standing.filteredHidden) continue;164 yield point;165 }166 }167}168169/** Whether a feature on this shard belongs to the layer the lens draws. */170export function onActiveShard(featureShard: number, lensShard: number): boolean {171 return !lensShard || !featureShard || featureShard === lensShard;172}173174function passes(groups: ReadonlyMap<string | number, ShapeRecord[]>, at: Coordinate): boolean {175 for (const shapes of groups.values()) {176 if (!shapes.some((shape) => shapeContains(shape, at))) return false;177 }178 return true;179}180181/**182 * Containment with a unit of grace.183 *184 * A pin dropped on a boundary was put there to mean the place, and exact185 * point-in-polygon arithmetic would flip it out over the width of the line it186 * stands on. A path claims what lies within its own declared ground width for187 * the same reason: a trail is a line and a weight.188 */189export function shapeContains(shape: ShapeRecord, at: Coordinate): boolean {190 const grace = shape.kind === "path"191 ? Math.max(1, Number(shape.collection.attrs?.[KEY_STROKE_WIDTH_PX] ?? 0) / 2)192 : 1;193 for (let i = 0; i < shape.lines.length; i++) {194 const outer = shape.lines[i];195 if (!outer) continue;196 if (shape.kind === "area") {197 if (inRing(outer, at)) {198 const holes = shape.holes[i] ?? [];199 if (!holes.some((hole) => inRing(hole, at))) return true;200 }201 }202 if (nearLine(outer, at, grace)) return true;203 }204 return false;205}206207/** The even-odd crossing test, which is also how the scrims cut their holes. */208function inRing(ring: Line, [x, y]: Coordinate): boolean {209 let inside = false;210 for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {211 const a = ring[i];212 const b = ring[j];213 if (!a || !b) continue;214 const crosses = a[1] > y !== b[1] > y &&215 x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0];216 if (crosses) inside = !inside;217 }218 return inside;219}220221function nearLine(line: Line, at: Coordinate, grace: number): boolean {222 const limit = grace * grace;223 for (let i = 1; i < line.length; i++) {224 const a = line[i - 1];225 const b = line[i];226 if (!a || !b) continue;227 if (segmentDistanceSquared(a, b, at) <= limit) return true;228 }229 return false;230}231232function segmentDistanceSquared(a: Coordinate, b: Coordinate, at: Coordinate): number {233 const dx = b[0] - a[0];234 const dy = b[1] - a[1];235 const length = dx * dx + dy * dy;236 const t = length === 0 ? 0 : Math.min(1, Math.max(0,237 ((at[0] - a[0]) * dx + (at[1] - a[1]) * dy) / length));238 const x = a[0] + t * dx - at[0];239 const y = a[1] + t * dy - at[1];240 return x * x + y * y;241}