1import { outsetColors, palette } from "./constants.js";2import { state } from "./state.js";34export function iconOutsetColor() {5 return outsetColors[state.world?.iconOutset === "dark" ? "dark" : "light"];6}78// A marker is drawn in its category's colour and edged with the map's outset,9// so a colour close to that outset leaves the marker with nothing to stand10// against. Rather than edge it the other way -- which would put a pale rim on11// a map that asked for dark ones -- the colour itself is taken to a lighter or12// darker shade of the same hue, so the legend still matches the world.13export function legibleIconColor(color) {14 const dark = state.world?.iconOutset === "dark";15 const luminance = relativeLuminance(color);16 if (dark ? luminance > 0.3 : luminance < 0.88) return color;17 return withLightness(color, dark ? 0.74 : 0.42);18}1920export function withLightness(color, lightness) {21 const hex = String(color).replace("#", "");22 if (hex.length !== 6) return color;23 const [r, g, b] = [0, 2, 4].map((at) => parseInt(hex.slice(at, at + 2), 16) / 255);24 const high = Math.max(r, g, b);25 const low = Math.min(r, g, b);26 const light = (high + low) / 2;27 let hue = 0;28 const chroma = high - low;29 const saturation = chroma === 0 ? 0 : chroma / (1 - Math.abs(2 * light - 1));30 if (chroma !== 0) {31 if (high === r) hue = ((g - b) / chroma) % 6;32 else if (high === g) hue = (b - r) / chroma + 2;33 else hue = (r - g) / chroma + 4;34 hue *= 60;35 if (hue < 0) hue += 360;36 }37 const c = (1 - Math.abs(2 * lightness - 1)) * saturation;38 const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));39 const m = lightness - c / 2;40 const [rr, gg, bb] =41 hue < 60 ? [c, x, 0] : hue < 120 ? [x, c, 0] : hue < 180 ? [0, c, x] :42 hue < 240 ? [0, x, c] : hue < 300 ? [x, 0, c] : [c, 0, x];43 const channel = (value) =>44 Math.round((value + m) * 255).toString(16).padStart(2, "0");45 return `#${channel(rr)}${channel(gg)}${channel(bb)}`;46}4748export function relativeLuminance(color) {49 const hex = String(color).replace("#", "");50 if (hex.length !== 6) return 0.5;51 const channel = (offset) => parseInt(hex.slice(offset, offset + 2), 16) / 255;52 return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4);53}5455export function colorFor(id) {56 const value = Math.abs(Number(id) || 0);57 return palette[(Math.imul(value, 2654435761) >>> 0) % palette.length];58}5960export function hexToRGBA(value, alpha) {61 const hex = String(value || "#ffffff").replace("#", "");62 const expanded = hex.length === 363 ? hex.split("").map((character) => character + character).join("")64 : hex.slice(0, 6).padEnd(6, "f");65 const number = Number.parseInt(expanded, 16);66 return `rgba(${(number >> 16) & 255}, ${(number >> 8) & 255}, ${number & 255}, ${alpha})`;67}6869export function applyCategoryVisual(element, category) {70 element.style.setProperty("--pin-color", categoryColor(category));71}7273// One colour for a category wherever it is drawn, so the legend and the map74// agree on what it looks like.75export function categoryColor(category) {76 return legibleIconColor(category.color || colorFor(category.id));77}7879export function applyCategoryGlyph(element, category, fallback) {80 element.classList.remove("has-source-icon");81 element.style.removeProperty("--pin-icon");82 element.textContent = "";83 if (!category.iconAsset) {84 element.textContent = fallback;85 return;86 }87 element.style.setProperty("--pin-icon", `url("${iconURL(category.iconAsset)}")`);88 element.classList.add("has-source-icon");89}9091export function iconURL(asset) {92 const path = asset.split("/").map((segment) => encodeURIComponent(segment)).join("/");93 return `${state.volume.base}/icons/${path}`;94}9596export function initials(value) {97 return value.split(/\s+/).slice(0, 2).map((part) => part[0] || "").join("");98}