#!/usr/bin/env node /* * araharah — Rams cartography basemap builder (D-08 / D-09 / D-10) * ------------------------------------------------------------------ * Forks the OpenFreeMap `liberty` vector style into the Telemetry * Rams cartography: calibrated grayscale/muted grounding, desaturated * roads (hierarchy by weight only), muted labels, zero accent colors. * * D-09: every cartographic value is DERIVED from the token package * (theme/src/tokens.css) inside this script — no hand-tuned hex in * the style JSON. The only literals below are the derivation * parameters (saturation multipliers, lightness targets per role) * that shape token genes into cartographic roles. * * D-08: signal-orange / functional-red / olive-green are reserved * for the route line + hazard semantics rendered by later phases * (Phase 5 owns the route vector). The accent guard at the bottom of * this script fails the build if any of the three accents leak into * the output layers. * * D-10: intended Rams label stacks (Space Grotesk for major place * labels, Geist for minor) are probed on the OpenFreeMap glyphs * endpoint at build time. The endpoint does not self-host them in * Phase 1 (pbf glyph ranges), so the closest HOSTED stack is * substituted and recorded in style.metadata["rams-font-substitutions"] * — honest fallback provenance; Phase 5+ may move to self-hosted * glyphs served from the Worker. * * Node >= 18 (global fetch). No dependencies beyond node builtins. * * Usage: npm run basemap --workspace theme */ import { readFile, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const TOKENS_CSS = join(HERE, '../src/tokens.css'); // D-09 derivation source const OUT_JSON = join(HERE, 'liberty-rams.json'); const LIBERTY_URL = 'https://tiles.openfreemap.org/styles/liberty'; /* ———————————————————— color math (pure helpers) ———————————————————— */ const SAT_BAND = [0.06, 0.12]; // calibrated low-saturation band (Interfaces) const hexToRgb = (hex) => { const n = Number.parseInt(hex.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }; const rgbToHex = ([r, g, b]) => '#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('').toUpperCase(); /** average two token hexes — "between soft-beige and chassis-subtle" */ const mixHex = (a, b) => { const A = hexToRgb(a); const B = hexToRgb(b); return rgbToHex(A.map((v, i) => Math.round((v + B[i]) / 2))); }; const rgbToHsl = ([r0, g0, b0]) => { const r = r0 / 255, g = g0 / 255, b = b0 / 255; const max = Math.max(r, g, b), min = Math.min(r, g, b); let h = 0, s = 0; const l = (max + min) / 2; if (max !== min) { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; default: h = (r - g) / d + 4; } h *= 60; } return [h, s, l]; }; const hslToRgb = ([hDeg, s, l]) => { const h = hDeg / 360; const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; const f = (t0) => { const t = (t0 % 1 + 1) % 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; }; return [f(h + 1 / 3), f(h), f(h - 1 / 3)].map((v) => Math.round(v * 255)); }; /** * desaturate(hex, satMultiplier, lightness?) — the D-09 derivation core. * hex→HSL, multiply saturation, clamp into the calibrated band * [0.06, 0.12], then adjust lightness to the role target (Interfaces * table). Hue genes from the source token are preserved — the whole * palette stays one warm-neutral instrument family. */ const desaturate = (hex, satMultiplier, lightness) => { const [h, s, l] = rgbToHsl(hexToRgb(hex)); const sat = Math.min(Math.max(s * satMultiplier, SAT_BAND[0]), SAT_BAND[1]); const lig = lightness === undefined ? l : lightness; return rgbToHex(hslToRgb([h, sat, lig])); }; /** token hex → rgba() string (labels, halos — matte-black at reduced opacity) */ const rgba = (hex, alpha) => { const [r, g, b] = hexToRgb(hex); return `rgba(${r}, ${g}, ${b}, ${alpha})`; }; /* ———————————————————— token parsing (D-09) ———————————————————— */ const TOKEN_RE = /--color-([a-z0-9-]+):\s*(#[0-9A-Fa-f]{6})/g; function parseTokens(css) { const map = new Map(); for (const m of css.matchAll(TOKEN_RE)) map.set(m[1], m[2].toUpperCase()); return map; } /* Accent tokens are excluded from the derivation set by name AND by * value (primary-container aliases the signal-orange hex) — D-08. */ const EXCLUDED_TOKENS = new Set(['signal-orange', 'functional-red', 'olive-green']); const ACCENT_HEXES = new Set(['#EB8008', '#ED3F1C', '#736B1E']); const ACCENT_RGB_RES = [ /\brgba?\(\s*235\s*,\s*128\s*,\s*8\b/i, // signal-orange /\brgba?\(\s*237\s*,\s*63\s*,\s*28\b/i, // functional-red /\brgba?\(\s*115\s*,\s*107\s*,\s*30\b/i, // olive-green ]; function token(tokens, name) { const hex = tokens.get(name); if (!hex) throw new Error(`token not found in tokens.css: --color-${name}`); if (EXCLUDED_TOKENS.has(name)) { throw new Error(`accent token excluded from basemap derivation: ${name} (D-08)`); } if (ACCENT_HEXES.has(hex)) { throw new Error(`token ${name} carries an accent hex (${hex}) excluded from derivation (D-08)`); } return hex; } /* ———————————————————— role palette (all derived) ———————————————————— */ function buildRoles(tokens) { const T = (name) => token(tokens, name); const roles = { /* land ground: between soft-beige and chassis-subtle (Interfaces) */ ground: desaturate(mixHex(T('soft-beige'), T('chassis-subtle')), 0.4), /* parcel step slightly darker than ground (parks, landcover, landuse) */ groundSubtle: desaturate(T('chassis-subtle'), 0.45), /* mid-dark rule for parcel outlines / park rings */ groundEdge: desaturate(T('matte-black'), 1, 0.62), /* water: desaturated mid-gray derived from matte-black (Interfaces) */ water: desaturate(T('matte-black'), 1, 0.71), /* minor road cores — lightest neutral */ roadFillMinor: desaturate(T('pure-white'), 1, 0.96), /* major road cores — slightly darker than minor; hierarchy by weight */ roadFillMajor: desaturate(T('surface-container-low'), 0.12, 0.92), /* machined dark rule around road cores (1px/2px solid rules per design doc) */ casing: desaturate(T('matte-black'), 1, 0.34), rail: desaturate(T('matte-black'), 1, 0.48), /* buildings: light gray within the calibrated band, darker than ground */ buildingFill: desaturate(T('soft-beige'), 0.5, 0.76), buildingEdge: desaturate(T('matte-black'), 1, 0.68), boundary: desaturate(T('matte-black'), 1, 0.4), aeroway: desaturate(T('chassis-subtle'), 0.5, 0.7), }; const derivations = { ground: 'mix(soft-beige, chassis-subtle) · sat ×0.4 · band [0.06, 0.12]', groundSubtle: 'chassis-subtle · sat ×0.45 (parcel step darker than ground)', groundEdge: 'matte-black · sat ×1 (band-min) · L 0.62', water: 'matte-black · sat ×1 (band-min) · L 0.71 (mid-gray)', roadFillMinor: 'pure-white · sat ×1 (band-min) · L 0.96', roadFillMajor: 'surface-container-low · sat ×0.12 · L 0.92 (slightly darker than minor)', casing: 'matte-black · sat ×1 (band-min) · L 0.34 (machined rule)', rail: 'matte-black · sat ×1 (band-min) · L 0.48', buildingFill: 'soft-beige · sat ×0.5 · L 0.76', buildingEdge: 'matte-black · sat ×1 (band-min) · L 0.68', boundary: 'matte-black · sat ×1 (band-min) · L 0.40', aeroway: 'chassis-subtle · sat ×0.5 · L 0.70', }; return { roles, derivations }; } /* Labels: matte-black at reduced opacity (Interfaces); halo = ground tone */ function buildLabelColors(roles, tokens) { const matte = token(tokens, 'matte-black'); // allowed — not an accent return { minor: rgba(matte, 0.6), major: rgba(matte, 0.82), halo: rgba(roles.ground, 0.9), }; } /* ———————————————————— layer classification ———————————————————— */ /* Major place labels: city and above ("place-city, place-subtown and * above" — liberty has no subtown layer; city-class and everything * above it in the place hierarchy). Town/village/other, POIs, water * names, highway names/shields, airports → minor. */ const MAJOR_LABEL_IDS = new Set([ 'label_country_1', 'label_country_2', 'label_country_3', 'label_state', 'label_city', 'label_city_capital', ]); const ROAD_BODY_MAJOR = new Set(['motorway', 'motorway_link', 'trunk_primary', 'secondary_tertiary', 'link']); const ROAD_BODY_MINOR = new Set(['minor', 'street', 'service_track', 'path_pedestrian']); function classifyRoadBody(body) { if (/^(major_rail|transit_rail)(_hatching)?$/.test(body)) return 'rail'; if (body.endsWith('_casing')) return 'casing'; if (ROAD_BODY_MAJOR.has(body)) return 'roadFillMajor'; if (ROAD_BODY_MINOR.has(body)) return 'roadFillMinor'; return null; // road_area_pattern / one-way arrow symbols — untouched } function classify(layer) { const id = layer.id; /* all label-bearing symbols first (waterway_line_label etc.) */ if (layer.type === 'symbol') { if (layer.layout && Array.isArray(layer.layout['text-font'])) { return { kind: 'label', major: MAJOR_LABEL_IDS.has(id) }; } return null; // icon-only symbols (one-way arrows) — untouched } if (id === 'background') return { kind: 'role', role: 'ground' }; if (id === 'building') return { kind: 'role', role: 'buildingFill', outline: 'buildingEdge' }; if (id === 'water' || id.startsWith('waterway_')) return { kind: 'role', role: 'water' }; if (id === 'park') return { kind: 'role', role: 'groundSubtle', outline: 'groundEdge' }; if (id === 'park_outline') return { kind: 'role', role: 'groundEdge' }; if (id.startsWith('landcover_') || id.startsWith('landuse_') || id === 'aeroway_fill') { return { kind: 'role', role: 'groundSubtle', outline: 'groundEdge' }; } if (id.startsWith('aeroway_')) return { kind: 'role', role: 'aeroway' }; if (id.startsWith('boundary_')) return { kind: 'role', role: 'boundary' }; if (/^(road|tunnel|bridge)_/.test(id)) { const role = classifyRoadBody(id.replace(/^(road|tunnel|bridge)_/, '')); return role ? { kind: 'role', role } : null; } return null; } /* ———————————————————— transform ———————————————————— */ function transformLayer(layer, ctx) { if (layer.id === 'natural_earth') { /* shaded-relief raster → fully desaturated (grayscale grounding) */ layer.paint = layer.paint || {}; layer.paint['raster-saturation'] = -1; ctx.touched.push(`${layer.id} → raster-saturation −1`); return layer; } const spec = classify(layer); if (!spec) { ctx.untouched.push(layer.id); return layer; } const p = (layer.paint = layer.paint || {}); if (spec.kind === 'role') { /* zoom-interpolated color ramps flatten to the role constant; * width/opacity ramps are preserved — hierarchy by weight only */ if ('background-color' in p) p['background-color'] = ctx.roles[spec.role]; if ('fill-color' in p) p['fill-color'] = ctx.roles[spec.role]; if ('fill-outline-color' in p) p['fill-outline-color'] = ctx.roles[spec.outline || spec.role]; if ('line-color' in p) p['line-color'] = ctx.roles[spec.role]; ctx.touched.push(`${layer.id} → ${spec.role}`); } if (spec.kind === 'label') { /* muted labels: matte-black at reduced opacity, ground-tone halo */ p['text-color'] = spec.major ? ctx.labelColors.major : ctx.labelColors.minor; p['text-halo-color'] = ctx.labelColors.halo; if ('text-halo-blur' in p) p['text-halo-blur'] = 0; // Rams: zero blur layer.layout['text-font'] = (spec.major ? ctx.fonts.served.major : ctx.fonts.served.minor).slice(); ctx.touched.push(`${layer.id} → label(${spec.major ? 'major' : 'minor'})`); } return layer; } /* ———————————————————— font probing (D-10 fallback) ———————————————————— */ const FONT_INTENT = { major: ['Space Grotesk Medium'], // Rams headline face for city-and-above minor: ['Geist Regular', 'Space Grotesk Regular'], // Rams body face for the rest }; const FONT_FALLBACK = { major: ['Noto Sans Bold'], // closest hosted stack to the headline-weight role minor: ['Noto Sans Regular'], }; async function probeGlyph(glyphsUrl, stack) { const url = glyphsUrl .replace('{fontstack}', encodeURIComponent(stack)) .replace('{range}', '0-255'); try { const res = await fetch(url, { redirect: 'follow' }); return res.ok; // 404 → false → substitution path } catch { return false; } } async function resolveFontStacks(glyphsUrl) { const served = {}; const substitutions = {}; for (const [kind, intentStacks] of Object.entries(FONT_INTENT)) { const hosted = []; for (const stack of intentStacks) { if (await probeGlyph(glyphsUrl, stack)) hosted.push(stack); } if (hosted.length > 0) { served[kind] = hosted; } else { for (const stack of FONT_FALLBACK[kind]) { if (!(await probeGlyph(glyphsUrl, stack))) { throw new Error(`fallback glyph stack not hosted either: ${stack}`); } } served[kind] = FONT_FALLBACK[kind]; substitutions[kind] = { intended: intentStacks, served: FONT_FALLBACK[kind], reason: 'not hosted on tiles.openfreemap.org glyphs endpoint (HTTP 404 at build time); ' + 'closest hosted stack substituted. Self-hosted Rams woff2 (theme/fonts) cannot ' + 'serve pbf glyph ranges in Phase 1 — revisit with a Worker-served glyphs route.', }; } } return { served, substitutions }; } /* ———————————————————— accent guard (D-08) ———————————————————— */ function assertNoAccent(style) { const json = JSON.stringify(style.layers); for (const hex of ACCENT_HEXES) { if (json.toUpperCase().includes(hex)) { throw new Error(`accent leak: ${hex} present in output layers (D-08 violation)`); } } for (const re of ACCENT_RGB_RES) { if (re.test(json)) { throw new Error(`accent leak (rgb form): ${re} matched output layers (D-08 violation)`); } } } /* ———————————————————— main ———————————————————— */ async function main() { const [tokensCss, styleRes] = await Promise.all([ readFile(TOKENS_CSS, 'utf8'), fetch(LIBERTY_URL, { redirect: 'follow' }), ]); if (!styleRes.ok) throw new Error(`liberty style fetch failed: HTTP ${styleRes.status}`); const style = await styleRes.json(); const tokens = parseTokens(tokensCss); if (tokens.size < 40) throw new Error(`token parse suspiciously thin: ${tokens.size} tokens`); const { roles, derivations } = buildRoles(tokens); const labelColors = buildLabelColors(roles, tokens); const fonts = await resolveFontStacks(style.glyphs); /* glyphs endpoint kept as hosted OpenFreeMap (Phase 1: no self-hosted pbf) */ if (!style.glyphs || !style.glyphs.includes('tiles.openfreemap.org/fonts')) { throw new Error(`unexpected glyphs endpoint: ${style.glyphs}`); } const ctx = { roles, labelColors, fonts, touched: [], untouched: [] }; const removedLayers = []; const layers = []; for (const layer of style.layers) { /* building-3d is fill-extrusion — faux 3D dimensionality is * rejected by the Rams design system (Elevation & Depth) */ if (layer.id === 'building-3d') { removedLayers.push({ id: layer.id, reason: 'fill-extrusion = faux 3D — rejected by Telemetry Rams (Elevation & Depth: Physical Planarity)', }); continue; } layers.push(transformLayer(layer, ctx)); } style.layers = layers; style.name = 'Telemetry Rams Basemap'; style.metadata = { ...(style.metadata || {}), 'rams-cartography': { 'derived-from': 'theme/src/tokens.css', 'derivation-mechanism': 'desaturate(tokenHex, satMultiplier, lightnessPerRole) — hue genes from tokens, saturation clamped to [0.06, 0.12]', 'source-style': LIBERTY_URL, roles: Object.fromEntries( Object.entries(roles).map(([k, v]) => [k, { color: v, derivation: derivations[k] }]) ), 'excluded-accent-tokens': [...EXCLUDED_TOKENS], 'untouched-layers': ctx.untouched, }, 'rams-font-substitutions': fonts.substitutions, 'rams-removed-layers': removedLayers, }; assertNoAccent(style); const out = JSON.stringify(style, null, 2) + '\n'; await writeFile(OUT_JSON, out, 'utf8'); /* build report — provenance for human review */ console.log('Telemetry Rams basemap — derivation report'); console.log(` tokens parsed: ${tokens.size} from ${TOKENS_CSS}`); console.log(' role palette:'); for (const [k, v] of Object.entries(roles)) console.log(` ${k.padEnd(15)} ${v} (${derivations[k]})`); console.log(` labels: major ${labelColors.major} / minor ${labelColors.minor} / halo ${labelColors.halo}`); console.log(` fonts (served): major ${fonts.served.major} / minor ${fonts.served.minor}`); if (Object.keys(fonts.substitutions).length > 0) { for (const [kind, s] of Object.entries(fonts.substitutions)) { console.log(` font substitution: [${kind}] ${s.intended.join(', ')} → ${s.served.join(', ')}`); } } console.log(` layers: ${ctx.touched.length} transformed, ${ctx.untouched.length} untouched (${ctx.untouched.join(', ')}), ${removedLayers.length} removed`); console.log(' accent guard: PASS (no #EB8008 / #ED3F1C / #736B1E in layers)'); console.log(` written: ${OUT_JSON} (${(out.length / 1024).toFixed(1)} KB)`); } main().catch((err) => { console.error(`basemap build failed: ${err.message}`); process.exit(1); });