Files
arah2/theme/basemap/build.mjs
araharah-executor 891caa1c5f feat(01-design-tokens-rams-foundation-03): Rams cartography basemap — liberty fork derived from token package
- build.mjs: fetches OpenFreeMap liberty style, parses theme/src/tokens.css, derives the full role palette in-script via desaturate(tokenHex, satMultiplier, lightnessPerRole) — no hand-tuned cartography hex (D-09)
- liberty-rams.json (110 layers): grayscale/muted land + water, desaturated roads (hierarchy by weight only), muted matte-black labels at reduced opacity, zero-radius aesthetic enforced (halo-blur zeroed, building-3d faux-3D removed) (D-08)
- accent guard: build fails if #EB8008/#ED3F1C/#736B1E (or rgb forms) leak into layers — route accent reserved for Phase 5
- D-10 font fallback: Space Grotesk/Geist probed on glyphs endpoint (404) → Noto Sans Bold/Regular substituted, recorded in style.metadata rams-font-substitutions
- raster relief desaturated (raster-saturation -1); derivation provenance embedded in style.metadata rams-cartography
- theme/package.json: basemap script
2026-10-22 08:05:54 +08:00

446 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
});