clone

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

main
atlas / render / chart / styles.ts
styles.ts 26.4K
1// How the chart draws what it draws.2//3// One class holds every mark the chart makes, because the marks answer each4// other: a pin's label is the pin's size away from it, a scrim is the exact5// complement of the shapes it dims, and a promoted pin is the same pin drawn6// again in a layer above. Splitting them across files would split decisions7// that have to agree.8//9// Two things here are NOT decided here.10//11// The grid: its colours, weights, alphas and chip are `gridCellVisual`'s,12// from the analysis lane, and this module adapts those tokens into `ol/style`13// without adding one of its own — which is what lets the chart and the globe14// read as one instrument (issue #5 §5.4: renderers adapt tokens; no renderer15// owns a cell rule).16//17// The marker: what a pin looks like is `markers.ts`'s, because the sphere18// draws the same mark and the two panes must not compose it twice. A marker19// is a symbol carrying its collection's colour with an outset cut to its own20// shape — never a coloured disc with a symbol punched out of it — and while21// its raster is still being composed the pin wears its collection's initials22// in the same colour, rimmed the same way.23//24// ONE KIND OF COLLECTION HAS NO MARKER. A point collection curated25// `atlas.render.as: text` is written on the projected map rather than pinned26// to it: its name is drawn on the place and nothing else is. This is a27// deliberate departure from the reference, which by its last revision drew a28// marker for every point collection and kept `atlas.render.as` only as the29// default its label policy fell back to — so a text collection wore its30// symbol AND its name, the "A — Wellspring of Wisdom" the reader rejected.31// This application's own addition, user-decided, like ⌘R. The sphere is untouched: this is a32// rule about the projected form.3334import Fill from "ol/style/Fill.js";35import Icon from "ol/style/Icon.js";36import Stroke from "ol/style/Stroke.js";37import Style from "ol/style/Style.js";38import Text from "ol/style/Text.js";39import Point from "ol/geom/Point.js";40import type { FeatureLike } from "ol/Feature.js";41import { KEY_LABEL_POLICY, KEY_STROKE_WIDTH_PX } from "@atlas/analysis/semconv/keys";42import { gridTheme, paletteColor } from "@atlas/analysis";43import type { CellVisual } from "@atlas/analysis";44import type { Collection } from "../data/payload.ts";45import { labelPolicy, renderAs } from "../data/payload.ts";46import type { PointRecord, ShapeRecord } from "../world/model.ts";47import type { Visibility } from "../world/visibility.ts";48import type { Scene } from "../scene/read.ts";49import {50 iconIsPicture, initialsOf, legibleIconColor, markerKey, markerRaster, markerRasterReady,51 outsetColor,52} from "./markers.ts";53import type { MarkerFace } from "./markers.ts";5455/** The mark's size on screen: the chosen pin is the larger of the two. */56const MARK_SIZE = 31;57const MARK_SELECTED_SIZE = 36;5859/** The font the initials fall back to while a raster is still on its way. */60const INITIALS_FONT = (selected: boolean): string =>61 `900 ${selected ? 15 : 13}px Inter, ui-sans-serif, system-ui, sans-serif`;6263const LABEL_FONT = "600 11px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif";64const TITLE_FONT = "600 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif";6566/**67 * The face a text collection's own name is drawn in, when the name is the68 * whole of the mark.69 *70 * This is the reference's `.text-symbol` — 11px, weight 900, the collection's71 * own colour — brought over from the key, where it was the swatch a text72 * collection wore, onto the map, where the same collection now has no other73 * form. The condensed family is that rule's, with the chart's own stack behind74 * it; the letter-spacing it also asked for has no expression in a canvas font75 * and is left off rather than approximated.76 *77 * It grows a step when the reader is pointing at the feature or holding its78 * card open, which is the marker's own 13→15 convention said at this size.79 */80const TEXT_MARK_FONT = (attended: boolean): string =>81 `900 ${attended ? 13 : 11}px "Arial Narrow", "Roboto Condensed", ` +82 "ui-sans-serif, system-ui, sans-serif";8384/** Everything a style needs to know that is not on the feature itself. */85export interface StyleContext {86 visibility: Visibility;87 scene: Scene;88 labelsHeld: boolean;89 hovered: string | null;90 outset: string;91 iconURL(asset: string): string;92 /**93 * Ask the pane to draw its pins again.94 *95 * A style function is evaluated once per layer revision, not once per96 * frame, so a marker raster that finishes composing after the first frame97 * is not drawn until something asks the pin layers for their styles again.98 * The host owns that poke because the host owns the layers; a host that99 * hands none down draws the initials until the next filter, hover or move.100 */101 repaint?: () => void;102}103104/** The colour a collection wears everywhere it is drawn. */105export function collectionColor(collection: Collection, ordinal: number): string {106 return collection.color || collection.iconColor || paletteColor(ordinal);107}108109/**110 * The colour one feature wears wherever it is drawn.111 *112 * The application's own hash (internal/app/legend.go, colorFor), spelled in113 * this lane so a zone on the map cannot disagree with its row in the index,114 * the filter and the detail card the server rendered beside it. The115 * arithmetic is 32-bit on purpose -- Knuth's multiplicative hash over the116 * feature id -- and an id that is not a number wears the wheel's first117 * colour, exactly as it does there.118 */119export function featureColor(id: string): string {120 const value = Number(id);121 if (!Number.isFinite(value) || !Number.isInteger(value)) return paletteColor(0);122 const magnitude = Math.abs(value) % 4294967296;123 return paletteColor(Math.imul(magnitude, 2654435761) >>> 0);124}125126/**127 * The producer's word on a point collection's names, before the reader's.128 *129 * `payload.labelPolicy` answers for a shape collection, where saying nothing130 * means speaking; a point collection's silence means the opposite, so the131 * default is read here rather than there. What is curated is taken as132 * curated — only `always` speaks — and a collection that says nothing speaks133 * exactly when it was curated to draw as text.134 */135export function curatedPointPolicy(collection: Collection): string {136 return collection.attrs?.[KEY_LABEL_POLICY] ||137 (renderAs(collection) === "text" ? "always" : "quiet");138}139140/**141 * The marks, and the state they read.142 *143 * The context is mutated in place by the chart rather than rebuilt, because144 * every style function closes over it: a filter that lands has to be visible145 * to marks already handed to OpenLayers.146 */147export class Styles {148 private readonly faces = new Map<number, MarkerFace>();149 private readonly marks = new Map<string, Style>();150 private readonly colors = new Map<number, string>();151 /** The rasters this world has already asked to be told about. */152 private readonly waiting = new Set<string>();153154 readonly context: StyleContext;155156 constructor(context: StyleContext) {157 this.context = context;158 }159160 /**161 * Remember the palette ordinal of every collection, in payload order, and162 * start composing the marks.163 *164 * The rasters are asked for here rather than at the first paint because165 * this runs while the map is still being built: a symbol that is on its way166 * before there is a frame to draw it in is usually there for the first one.167 */168 learn(collections: readonly Collection[]): void {169 this.faces.clear();170 this.marks.clear();171 this.colors.clear();172 this.waiting.clear();173 collections.forEach((collection, ordinal) => {174 this.colors.set(collection.id, collectionColor(collection, ordinal));175 });176 for (const collection of collections) {177 if (collection.kind === "point") this.face(collection);178 }179 }180181 color(collection: Collection): string {182 return this.colors.get(collection.id) ?? paletteColor(collection.id);183 }184185 /**186 * What a collection's mark is made of, and the ask that composes it.187 *188 * A style function runs inside a render, and a render that throws takes the189 * map down with it: there is a moment during a navigation when the world190 * the styles were built for is not the world the base names any more, so a191 * collection whose asset resolves to nothing is a collection with no192 * artwork, which draws its initials rather than throwing.193 */194 private face(collection: Collection): MarkerFace {195 const held = this.faces.get(collection.id);196 if (held) return held;197 const asset = collection.iconAsset ?? "";198 const url = asset ? this.context.iconURL(asset) : "";199 const face: MarkerFace = {200 asset: url ? asset : "",201 url,202 picture: iconIsPicture(collection),203 color: legibleIconColor(this.color(collection), this.context.outset),204 outset: outsetColor(this.context.outset),205 };206 this.faces.set(collection.id, face);207 // Once per raster, not once per collection: a world whose forty208 // collections share five symbols asks five times and is woken five times,209 // rather than forty repaints of the same five arrivals.210 const key = markerKey(face);211 if (face.asset && !this.waiting.has(key)) {212 this.waiting.add(key);213 void markerRasterReady(face).then((raster) => { if (raster) this.wake(); });214 }215 return face;216 }217218 /**219 * A raster arrived: every mark is dropped, not that collection's.220 *221 * A raster is keyed by asset, colour and rim, so it stands in for every222 * collection drawn that way — Shrine and Daedric Shrine, House and House223 * (Ownable) — and only one of them asked for it. The others cached their224 * initials while it was loading and have nothing to tell them the symbol225 * has arrived.226 */227 private wake(): void {228 this.marks.clear();229 this.context.repaint?.();230 }231232 /**233 * Whether a feature IS its name on the projected map.234 *235 * `atlas.render.as: text` is curation saying that these places are written236 * rather than pinned — a province is a word across a region, not a dot237 * somewhere inside it — so a text collection draws its name and nothing238 * else: no symbol, no initials, no "A — Wellspring of Wisdom" with the mark239 * still standing beside the word it duplicates.240 *241 * TWO THINGS PUT THE MARKER BACK, and both are the same rule: nothing may242 * be made to vanish off a map while its box is still ticked and its place243 * still counted.244 *245 * The reader's own silencing is the first. Quieting a collection quiets its246 * names, and for a collection whose name is its only form that would erase247 * the feature outright — so a silenced text collection draws the ordinary248 * marker instead. The override becomes a choice between the two forms a249 * point collection can take rather than between a form and nothing, which250 * is what the reference's own key called that toggle ("a point collection's251 * override is which shape it takes at all — markers, or the floating names252 * text categories used to be set apart for").253 *254 * A feature with no name is the second, and needs no argument: there is no255 * text to draw it as.256 */257 private drawsAsText(point: PointRecord): boolean {258 return Boolean(point.title) && renderAs(point.collection) === "text" &&259 this.context.scene.overrides.get(String(point.collection.id)) !== "quiet";260 }261262 /**263 * The pin itself: the collection's symbol, in the collection's colour,264 * wearing the world's outset.265 *266 * There is no disc. The colour is carried by the symbol, and what makes it267 * legible over the art beneath is a halo cut to the symbol's own shape —268 * composed into the raster, not drawn here. Until that raster exists the269 * pin wears the collection's initials in the same colour and the same rim,270 * which is also what a collection with no artwork wears for good.271 *272 * `selected` is the pin whose card is open and nothing else: a search273 * promotes every pin it matched so the declutterer cannot drop one, and274 * growing all of those would say that a hundred places were chosen.275 *276 * A collection curated to draw as text has no mark here at all: its name is277 * its mark, and `pinLabel` draws it (see `drawsAsText`).278 */279 pin(point: PointRecord, promoted: boolean): Style[] {280 if (this.drawsAsText(point)) return [];281 const selected = promoted && point.id === this.context.scene.selected;282 const face = this.face(point.collection);283 // One mark per collection and state, shared by every pin wearing it: two284 // thousand pins of twenty collections are forty styles. The zIndex is the285 // priority of whichever pin asked first, which is the collection's own286 // rarity give or take the tie-break — so the crowd is still drawn rarest287 // last, and two pins of one collection are drawn in the order they stand288 // in the source rather than by their hash.289 const key = `${point.collection.id}:${selected ? 1 : 0}`;290 const held = this.marks.get(key);291 if (held) return [held];292 const zIndex = selected ? 20_000_000 : point.priority;293 const raster = markerRaster(face);294 const size = selected ? MARK_SELECTED_SIZE : MARK_SIZE;295 const mark = raster296 ? new Style({ image: new Icon({ src: raster, width: size, height: size }), zIndex })297 : new Style({298 text: new Text({299 text: initialsOf(point.collection.title),300 font: INITIALS_FONT(selected),301 fill: new Fill({ color: face.color }),302 stroke: new Stroke({ color: face.outset, width: selected ? 4 : 3 }),303 }),304 zIndex,305 });306 this.marks.set(key, mark);307 return [mark];308 }309310 /**311 * The name beside a pin.312 *313 * The same ladder `areaTitle` climbs, with a point collection's own default314 * at the bottom: the reader's override wins, then the producer's curated315 * `atlas.label.policy`, then the kind's default — a pin collection waits to316 * be asked, and one curated as text speaks unasked, because floating names317 * are labels a producer pinned on rather than a different way of drawing.318 * A text collection's name is not beside its pin: it IS the pin, drawn319 * centred on the place with no marker under it (`drawsAsText`), which is320 * the one thing `atlas.render.as` decides about how the chart draws.321 *322 * Two things speak over the policy, and only two: the pin the reader is323 * pointing at and the one whose card is open. **Promotion is not one of324 * them.** A search promotes every name it matches so none of them is325 * decluttered away, and labelling all of those would bury a searched map326 * under its own matches — so `promoted` decides where a label is drawn and327 * whether it may be decluttered, never whether there is one.328 *329 * Holding Z reveals what is merely optional and never revives what the330 * reader silenced by hand, which is the one asymmetry in the ladder and the331 * reason the override is consulted on its own as well as through the policy.332 */333 pinLabel(point: PointRecord, promoted: boolean): Style | null {334 if (!point.title) return null;335 const override = this.context.scene.overrides.get(String(point.collection.id));336 const policy = override ?? curatedPointPolicy(point.collection);337 const attended = point.id === this.context.scene.selected ||338 point.id === this.context.hovered;339 const revealed = this.context.labelsHeld && override !== "quiet";340 if (policy !== "always" && !attended && !revealed) return null;341 if (this.drawsAsText(point)) return this.textMark(point, promoted, attended);342 return new Style({343 text: new Text({344 text: point.title,345 font: LABEL_FONT,346 offsetY: 16,347 fill: new Fill({ color: "#f2f5f9" }),348 stroke: new Stroke({ color: "rgba(6, 9, 14, 0.86)", width: 3 }),349 overflow: true,350 // Declutter is priority-ordered: a name that has to give way gives351 // way to a rarer collection's, never to whichever was built first.352 // The promoted layer is not decluttered at all, which is the353 // selected/searched bypass.354 declutterMode: promoted ? "none" : "declutter",355 }),356 zIndex: promoted ? 9_100_000 : point.priority,357 });358 }359360 /**361 * A text collection's whole visible form: its name, standing on the place.362 *363 * Centred, because there is no mark for it to stand off from — the label's364 * 16-pixel drop is the room a marker takes, and kept here it would leave365 * every province written below the point it names. The colour and the rim366 * are the marker's own, taken through the same legibility ladder, so a367 * place written on the map is recognisably its collection's in exactly the368 * way a place pinned on it is.369 *370 * The stacking is the mark's rather than the label's: the feature whose371 * card is open is drawn over everything, because this text is what a372 * chosen marker would have been.373 */374 private textMark(point: PointRecord, promoted: boolean, attended: boolean): Style {375 const face = this.face(point.collection);376 const selected = point.id === this.context.scene.selected;377 return new Style({378 text: new Text({379 text: point.title,380 font: TEXT_MARK_FONT(attended),381 fill: new Fill({ color: face.color }),382 stroke: new Stroke({ color: face.outset, width: attended ? 4 : 3 }),383 overflow: true,384 // The same declutter the label declares, for the same reason: a name385 // that has to give way gives way to a rarer collection's.386 declutterMode: promoted ? "none" : "declutter",387 }),388 zIndex: selected ? 20_000_000 : promoted ? 9_100_000 : point.priority,389 });390 }391392 /** A path drawn at its declared ground width, never thinner than a hair. */393 path(shape: ShapeRecord, resolution: number, highlighted: boolean): Style[] {394 const color = featureColor(shape.id);395 const ground = Number(shape.collection.attrs?.[KEY_STROKE_WIDTH_PX] ?? 0);396 const width = Math.max(1.4, ground > 0 ? ground / resolution : 2);397 return [398 new Style({399 stroke: new Stroke({ color: "rgba(6, 9, 14, 0.5)", width: width + 2 }),400 zIndex: 9,401 }),402 new Style({403 stroke: new Stroke({ color, width, lineCap: "round", lineJoin: "round" }),404 zIndex: highlighted ? 11 : 10,405 }),406 ];407 }408409 /** An area: its own accent, faintly filled, brighter when it is highlighted.410 * The accent is the feature's, not its collection's: a zoning collection is411 * one row, but B-1 and O-1 are different grounds and wear different colours412 * -- the same ones their index rows wear. */413 area(shape: ShapeRecord, highlighted: boolean): Style {414 const color = featureColor(shape.id);415 return new Style({416 fill: new Fill({ color: withAlpha(color, highlighted ? 0.28 : 0.1) }),417 stroke: new Stroke({ color, width: highlighted ? 2.4 : 1.4 }),418 zIndex: highlighted ? 11 : 10,419 });420 }421422 /**423 * The scrim: everything the highlights did not claim, dimmed.424 *425 * It is drawn UNDER the shapes and over the raster, because the dimming is426 * something the map is seen through, not something painted on top of the427 * ground that was asked for.428 */429 scrim(): Style {430 return new Style({ fill: new Fill({ color: "rgba(4, 7, 14, 0.55)" }), zIndex: 6 });431 }432433 /**434 * An area's name on the map.435 *436 * `atlas.label.policy` is the curation — `always`, or `quiet`, which means437 * only on highlight, selection, or an explicit reveal — and the reader's own438 * override arrives in the scene and wins. Holding Z reveals what is439 * optional and never revives what someone chose to quiet, which is the one440 * asymmetry in the ladder and the reason the override is consulted first.441 */442 areaTitle(shape: ShapeRecord, promoted: boolean): Style | null {443 if (!shape.title) return null;444 const override = this.context.scene.overrides.get(String(shape.collection.id));445 const policy = override ?? labelPolicy(shape.collection);446 const speaking = shape.kind === "area" && policy === "always";447 const revealed = promoted || (this.context.labelsHeld && override !== "quiet");448 if (!speaking && !revealed) return null;449 return new Style({450 text: new Text({451 text: shape.title,452 font: TITLE_FONT,453 fill: new Fill({ color: "#e8eef6" }),454 stroke: new Stroke({ color: "rgba(6, 9, 14, 0.8)", width: 3 }),455 overflow: true,456 declutterMode: promoted ? "none" : "declutter",457 }),458 zIndex: promoted ? 9_100_000 : 20,459 });460 }461462 /**463 * One grid cell, adapted from the analysis lane's tokens and nothing else.464 *465 * `zIndex` is the caller's — the plan's own order, which decides how the466 * dimmed neighbourhood stacks against itself and how the chosen path stands467 * over it — and the chip rides one above whatever its cell was given, so a468 * boundary never crosses the address it belongs to.469 */470 grid(visual: CellVisual, zIndex: number, neighbor: boolean): Style[] {471 const marks: Style[] = [472 new Style({473 stroke: new Stroke({474 color: withAlpha(visual.line.color, visual.line.opacity),475 width: visual.line.widthPx,476 }),477 ...(visual.fill478 ? { fill: new Fill({ color: withAlpha(visual.fill.color, visual.fill.opacity) }) }479 : {}),480 zIndex,481 }),482 ];483 if (visual.label) {484 const { prefix, final, color, textAlpha, chip, sizePx } = visual.label;485 // TWO CUTS OF ONE PITCH. The prefix is context and the final character486 // is the address's principal digit, so the prefix takes a lighter cut of487 // the same fixed pitch at the same size: weight alone carries the488 // faintness, because mixing sizes on one line moves the baselines and489 // the address wobbles. Written as one 600 run — which is what this said490 // before — a hash reads as one flat word and the digit that actually491 // changes as you descend is lost in it.492 const text = prefix493 ? [prefix, gridLabelFont(500, sizePx), final, gridLabelFont(900, sizePx)]494 : final;495 marks.push(new Style({496 text: new Text({497 text,498 font: gridLabelFont(900, sizePx),499 textAlign: "right",500 textBaseline: "bottom",501 offsetX: -gridTheme.labelInsetPx,502 offsetY: -gridTheme.labelInsetPx,503 fill: new Fill({ color: withAlpha(color, textAlpha) }),504 backgroundFill: new Fill({ color: chip }),505 padding: chipPadding(neighbor),506 declutterMode: "none",507 }),508 // The chip belongs in the cell's bottom-right corner, which is the509 // bounding-box convention the label placement below relies on.510 geometry: cornerOf,511 zIndex: zIndex + 1,512 }));513 }514 return marks;515 }516}517518/**519 * One chunk of a cell's address, in the pitch the whole grid is measured at.520 *521 * Monospace, as the field these are typed into already is: every hash at a522 * level is the same length, so in a fixed pitch a level keeps or drops its523 * labels as one.524 */525export function gridLabelFont(weight: number, sizePx: number): string {526 return `${weight} ${sizePx}px ${gridTheme.labelFont}`;527}528529/** The room a chip gives its address. A neighbour's is the tighter. */530function chipPadding(neighbor: boolean): [number, number, number, number] {531 return neighbor ? [2, 4, 2, 4] : [3, 5, 3, 5];532}533534/**535 * One shared canvas for measuring an address: measuring is not drawing.536 *537 * Widths are cached by font and text, because the fit gate below is asked once538 * per cell per render — ninety-odd cells at sixty frames — and a hash is one539 * of a handful of strings at any level.540 */541let ruler: CanvasRenderingContext2D | null | undefined;542const rulings = new Map<string, number>();543544export function measureLabel(text: string, font: string): number {545 const key = `${font}|${text}`;546 const held = rulings.get(key);547 if (held !== undefined) return held;548 ruler ??= document.createElement("canvas").getContext("2d");549 let width = text.length * 7;550 if (ruler) {551 ruler.font = font;552 width = ruler.measureText(text).width;553 }554 rulings.set(key, width);555 return width;556}557558/**559 * Whether a cell can carry its own address at this resolution.560 *561 * A hash names the cell it sits in, so a label wider than its cell names the562 * neighbours instead — and at the depth where cells are smallest, that is563 * every label at once. The cell keeps its outline and colour; only the word564 * waits for a zoom that has room for it. (A *child* waits altogether: the565 * subdivision appears at the size it can be read, which is the analysis lane's566 * rule and not this one's.)567 *568 * The measurement is the chip as it will actually be drawn — the address in569 * its own fixed pitch, plus the padding around it — rather than a round number570 * of pixels standing in for one. A flat threshold is wrong in both directions:571 * it drops a two-character hash that had room and keeps a six-character one572 * that never did.573 */574export function labelFitsCell(575 hash: string,576 role: string,577 extent: readonly [number, number, number, number],578 resolution: number,579): boolean {580 if (!resolution) return true;581 const neighbor = role === "neighbor";582 const size = neighbor ? gridTheme.neighborLabelSizePx : gridTheme.labelSizePx;583 const [top, right, bottom, left] = chipPadding(neighbor);584 const width = measureLabel(hash, gridLabelFont(900, size)) + left + right;585 const height = size + top + bottom;586 return width <= (extent[2] - extent[0]) / resolution &&587 height <= (extent[3] - extent[1]) / resolution;588}589590/** A CSS colour at an alpha, whether it arrived as hex or as rgb(). */591export function withAlpha(color: string, alpha: number): string {592 if (alpha >= 1) return color;593 if (color.startsWith("#") && (color.length === 7 || color.length === 4)) {594 const full = color.length === 4595 ? `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}`596 : color;597 const r = Number.parseInt(full.slice(1, 3), 16);598 const g = Number.parseInt(full.slice(3, 5), 16);599 const b = Number.parseInt(full.slice(5, 7), 16);600 return `rgba(${r}, ${g}, ${b}, ${alpha})`;601 }602 if (color.startsWith("rgba(")) return color;603 if (color.startsWith("rgb(")) return `rgba(${color.slice(4, -1)}, ${alpha})`;604 return color;605}606607/**608 * The bottom-right corner of a cell, where its chip sits.609 *610 * The corner is taken from the drawn geometry's own extent, so a cell the611 * antimeridian cut into two pieces carries a chip on each piece rather than612 * one chip halfway between them.613 */614function cornerOf(feature: FeatureLike): Point | undefined {615 const geometry = feature.getGeometry();616 if (!geometry) return undefined;617 const [, minimumY = 0, maximumX = 0] = geometry.getExtent();618 return new Point([maximumX, minimumY]);619}