1import Point from "ol/geom/Point.js";2import {3 Fill,4 Icon,5 Stroke,6 Style,7 Text,8} from "ol/style.js";910import { anyShapeCollectionVisible, collectionFor, collectionOf, isCollectionHidden } from "./collections.js";11import { gridTheme } from "./constants.js";12import { gridCellVisual } from "./grid.js";13import { labelPolicy, labelSilenced } from "./semconv.js";14import { state } from "./state.js";15import {16 atMaximumNativeZoom,17 isPriorityPin,18 pinIsHidden,19} from "./features.js";20import {21 categoryColor,22 hexToRGBA,23 iconOutsetColor,24 iconURL,25 initials,26} from "./theme.js";2728export function featureOrder(left, right) {29 return (left.get("priority") || 0) - (right.get("priority") || 0);30}3132export function pinFeatureStyle(feature) {33 const pin = feature.get("pin");34 if (!pin || pinIsHidden(pin) || pin.passesZoneFilters || isPriorityPin(pin)) return null;35 return markerStyles(pin, false);36}3738export function zonePinFeatureStyle(feature) {39 const pin = feature.get("pin");40 if (!pin || !state.highlightedZones.size || !pin.passesZoneFilters ||41 pinIsHidden(pin) || isPriorityPin(pin)) {42 return null;43 }44 return markerStyles(pin, false);45}4647// A pin's label draws when its collection's names are spoken -- curated48// always, or the reader's toggle -- or while Z is held. The key reveals what49// is merely optional, never what the reader silenced by hand, and the zoom50// has no say either way: what was asked for is every name at once.51export function pinLabelFeatureStyle(feature) {52 const pin = feature.get("pin");53 if (!pin || pinIsHidden(pin)) return null;54 if (labelPolicy(null, pin.category) !== "always" &&55 !(state.labelsHeld && !labelSilenced(pin.category))) {56 return null;57 }58 return markerLabelStyle(pin);59}6061export function priorityFeatureStyle(feature) {62 const pin = feature.get("pin");63 if (!pin || pinIsHidden(pin)) return null;64 const marker = markerStyles(pin, pin === state.selectedPin);65 if (pin === state.hoveredPin || pin === state.selectedPin) {66 return [marker, markerLabelStyle(pin)];67 }68 return marker;69}7071export function markerStyles(pin, selected) {72 const key = `marker:${pin.category.id}:${selected ? 1 : 0}`;73 if (state.styleCache.has(key)) return state.styleCache.get(key);74 const color = categoryColor(pin.category);75 const zIndex = selected ? 20_000_000 : pin.priority;76 const renderedIcon = state.markerIcons.get(markerIconKey(pin.category));77 let style;78 if (renderedIcon) {79 style = new Style({80 image: new Icon({81 src: renderedIcon,82 width: selected ? 36 : 31,83 height: selected ? 36 : 31,84 }),85 zIndex,86 });87 } else {88 style = new Style({89 text: new Text({90 text: initials(pin.category.title),91 font: `900 ${selected ? 15 : 13}px Inter, sans-serif`,92 fill: new Fill({ color }),93 stroke: new Stroke({ color: iconOutsetColor(), width: selected ? 4 : 3 }),94 }),95 zIndex,96 });97 }98 state.styleCache.set(key, style);99 return style;100}101102export function markerLabelStyle(pin) {103 const key = `marker-label:${pin.location.id}`;104 if (state.styleCache.has(key)) return state.styleCache.get(key);105 const style = new Style({106 text: new Text({107 text: pin.location.title,108 offsetY: 21,109 font: '700 10px "Arial Narrow", "Roboto Condensed", sans-serif',110 fill: new Fill({ color: "#f2ece0" }),111 stroke: new Stroke({ color: "rgba(0,0,0,0.98)", width: 3 }),112 backgroundFill: new Fill({ color: "rgba(10,12,17,0.72)" }),113 padding: [2, 4, 2, 4],114 overflow: true,115 }),116 zIndex: pin.priority,117 });118 state.styleCache.set(key, style);119 return style;120}121122export function prepareMarkerIcon(category) {123 if (!category.iconAsset) return;124 const key = markerIconKey(category);125 if (state.markerIcons.has(key)) return;126 state.markerIcons.set(key, null);127 const source = new Image();128 source.onload = () => {129 const canvas = document.createElement("canvas");130 canvas.width = 64;131 canvas.height = 64;132 const context = canvas.getContext("2d");133 const color = categoryColor(category);134135 // A glyph is a silhouette, so it takes the category colour. A picture136 // already carries its own, and flattening it to one colour would leave137 // nothing but its outline filled in.138 const tinted = document.createElement("canvas");139 tinted.width = 64;140 tinted.height = 64;141 const tintedContext = tinted.getContext("2d");142 tintedContext.imageSmoothingEnabled = !category.iconPicture;143 tintedContext.drawImage(source, 6, 6, 52, 52);144 if (!category.iconPicture) {145 tintedContext.globalCompositeOperation = "source-in";146 tintedContext.fillStyle = color;147 tintedContext.fillRect(0, 0, 64, 64);148 }149150 const outline = document.createElement("canvas");151 outline.width = 64;152 outline.height = 64;153 const outlineContext = outline.getContext("2d");154 outlineContext.drawImage(source, 6, 6, 52, 52);155 outlineContext.globalCompositeOperation = "source-in";156 outlineContext.fillStyle = iconOutsetColor();157 outlineContext.fillRect(0, 0, 64, 64);158159 for (let y = -3; y <= 3; y++) {160 for (let x = -3; x <= 3; x++) {161 if (x * x + y * y <= 10) context.drawImage(outline, x, y);162 }163 }164 context.drawImage(tinted, 0, 0);165 state.markerIcons.set(key, canvas.toDataURL("image/png"));166 // A raster is keyed by asset and colour, so it stands in for every category167 // drawn that way -- Shrine and Daedric Shrine, House and House (Ownable) --168 // and only one of them asked for it. Every marker style is dropped rather169 // than that category's two, because the others cached their initials while170 // this was still loading and have nothing to tell them the icon arrived.171 dropMarkerStyles();172 state.layers.pins.changed();173 state.layers.zonePins.changed();174 state.layers.priority.changed();175 };176 source.onerror = () => state.markerIcons.set(key, false);177 source.src = iconURL(category.iconAsset);178}179180export function dropMarkerStyles() {181 for (const key of state.styleCache.keys()) {182 if (key.startsWith("marker:")) state.styleCache.delete(key);183 }184}185186export function markerIconKey(category) {187 return `${category.iconAsset || ""}:${categoryColor(category)}:${state.world?.iconOutset || "light"}`;188}189190// gridLabelFont spells one chunk of a cell label. Monospace, as the field191// these are typed into already is: every hash at a level is the same length,192// so in a fixed pitch a level keeps or drops its labels as one.193function gridLabelFont(weight, size) {194 return `${weight} ${size}px ${gridTheme.labelFont}`;195}196197// gridStyle is the chart's adapter over gridCellVisual: the shared tokens198// become an outline-and-fill style plus, when the cell can carry it, a small199// chip in the cell's bottom-right corner -- the bounding-box convention --200// spelling the hash with its prefix faint and its final character bright.201export function gridStyle(feature, resolution) {202 const cell = feature.get("gridCell");203 if (!cell) return null;204 const neighbor = cell.role === "neighbor";205 const size = neighbor ? gridTheme.neighborLabelSizePx : gridTheme.labelSizePx;206 const font = gridLabelFont(900, size);207 const padding = neighbor ? [2, 4, 2, 4] : [3, 5, 3, 5];208 const labelled = labelFitsCell(cell, font, size, padding, resolution);209 const visual = gridCellVisual(cell, {210 subgridVisible: state.subgridVisible,211 labelled,212 });213 if (!visual) return null;214215 // The system is part of the key: "1" names a geohash cell and an S2 face,216 // and the two are drawn nowhere near each other.217 const key = `grid:${state.gridSystem}:${cell.hash}:${cell.role}:${cell.contextDistance}:` +218 `${labelled ? 1 : 0}:${visual.bare ? 1 : 0}`;219 if (state.styleCache.has(key)) return state.styleCache.get(key);220221 const zIndex = cell.role === "leaf" || cell.role === "scope" ? 100 : feature.get("priority");222 const styles = [new Style({223 fill: visual.fill224 ? new Fill({ color: hexToRGBA(visual.fill.color, visual.fill.opacity) })225 : undefined,226 stroke: new Stroke({227 color: hexToRGBA(visual.line.color, visual.line.opacity),228 width: visual.line.widthPx,229 }),230 zIndex,231 })];232 if (visual.label) {233 // The prefix is context and the final character is the address's234 // principal digit, so the prefix takes a lighter cut of the same fixed235 // pitch at the same size -- weight alone carries the faintness, because236 // mixing sizes on one line moves the baselines and the address wobbles.237 const chunks = visual.label.prefix238 ? [visual.label.prefix, gridLabelFont(500, size), visual.label.final, gridLabelFont(900, size)]239 : visual.label.final;240 const inset = gridTheme.labelInsetPx * resolution;241 const corner = chipVertex(feature.getGeometry());242 if (corner) styles.push(new Style({243 geometry: new Point([corner[0] - inset, corner[1] + inset]),244 text: new Text({245 text: chunks,246 font: gridLabelFont(900, size),247 textAlign: "right",248 textBaseline: "bottom",249 fill: new Fill({ color: hexToRGBA(visual.label.color, visual.label.textAlpha) }),250 backgroundFill: new Fill({ color: visual.label.chip }),251 padding,252 overflow: true,253 }),254 zIndex: zIndex + 1,255 }));256 }257 state.styleCache.set(key, styles);258 return styles;259}260261// chipVertex finds where the chip belongs: the bottom-right-most vertex of262// the cell as it is actually drawn -- clipped to the surface, closed along263// a pole, curved however the system curves. For a rectangle that is exactly264// the bounding-box corner, so geohash chips sit where they always have; for265// a cell whose bounding box wanders off its own ground -- an S2 face266// unwrapped past the seam -- the chip stays on the cell it names.267function chipVertex(geometry) {268 const polygons = geometry.getType() === "MultiPolygon"269 ? geometry.getCoordinates()270 : [geometry.getCoordinates()];271 let corner = null;272 for (const polygon of polygons) {273 for (const [x, y] of polygon[0] || []) {274 if (!corner || x - y > corner[0] - corner[1]) corner = [x, y];275 }276 }277 return corner;278}279280// A hash names the cell it sits in, so a label wider than its cell names the281// neighbours instead -- and at the depth where cells are smallest, that is282// every label at once. The cell keeps its outline and colour; only the word283// waits for a zoom that has room for it.284export function labelFitsCell(cell, font, size, padding, resolution) {285 if (!resolution) return true;286 const width = measureLabel(cell.hash, font) + padding[1] + padding[3];287 const height = size + padding[0] + padding[2];288 return width <= (cell.extent[2] - cell.extent[0]) / resolution &&289 height <= (cell.extent[3] - cell.extent[1]) / resolution;290}291292export const labelRuler = document.createElement("canvas").getContext("2d");293export const labelWidths = new Map();294295export function measureLabel(text, font) {296 const key = `${font}|${text}`;297 let width = labelWidths.get(key);298 if (width === undefined) {299 labelRuler.font = font;300 width = labelRuler.measureText(text).width;301 labelWidths.set(key, width);302 }303 return width;304}305306export const zoneScrimFill = new Style({ fill: new Fill({ color: "rgba(5, 8, 16, 0.62)" }) });307308// The scrim exists only while something is highlighted, and hiding a309// collection withdraws its highlights, so an all-hidden map has no scrim310// feature left to draw; the check covers the moment in between.311export function zoneScrimStyle() {312 return anyShapeCollectionVisible() ? zoneScrimFill : null;313}314315export function zoneStyle(feature, resolution) {316 const zone = feature.get("zone");317 if (isCollectionHidden(collectionOf(zone))) return null;318 const child = feature.get("child");319 const highlighted = state.highlightedZones.has(zone.id);320 const dimmed = zoneContextDimmed(zone.id);321 // A path is a line and a weight, and the weight is its collection's to322 // declare -- drawn at the width the world gives it rather than a width the323 // screen does. A feature spelling a width of its own is still honoured.324 const groundWidth = Number(zone.attrs?.["atlas.stroke.width_px"]) ||325 Number(collectionFor(zone)?.attrs?.["atlas.stroke.width_px"]) || 0;326 if (groundWidth > 0 && feature.getGeometry()?.getType() === "MultiLineString") {327 return pathZoneStyle(zone, feature.get("color"), groundWidth / resolution, highlighted, dimmed);328 }329 const key = `zone:${zone.id}:${child ? 1 : 0}:${highlighted ? 1 : dimmed ? 2 : 0}`;330 if (state.styleCache.has(key)) return state.styleCache.get(key);331 const color = feature.get("color");332 if (dimmed) {333 // No fill of its own: the scrim below has already taken this zone down334 // with the rest of the map, and a second wash would single it out again.335 const dimmedStyle = new Style({336 stroke: new Stroke({337 color: hexToRGBA(color, child ? 0.34 : 0.44),338 width: child ? 1 : 1.3,339 lineDash: child ? [3, 4] : [7, 5],340 }),341 zIndex: child ? 101 : 100,342 });343 state.styleCache.set(key, dimmedStyle);344 return dimmedStyle;345 }346 const style = new Style({347 fill: new Fill({ color: hexToRGBA(color, child ? 0.035 : 0.07) }),348 stroke: new Stroke({349 color: hexToRGBA(color, child ? 0.5 : 0.72),350 width: child ? 1.2 : 1.8,351 lineDash: child ? [3, 4] : [7, 5],352 }),353 });354 if (!highlighted) {355 state.styleCache.set(key, style);356 return style;357 }358 const highlightedStyles = [359 new Style({360 stroke: new Stroke({361 color: "rgba(255,255,255,0.94)",362 width: 5,363 }),364 zIndex: 9000,365 }),366 new Style({367 fill: new Fill({ color: hexToRGBA(color, 0.22) }),368 stroke: new Stroke({369 color,370 width: 2.6,371 }),372 zIndex: 9001,373 }),374 ];375 state.styleCache.set(key, highlightedStyles);376 return highlightedStyles;377}378379// pathZoneStyle draws a line zone as the one stroke it is: a translucent380// band at ground width with round joins, and a thin centerline so the path381// reads at any zoom. Widths depend on the view's resolution, so these styles382// are built per call rather than cached; a world holds few path zones.383function pathZoneStyle(zone, color, width, highlighted, dimmed) {384 const band = Math.max(width, 1.5);385 if (highlighted) {386 return [387 new Style({388 stroke: new Stroke({389 color: "rgba(255,255,255,0.94)",390 width: band + 4,391 lineCap: "round",392 lineJoin: "round",393 }),394 zIndex: 9000,395 }),396 new Style({397 stroke: new Stroke({398 color: hexToRGBA(color, 0.9),399 width: band,400 lineCap: "round",401 lineJoin: "round",402 }),403 zIndex: 9001,404 }),405 ];406 }407 return [408 new Style({409 stroke: new Stroke({410 color: hexToRGBA(color, dimmed ? 0.14 : 0.3),411 width: band,412 lineCap: "round",413 lineJoin: "round",414 }),415 }),416 new Style({417 stroke: new Stroke({418 color: hexToRGBA(color, dimmed ? 0.4 : 0.8),419 width: Math.min(1.6, band),420 lineDash: [7, 5],421 }),422 }),423 ];424}425426export function zoneTitleStyle(feature) {427 if (atMaximumNativeZoom()) return null;428 const zone = feature.get("zone");429 if (isCollectionHidden(collectionOf(zone))) return null;430 if (quietChipHidden(zone)) return null;431 const highlighted = state.highlightedZones.has(zone.id);432 // A quiet name, once asked for, skips the crowd-thinning below: like433 // holding Z for pin labels, asking means every one of them.434 if (labelPolicy(zone) === "quiet") return renderedZoneTitleStyle(feature);435 const child = feature.get("child");436 const zoom = state.engine.getView().getZoom() || 0;437 if (!highlighted && child && zoom < state.fitZoom + 3) return null;438 const spanPixels = feature.get("span") / state.engine.getView().getResolution();439 if (!highlighted && state.zoneTitleCount > 40 && spanPixels < 52) return null;440 return renderedZoneTitleStyle(feature);441}442443export function zoneTitleDetailStyle(feature) {444 if (!atMaximumNativeZoom()) return null;445 const zone = feature.get("zone");446 if (isCollectionHidden(collectionOf(zone))) return null;447 if (quietChipHidden(zone)) return null;448 return renderedZoneTitleStyle(feature);449}450451// A quiet zone's name is context, not headline: the chip waits until the452// reader asks after that zone in particular -- highlighting it, selecting it453// -- or asks after every name at once by holding Z. Z reveals what is merely454// optional, never what was silenced: a collection the reader quieted by hand455// stays quiet under the key, because the choice was theirs and the key is456// not an override.457function quietChipHidden(zone) {458 if (labelPolicy(zone) !== "quiet") return false;459 if (state.highlightedZones.has(zone.id) || state.selectedZone === zone) return false;460 const silenced = state.labelOverrides.get(collectionOf(zone)) === "quiet";461 return silenced || !state.labelsHeld;462}463464export function renderedZoneTitleStyle(feature) {465 const child = feature.get("child");466 const zone = feature.get("zone");467 const highlighted = state.highlightedZones.has(zone.id);468 const dimmed = zoneContextDimmed(zone.id);469 const key = `zone-title:${zone.id}:${child ? 1 : 0}:${highlighted ? 1 : dimmed ? 2 : 0}`;470 if (state.styleCache.has(key)) return state.styleCache.get(key);471 const color = feature.get("color");472 const style = new Style({473 text: new Text({474 text: zone.title.toLocaleUpperCase(),475 font: `${child ? "700 12px" : "800 14px"} "Arial Narrow", "Roboto Condensed", sans-serif`,476 fill: new Fill({477 color: highlighted478 ? "#10131a"479 : dimmed480 ? "rgba(196,191,177,0.56)"481 : child ? "#c8c2b4" : "#f2ece0",482 }),483 stroke: new Stroke({484 color: highlighted485 ? "rgba(255,255,255,0.96)"486 : dimmed ? "rgba(0,0,0,0.82)" : "rgba(0,0,0,0.95)",487 width: highlighted ? 2 : dimmed ? 3 : 4,488 }),489 backgroundFill: new Fill({490 color: highlighted ? color : dimmed ? "rgba(8,11,18,0.84)" : "rgba(13,16,23,0.72)",491 }),492 backgroundStroke: new Stroke({493 color: highlighted ? "#ffffff" : hexToRGBA(color, dimmed ? 0.24 : 0.55),494 width: highlighted ? 2 : 1,495 }),496 padding: [5, 8, 5, 8],497 overflow: true,498 }),499 zIndex: highlighted ? 9_100_000 : feature.get("priority"),500 });501 state.styleCache.set(key, style);502 return style;503}504505export function zoneContextDimmed(zoneID) {506 if (!state.highlightedZones.size || state.highlightedZones.has(zoneID)) return false;507 for (const highlightedID of state.highlightedZones) {508 if (zoneIsAncestorOf(zoneID, highlightedID)) return false;509 }510 return true;511}512513export function zoneIsAncestorOf(candidateID, zoneID) {514 const visited = new Set();515 let parentID = state.zoneRecords.get(zoneID)?.zone.parent;516 while (parentID != null && !visited.has(parentID)) {517 if (parentID === candidateID) return true;518 visited.add(parentID);519 parentID = state.zoneRecords.get(parentID)?.zone.parent;520 }521 return false;522}