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 { stableRank, type PointRecord, type 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 return paletteColor(stableRank(id));121}122123/**124 * The producer's word on a point collection's names, before the reader's.125 *126 * `payload.labelPolicy` answers for a shape collection, where saying nothing127 * means speaking; a point collection's silence means the opposite, so the128 * default is read here rather than there. What is curated is taken as129 * curated — only `always` speaks — and a collection that says nothing speaks130 * exactly when it was curated to draw as text.131 */132export function curatedPointPolicy(collection: Collection): string {133 return collection.attrs?.[KEY_LABEL_POLICY] ||134 (renderAs(collection) === "text" ? "always" : "quiet");135}136137/**138 * The marks, and the state they read.139 *140 * The context is mutated in place by the chart rather than rebuilt, because141 * every style function closes over it: a filter that lands has to be visible142 * to marks already handed to OpenLayers.143 */144export class Styles {145 private readonly faces = new Map<string | number, MarkerFace>();146 private readonly marks = new Map<string, Style>();147 private readonly colors = new Map<string | number, string>();148 /** The rasters this world has already asked to be told about. */149 private readonly waiting = new Set<string>();150151 readonly context: StyleContext;152153 constructor(context: StyleContext) {154 this.context = context;155 }156157 /**158 * Remember the palette ordinal of every collection, in payload order, and159 * start composing the marks.160 *161 * The rasters are asked for here rather than at the first paint because162 * this runs while the map is still being built: a symbol that is on its way163 * before there is a frame to draw it in is usually there for the first one.164 */165 learn(collections: readonly Collection[]): void {166 this.faces.clear();167 this.marks.clear();168 this.colors.clear();169 this.waiting.clear();170 collections.forEach((collection, ordinal) => {171 this.colors.set(collection.id, collectionColor(collection, ordinal));172 });173 for (const collection of collections) {174 if (collection.kind === "point") this.face(collection);175 }176 }177178 color(collection: Collection): string {179 return this.colors.get(collection.id) ?? paletteColor(stableRank(String(collection.id)));180 }181182 /**183 * What a collection's mark is made of, and the ask that composes it.184 *185 * A style function runs inside a render, and a render that throws takes the186 * map down with it: there is a moment during a navigation when the world187 * the styles were built for is not the world the base names any more, so a188 * collection whose asset resolves to nothing is a collection with no189 * artwork, which draws its initials rather than throwing.190 */191 private face(collection: Collection): MarkerFace {192 const held = this.faces.get(collection.id);193 if (held) return held;194 const asset = collection.iconAsset ?? "";195 const url = asset ? this.context.iconURL(asset) : "";196 const face: MarkerFace = {197 asset: url ? asset : "",198 url,199 picture: iconIsPicture(collection),200 color: legibleIconColor(this.color(collection), this.context.outset),201 outset: outsetColor(this.context.outset),202 };203 this.faces.set(collection.id, face);204 // Once per raster, not once per collection: a world whose forty205 // collections share five symbols asks five times and is woken five times,206 // rather than forty repaints of the same five arrivals.207 const key = markerKey(face);208 if (face.asset && !this.waiting.has(key)) {209 this.waiting.add(key);210 void markerRasterReady(face).then((raster) => { if (raster) this.wake(); });211 }212 return face;213 }214215 /**216 * A raster arrived: every mark is dropped, not that collection's.217 *218 * A raster is keyed by asset, colour and rim, so it stands in for every219 * collection drawn that way — Shrine and Daedric Shrine, House and House220 * (Ownable) — and only one of them asked for it. The others cached their221 * initials while it was loading and have nothing to tell them the symbol222 * has arrived.223 */224 private wake(): void {225 this.marks.clear();226 this.context.repaint?.();227 }228229 /**230 * Whether a feature IS its name on the projected map.231 *232 * `atlas.render.as: text` is curation saying that these places are written233 * rather than pinned — a province is a word across a region, not a dot234 * somewhere inside it — so a text collection draws its name and nothing235 * else: no symbol, no initials, no "A — Wellspring of Wisdom" with the mark236 * still standing beside the word it duplicates.237 *238 * TWO THINGS PUT THE MARKER BACK, and both are the same rule: nothing may239 * be made to vanish off a map while its box is still ticked and its place240 * still counted.241 *242 * The reader's own silencing is the first. Quieting a collection quiets its243 * names, and for a collection whose name is its only form that would erase244 * the feature outright — so a silenced text collection draws the ordinary245 * marker instead. The override becomes a choice between the two forms a246 * point collection can take rather than between a form and nothing, which247 * is what the reference's own key called that toggle ("a point collection's248 * override is which shape it takes at all — markers, or the floating names249 * text categories used to be set apart for").250 *251 * A feature with no name is the second, and needs no argument: there is no252 * text to draw it as.253 */254 private drawsAsText(point: PointRecord): boolean {255 return Boolean(point.title) && renderAs(point.collection) === "text" &&256 this.context.scene.overrides.get(String(point.collection.id)) !== "quiet";257 }258259 /**260 * The pin itself: the collection's symbol, in the collection's colour,261 * wearing the world's outset.262 *263 * There is no disc. The colour is carried by the symbol, and what makes it264 * legible over the art beneath is a halo cut to the symbol's own shape —265 * composed into the raster, not drawn here. Until that raster exists the266 * pin wears the collection's initials in the same colour and the same rim,267 * which is also what a collection with no artwork wears for good.268 *269 * `selected` is the pin whose card is open and nothing else: a search270 * promotes every pin it matched so the declutterer cannot drop one, and271 * growing all of those would say that a hundred places were chosen.272 *273 * A collection curated to draw as text has no mark here at all: its name is274 * its mark, and `pinLabel` draws it (see `drawsAsText`).275 */276 pin(point: PointRecord, promoted: boolean): Style[] {277 if (this.drawsAsText(point)) return [];278 const selected = promoted && point.id === this.context.scene.selected;279 const face = this.face(point.collection);280 // One mark per collection and state, shared by every pin wearing it: two281 // thousand pins of twenty collections are forty styles. The zIndex is the282 // priority of whichever pin asked first, which is the collection's own283 // rarity give or take the tie-break — so the crowd is still drawn rarest284 // last, and two pins of one collection are drawn in the order they stand285 // in the source rather than by their hash.286 const key = `${point.collection.id}:${selected ? 1 : 0}`;287 const held = this.marks.get(key);288 if (held) return [held];289 const zIndex = selected ? 20_000_000 : point.priority;290 const raster = markerRaster(face);291 const size = selected ? MARK_SELECTED_SIZE : MARK_SIZE;292 const mark = raster293 ? new Style({ image: new Icon({ src: raster, width: size, height: size }), zIndex })294 : new Style({295 text: new Text({296 text: initialsOf(point.collection.title),297 font: INITIALS_FONT(selected),298 fill: new Fill({ color: face.color }),299 stroke: new Stroke({ color: face.outset, width: selected ? 4 : 3 }),300 }),301 zIndex,302 });303 this.marks.set(key, mark);304 return [mark];305 }306307 /**308 * The name beside a pin.309 *310 * The same ladder `areaTitle` climbs, with a point collection's own default311 * at the bottom: the reader's override wins, then the producer's curated312 * `atlas.label.policy`, then the kind's default — a pin collection waits to313 * be asked, and one curated as text speaks unasked, because floating names314 * are labels a producer pinned on rather than a different way of drawing.315 * A text collection's name is not beside its pin: it IS the pin, drawn316 * centred on the place with no marker under it (`drawsAsText`), which is317 * the one thing `atlas.render.as` decides about how the chart draws.318 *319 * Two things speak over the policy, and only two: the pin the reader is320 * pointing at and the one whose card is open. **Promotion is not one of321 * them.** A search promotes every name it matches so none of them is322 * decluttered away, and labelling all of those would bury a searched map323 * under its own matches — so `promoted` decides where a label is drawn and324 * whether it may be decluttered, never whether there is one.325 *326 * Holding Z reveals what is merely optional and never revives what the327 * reader silenced by hand, which is the one asymmetry in the ladder and the328 * reason the override is consulted on its own as well as through the policy.329 */330 pinLabel(point: PointRecord, promoted: boolean): Style | null {331 if (!point.title) return null;332 const override = this.context.scene.overrides.get(String(point.collection.id));333 const policy = override ?? curatedPointPolicy(point.collection);334 const attended = point.id === this.context.scene.selected ||335 point.id === this.context.hovered;336 const revealed = this.context.labelsHeld && override !== "quiet";337 if (policy !== "always" && !attended && !revealed) return null;338 if (this.drawsAsText(point)) return this.textMark(point, promoted, attended);339 return new Style({340 text: new Text({341 text: point.title,342 font: LABEL_FONT,343 offsetY: 16,344 fill: new Fill({ color: "#f2f5f9" }),345 stroke: new Stroke({ color: "rgba(6, 9, 14, 0.86)", width: 3 }),346 overflow: true,347 // Declutter is priority-ordered: a name that has to give way gives348 // way to a rarer collection's, never to whichever was built first.349 // The promoted layer is not decluttered at all, which is the350 // selected/searched bypass.351 declutterMode: promoted ? "none" : "declutter",352 }),353 zIndex: promoted ? 9_100_000 : point.priority,354 });355 }356357 /**358 * A text collection's whole visible form: its name, standing on the place.359 *360 * Centred, because there is no mark for it to stand off from — the label's361 * 16-pixel drop is the room a marker takes, and kept here it would leave362 * every province written below the point it names. The colour and the rim363 * are the marker's own, taken through the same legibility ladder, so a364 * place written on the map is recognisably its collection's in exactly the365 * way a place pinned on it is.366 *367 * The stacking is the mark's rather than the label's: the feature whose368 * card is open is drawn over everything, because this text is what a369 * chosen marker would have been.370 */371 private textMark(point: PointRecord, promoted: boolean, attended: boolean): Style {372 const face = this.face(point.collection);373 const selected = point.id === this.context.scene.selected;374 return new Style({375 text: new Text({376 text: point.title,377 font: TEXT_MARK_FONT(attended),378 fill: new Fill({ color: face.color }),379 stroke: new Stroke({ color: face.outset, width: attended ? 4 : 3 }),380 overflow: true,381 // The same declutter the label declares, for the same reason: a name382 // that has to give way gives way to a rarer collection's.383 declutterMode: promoted ? "none" : "declutter",384 }),385 zIndex: selected ? 20_000_000 : promoted ? 9_100_000 : point.priority,386 });387 }388389 /** A path drawn at its declared ground width, never thinner than a hair. */390 path(shape: ShapeRecord, resolution: number, highlighted: boolean): Style[] {391 const color = featureColor(shape.id);392 const ground = Number(shape.collection.attrs?.[KEY_STROKE_WIDTH_PX] ?? 0);393 const width = Math.max(1.4, ground > 0 ? ground / resolution : 2);394 return [395 new Style({396 stroke: new Stroke({ color: "rgba(6, 9, 14, 0.5)", width: width + 2 }),397 zIndex: 9,398 }),399 new Style({400 stroke: new Stroke({ color, width, lineCap: "round", lineJoin: "round" }),401 zIndex: highlighted ? 11 : 10,402 }),403 ];404 }405406 /** An area: its own accent, faintly filled, brighter when it is highlighted.407 * The accent is the feature's, not its collection's: a zoning collection is408 * one row, but B-1 and O-1 are different grounds and wear different colours409 * -- the same ones their index rows wear. */410 area(shape: ShapeRecord, highlighted: boolean): Style {411 const color = featureColor(shape.id);412 return new Style({413 fill: new Fill({ color: withAlpha(color, highlighted ? 0.28 : 0.1) }),414 stroke: new Stroke({ color, width: highlighted ? 2.4 : 1.4 }),415 zIndex: highlighted ? 11 : 10,416 });417 }418419 /**420 * The scrim: everything the highlights did not claim, dimmed.421 *422 * It is drawn UNDER the shapes and over the raster, because the dimming is423 * something the map is seen through, not something painted on top of the424 * ground that was asked for.425 */426 scrim(): Style {427 return new Style({ fill: new Fill({ color: "rgba(4, 7, 14, 0.55)" }), zIndex: 6 });428 }429430 /**431 * An area's name on the map.432 *433 * `atlas.label.policy` is the curation — `always`, or `quiet`, which means434 * only on highlight, selection, or an explicit reveal — and the reader's own435 * override arrives in the scene and wins. Holding Z reveals what is436 * optional and never revives what someone chose to quiet, which is the one437 * asymmetry in the ladder and the reason the override is consulted first.438 */439 areaTitle(shape: ShapeRecord, promoted: boolean): Style | null {440 if (!shape.title) return null;441 const override = this.context.scene.overrides.get(String(shape.collection.id));442 const policy = override ?? labelPolicy(shape.collection);443 const speaking = shape.kind === "area" && policy === "always";444 const revealed = promoted || (this.context.labelsHeld && override !== "quiet");445 if (!speaking && !revealed) return null;446 return new Style({447 text: new Text({448 text: shape.title,449 font: TITLE_FONT,450 fill: new Fill({ color: "#e8eef6" }),451 stroke: new Stroke({ color: "rgba(6, 9, 14, 0.8)", width: 3 }),452 overflow: true,453 declutterMode: promoted ? "none" : "declutter",454 }),455 zIndex: promoted ? 9_100_000 : 20,456 });457 }458459 /**460 * One grid cell, adapted from the analysis lane's tokens and nothing else.461 *462 * `zIndex` is the caller's — the plan's own order, which decides how the463 * dimmed neighbourhood stacks against itself and how the chosen path stands464 * over it — and the chip rides one above whatever its cell was given, so a465 * boundary never crosses the address it belongs to.466 */467 grid(visual: CellVisual, zIndex: number, neighbor: boolean): Style[] {468 const marks: Style[] = [469 new Style({470 stroke: new Stroke({471 color: withAlpha(visual.line.color, visual.line.opacity),472 width: visual.line.widthPx,473 }),474 ...(visual.fill475 ? { fill: new Fill({ color: withAlpha(visual.fill.color, visual.fill.opacity) }) }476 : {}),477 zIndex,478 }),479 ];480 if (visual.label) {481 const { prefix, final, color, textAlpha, chip, sizePx } = visual.label;482 // TWO CUTS OF ONE PITCH. The prefix is context and the final character483 // is the address's principal digit, so the prefix takes a lighter cut of484 // the same fixed pitch at the same size: weight alone carries the485 // faintness, because mixing sizes on one line moves the baselines and486 // the address wobbles. Written as one 600 run — which is what this said487 // before — a hash reads as one flat word and the digit that actually488 // changes as you descend is lost in it.489 const text = prefix490 ? [prefix, gridLabelFont(500, sizePx), final, gridLabelFont(900, sizePx)]491 : final;492 marks.push(new Style({493 text: new Text({494 text,495 font: gridLabelFont(900, sizePx),496 textAlign: "right",497 textBaseline: "bottom",498 offsetX: -gridTheme.labelInsetPx,499 offsetY: -gridTheme.labelInsetPx,500 fill: new Fill({ color: withAlpha(color, textAlpha) }),501 backgroundFill: new Fill({ color: chip }),502 padding: chipPadding(neighbor),503 declutterMode: "none",504 }),505 // The chip belongs in the cell's bottom-right corner, which is the506 // bounding-box convention the label placement below relies on.507 geometry: cornerOf,508 zIndex: zIndex + 1,509 }));510 }511 return marks;512 }513}514515/**516 * One chunk of a cell's address, in the pitch the whole grid is measured at.517 *518 * Monospace, as the field these are typed into already is: every hash at a519 * level is the same length, so in a fixed pitch a level keeps or drops its520 * labels as one.521 */522export function gridLabelFont(weight: number, sizePx: number): string {523 return `${weight} ${sizePx}px ${gridTheme.labelFont}`;524}525526/** The room a chip gives its address. A neighbour's is the tighter. */527function chipPadding(neighbor: boolean): [number, number, number, number] {528 return neighbor ? [2, 4, 2, 4] : [3, 5, 3, 5];529}530531/**532 * One shared canvas for measuring an address: measuring is not drawing.533 *534 * Widths are cached by font and text, because the fit gate below is asked once535 * per cell per render — ninety-odd cells at sixty frames — and a hash is one536 * of a handful of strings at any level.537 */538let ruler: CanvasRenderingContext2D | null | undefined;539const rulings = new Map<string, number>();540541export function measureLabel(text: string, font: string): number {542 const key = `${font}|${text}`;543 const held = rulings.get(key);544 if (held !== undefined) return held;545 ruler ??= document.createElement("canvas").getContext("2d");546 let width = text.length * 7;547 if (ruler) {548 ruler.font = font;549 width = ruler.measureText(text).width;550 }551 rulings.set(key, width);552 return width;553}554555/**556 * Whether a cell can carry its own address at this resolution.557 *558 * A hash names the cell it sits in, so a label wider than its cell names the559 * neighbours instead — and at the depth where cells are smallest, that is560 * every label at once. The cell keeps its outline and colour; only the word561 * waits for a zoom that has room for it. (A *child* waits altogether: the562 * subdivision appears at the size it can be read, which is the analysis lane's563 * rule and not this one's.)564 *565 * The measurement is the chip as it will actually be drawn — the address in566 * its own fixed pitch, plus the padding around it — rather than a round number567 * of pixels standing in for one. A flat threshold is wrong in both directions:568 * it drops a two-character hash that had room and keeps a six-character one569 * that never did.570 */571export function labelFitsCell(572 hash: string,573 role: string,574 extent: readonly [number, number, number, number],575 resolution: number,576): boolean {577 if (!resolution) return true;578 const neighbor = role === "neighbor";579 const size = neighbor ? gridTheme.neighborLabelSizePx : gridTheme.labelSizePx;580 const [top, right, bottom, left] = chipPadding(neighbor);581 const width = measureLabel(hash, gridLabelFont(900, size)) + left + right;582 const height = size + top + bottom;583 return width <= (extent[2] - extent[0]) / resolution &&584 height <= (extent[3] - extent[1]) / resolution;585}586587/** A CSS colour at an alpha, whether it arrived as hex or as rgb(). */588export function withAlpha(color: string, alpha: number): string {589 if (alpha >= 1) return color;590 if (color.startsWith("#") && (color.length === 7 || color.length === 4)) {591 const full = color.length === 4592 ? `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}`593 : color;594 const r = Number.parseInt(full.slice(1, 3), 16);595 const g = Number.parseInt(full.slice(3, 5), 16);596 const b = Number.parseInt(full.slice(5, 7), 16);597 return `rgba(${r}, ${g}, ${b}, ${alpha})`;598 }599 if (color.startsWith("rgba(")) return color;600 if (color.startsWith("rgb(")) return `rgba(${color.slice(4, -1)}, ${alpha})`;601 return color;602}603604/**605 * The bottom-right corner of a cell, where its chip sits.606 *607 * The corner is taken from the drawn geometry's own extent, so a cell the608 * antimeridian cut into two pieces carries a chip on each piece rather than609 * one chip halfway between them.610 */611function cornerOf(feature: FeatureLike): Point | undefined {612 const geometry = feature.getGeometry();613 if (!geometry) return undefined;614 const [, minimumY = 0, maximumX = 0] = geometry.getExtent();615 return new Point([maximumX, minimumY]);616}