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
This commit is contained in:
araharah-executor
2026-10-22 08:05:54 +08:00
parent d345e8c1aa
commit 891caa1c5f
3 changed files with 6536 additions and 1 deletions
+445
View File
@@ -0,0 +1,445 @@
#!/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);
});
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -11,6 +11,7 @@
"scripts": {
"build": "tailwindcss -i ./src/theme.css -o ./dist/rams.css --minify",
"send": "mkdir -p dist/fonts && cp -r fonts/geist fonts/space-grotesk dist/fonts/",
"sheet": "npm run build && echo 'open theme/src/sheet.html in browser ( Consumes theme/dist/rams.css )'"
"sheet": "npm run build && echo 'open theme/src/sheet.html in browser ( Consumes theme/dist/rams.css )'",
"basemap": "node basemap/build.mjs"
}
}