clone

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

feat/atlas-next-horizon
atlas / render / chart / markers.ts
markers.ts 11.0K
1// The mark a collection wears, composed once and worn by both panes.2//3// A MARKER IS A SYMBOL, NOT A BUBBLE. The collection's colour is carried by4// the symbol itself — the glyph is a silhouette and it is filled with that5// colour — and what keeps it legible over whatever art the world is drawn on6// is an outset: the same silhouette, filled with the world's rim colour and7// stamped in a small disc around the symbol, so the mark wears a halo cut to8// its own shape. A disc behind the glyph with a black symbol punched out of9// it says the same thing about every collection and hides the one drawing10// that told them apart.11//12// THE RASTER is 64×64 and is composed once per asset, colour and rim, then13// handed to both panes: OpenLayers draws it as an `Icon`, three draws it into14// a sprite's texture. Two panes composing their own would be two answers to15// "what is a Shrine", and the halo — the expensive half — would have to be16// written twice.17//18// A GLYPH IS TINTED AND A PICTURE IS NOT. A glyph is a monochrome silhouette19// whose colour is the reader's to assign; a picture already carries its own,20// and flattening it to one colour would leave nothing but its outline filled21// in. `atlas.icon.kind` is curation's word on which of the two arrived.22//23// THE COLOUR LADDER is here for the same reason the halo is: a colour close24// to the rim it is drawn against leaves the mark with nothing to stand25// against, so a colour that collides with the outset is taken to a lighter or26// darker shade of the same hue. The hue survives, which is what keeps the27// legend and the map agreeing about what a collection looks like.2829import { KEY_ICON_KIND } from "@atlas/analysis/semconv/keys";30import type { Collection } from "../data/payload.ts";3132const LIGHT_OUTSET = "rgba(255, 255, 255, 0.96)";3334/** The rim a world's markers wear to stay legible against its art. */35export const OUTSET_COLORS: Readonly<Record<string, string>> = {36 light: LIGHT_OUTSET,37 dark: "rgba(7, 9, 7, 0.98)",38};3940/**41 * The colour a declared outset names.42 *43 * `atlas.icon.outset` is curation's word about the art a world is drawn on —44 * a dark rim on a pale map, a pale rim on a dark one — and it arrives as the45 * token, not the colour. Anything that is not exactly `dark` reads as light,46 * which is the reference's own rule and the reason an unset or misspelled47 * outset still draws a legible marker rather than none.48 */49export function outsetColor(outset: string): string {50 return OUTSET_COLORS[outset] ?? LIGHT_OUTSET;51}5253/**54 * A colour that can be seen against the rim it will be drawn with.55 *56 * A dark rim needs a mark bright enough to sit inside it and a light rim57 * needs one dark enough. Rather than rim it the other way — which would put a58 * pale halo on a world that asked for dark ones — the colour itself is taken59 * to a lighter or darker shade of the same hue, so the collection is still60 * recognisably its own colour wherever else it is drawn.61 */62export function legibleIconColor(color: string, outset: string): string {63 const dark = outset === "dark";64 const luminance = relativeLuminance(color);65 if (dark ? luminance > 0.3 : luminance < 0.88) return color;66 return withLightness(color, dark ? 0.74 : 0.42);67}6869/**70 * The same hue and saturation at a different lightness.71 *72 * Ungamma'd, deliberately: this is the reference's own arithmetic, and the73 * numbers the ladder above is calibrated against are these ones. Anything74 * that is not a six-digit hex is handed back untouched — a collection may75 * declare `rgb()` or a keyword, and a colour we cannot take apart is better76 * drawn as it was asked for than guessed at.77 */78export function withLightness(color: string, lightness: number): string {79 const hex = String(color).replace("#", "");80 if (hex.length !== 6) return color;81 const [r = 0, g = 0, b = 0] = [0, 2, 4].map(82 (at) => Number.parseInt(hex.slice(at, at + 2), 16) / 255);83 const high = Math.max(r, g, b);84 const low = Math.min(r, g, b);85 const light = (high + low) / 2;86 const chroma = high - low;87 const saturation = chroma === 0 ? 0 : chroma / (1 - Math.abs(2 * light - 1));88 let hue = 0;89 if (chroma !== 0) {90 if (high === r) hue = ((g - b) / chroma) % 6;91 else if (high === g) hue = (b - r) / chroma + 2;92 else hue = (r - g) / chroma + 4;93 hue *= 60;94 if (hue < 0) hue += 360;95 }96 const c = (1 - Math.abs(2 * lightness - 1)) * saturation;97 const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));98 const m = lightness - c / 2;99 const [rr, gg, bb] =100 hue < 60 ? [c, x, 0] : hue < 120 ? [x, c, 0] : hue < 180 ? [0, c, x] :101 hue < 240 ? [0, x, c] : hue < 300 ? [x, 0, c] : [c, 0, x];102 const channel = (value: number): string =>103 Math.round((value + m) * 255).toString(16).padStart(2, "0");104 return `#${channel(rr)}${channel(gg)}${channel(bb)}`;105}106107/** How bright a colour reads, on the reference's ungamma'd weights. */108export function relativeLuminance(color: string): number {109 const hex = String(color).replace("#", "");110 if (hex.length !== 6) return 0.5;111 const channel = (at: number): number => Number.parseInt(hex.slice(at, at + 2), 16) / 255;112 return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4);113}114115/** A collection's name, cut down to what fits inside a marker. */116export function initialsOf(title: string): string {117 return title.split(/\s+/).slice(0, 2).map((part) => part[0] ?? "").join("");118}119120/**121 * Whether a collection's artwork is a picture rather than a glyph.122 *123 * `atlas.icon.kind` names what a file suffix used to imply, and `iconPicture`124 * is the manifest's own copy of the same fact.125 */126export function iconIsPicture(collection: Collection): boolean {127 return Boolean(collection.iconPicture || collection.attrs?.[KEY_ICON_KIND] === "picture");128}129130/** Everything a raster is composed from, which is everything it is keyed by. */131export interface MarkerFace {132 /** The asset path, or "" for a collection with no artwork at all. */133 readonly asset: string;134 /** Where that asset lives, already resolved against the build's base. */135 readonly url: string;136 readonly picture: boolean;137 /** The collection's colour, already taken through the ladder above. */138 readonly color: string;139 /** The rim's colour, already resolved from the world's outset token. */140 readonly outset: string;141}142143/**144 * What two faces have to agree on to share a raster.145 *146 * Not the collection: one asset drawn in one colour with one rim is one147 * image, and a world that gives Shrine and Daedric Shrine the same artwork148 * and the same accent composes it once.149 */150export function markerKey(face: MarkerFace): string {151 return `${face.asset}:${face.color}:${face.outset}`;152}153154// A raster is 64 wide with the glyph inset 6 on every side, which leaves the155// halo its 3 pixels of reach plus a pixel of air. The disc is every offset156// within a radius of √10 — a rounder rim than a 7×7 square, and 37 stamps157// rather than 49.158const RASTER = 64;159const INSET = 6;160const GLYPH = 52;161const HALO = 3;162const HALO_REACH = 10;163164/**165 * The raster itself: a haloed, tinted symbol on a transparent field.166 *167 * The halo goes down first and the symbol last, so the rim is behind the168 * mark rather than over it — stamping the other way around would leave every169 * glyph looking eroded at its edges.170 */171export function composeMarker(source: CanvasImageSource, face: MarkerFace): string {172 const canvas = surface();173 const paper = canvas?.getContext("2d");174 // A page with no canvas draws no marker; the initials fallback covers it.175 if (!canvas || !paper) return "";176 const tinted = silhouette(source, face.picture ? "" : face.color, !face.picture);177 const outline = silhouette(source, face.outset);178 if (!tinted || !outline) return "";179 for (let y = -HALO; y <= HALO; y++) {180 for (let x = -HALO; x <= HALO; x++) {181 if (x * x + y * y <= HALO_REACH) paper.drawImage(outline, x, y);182 }183 }184 paper.drawImage(tinted, 0, 0);185 return canvas.toDataURL("image/png");186}187188/**189 * The glyph on its own canvas, filled with one colour when it is asked for.190 *191 * `source-in` keeps the drawn shape and replaces its pixels, which is what192 * turns a monochrome symbol into a coloured one without knowing anything193 * about the colour it arrived in. Smoothing is only ever spoken about for the194 * tint: a picture is scaled nearest-neighbour so its own pixels survive, a195 * glyph is smoothed because it is about to become a silhouette anyway.196 */197function silhouette(198 source: CanvasImageSource, fill: string, smoothing?: boolean,199): HTMLCanvasElement | null {200 const canvas = surface();201 const paper = canvas?.getContext("2d");202 if (!canvas || !paper) return null;203 if (smoothing !== undefined) paper.imageSmoothingEnabled = smoothing;204 paper.drawImage(source, INSET, INSET, GLYPH, GLYPH);205 if (fill) {206 paper.globalCompositeOperation = "source-in";207 paper.fillStyle = fill;208 paper.fillRect(0, 0, RASTER, RASTER);209 }210 return canvas;211}212213function surface(): HTMLCanvasElement | null {214 const canvas = document.createElement("canvas");215 canvas.width = RASTER;216 canvas.height = RASTER;217 return canvas;218}219220/** Rasters that have finished, whether they arrived or failed. */221const composed = new Map<string, string | null>();222/** Rasters that have been asked for, so no asset is ever fetched twice. */223const asked = new Map<string, Promise<string | null>>();224225/**226 * The raster for a face if it is already composed, and nothing if it is not.227 *228 * The synchronous half, for a style function: a style is evaluated inside a229 * render and cannot wait for an image. What a pane draws while it waits — and230 * for a collection whose artwork never arrives — is the initials fallback,231 * which is also what a build with no icons at all draws.232 */233export function markerRaster(face: MarkerFace): string | null {234 if (!face.asset) return null;235 return composed.get(markerKey(face)) ?? null;236}237238/**239 * The raster for a face, composing it if this is the first ask.240 *241 * A failure is remembered as permanently as a success: a collection whose242 * artwork 404s wears its initials for the life of the page rather than asking243 * again on every repaint.244 */245export function markerRasterReady(face: MarkerFace): Promise<string | null> {246 if (!face.asset || !face.url) return Promise.resolve(null);247 const key = markerKey(face);248 const held = asked.get(key);249 if (held) return held;250 const work = new Promise<string | null>((resolve) => {251 const source = new Image();252 // The raster is exported as a data URL, and a canvas tainted by an image253 // fetched without CORS cannot be exported at all — so a cross-origin254 // build's icons are asked for in the one way that can be composed.255 source.crossOrigin = "anonymous";256 source.onload = (): void => { resolve(composeMarker(source, face) || null); };257 source.onerror = (): void => { resolve(null); };258 source.src = face.url;259 }).then((raster) => {260 composed.set(key, raster);261 return raster;262 });263 asked.set(key, work);264 return work;265}266267/** Forget every composed raster. For tests, which compose their own. */268export function forgetMarkerRasters(): void {269 composed.clear();270 asked.clear();271}