/** * Pure styling/label helpers for the map feature layers. * No Payload import: safe on client and server. */ import { pointDistance, polylineLength, type MapPoint } from "./points"; const HEX_COLOR = /^#[0-9a-fA-F]{6}$/; /** Field validator for optional hex colors (#rrggbb). Empty = use defaults. */ export function validateHexColor(value: unknown): true | string { if (value === undefined || value === null || value === "") return true; return typeof value === "string" && HEX_COLOR.test(value) ? true : "Use a hex color like #38bdf8, or leave empty for the default."; } /** Meters per screen pixel at the given zoom for a world scaled to 256 px at zoom 0. */ export function metersPerPx(zoom: number, worldSize: number): number { return worldSize / (256 * Math.pow(2, zoom)); } /** Dash array for a stroke style name. */ export function dashArrayFor(style: string | null | undefined): string | undefined { if (style === "dashed") return "8 8"; if (style === "dotted") return "2 6"; return undefined; } /** * Centroid of a polygon ring (area-weighted; falls back to the vertex average * for degenerate rings). */ export function polygonCentroid(points: MapPoint[]): MapPoint { if (points.length === 0) return [0, 0]; let area = 0; let cx = 0; let cy = 0; for (let i = 0, j = points.length - 1; i < points.length; j = i++) { const [xi, yi] = points[i]; const [xj, yj] = points[j]; const cross = xi * yj - xj * yi; area += cross; cx += (xj + xi) * cross; cy += (yj + yi) * cross; } area /= 2; if (Math.abs(area) < 1e-6) { const sumX = points.reduce((total, point) => total + point[0], 0); const sumY = points.reduce((total, point) => total + point[1], 0); return [sumX / points.length, sumY / points.length]; } return [cx / (6 * area), cy / (6 * area)]; } /** * Densifies a polyline with a Catmull-Rom spline (passing through every * vertex) so rendered lines curve smoothly through bends instead of kinking * at each vertex. Unit-agnostic: works in map meters or screen pixels alike. */ export function splinePolyline(points: MapPoint[], subdivisions = 16): MapPoint[] { if (points.length < 3 || subdivisions < 1) return points; const at = (index: number): MapPoint => points[Math.max(0, Math.min(points.length - 1, index))]; const out: MapPoint[] = [points[0]]; for (let i = 0; i < points.length - 1; i++) { const p0 = at(i - 1); const p1 = at(i); const p2 = at(i + 1); const p3 = at(i + 2); for (let s = 1; s <= subdivisions; s++) { const t = s / subdivisions; const t2 = t * t; const t3 = t2 * t; out.push([ 0.5 * (2 * p1[0] + (-p0[0] + p2[0]) * t + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3), 0.5 * (2 * p1[1] + (-p0[1] + p2[1]) * t + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3), ]); } } return out; } export interface RoadLabelPlacement { point: MapPoint; /** Arc length along the input polyline at this label. */ distance: number; /** True when the segment direction would render text upside down or reading downward (use a reversed path so vertical text reads bottom-to-top). */ flipped: boolean; } /** * Label placements along a road polyline: one label roughly every * `intervalMeters`, centered on the segment it sits on and flagged when the * direction would render text upside down, capped at `maxLabels`. */ export function roadLabelPoints( points: MapPoint[], intervalMeters: number, maxLabels: number, ): RoadLabelPlacement[] { const total = polylineLength(points); if (points.length < 2 || total <= 0 || intervalMeters <= 0) return []; const labels: RoadLabelPlacement[] = []; let nextAt = intervalMeters / 2; let traveled = 0; for (let i = 1; i < points.length && labels.length < maxLabels; i++) { const from = points[i - 1]; const to = points[i]; const segment = pointDistance(from, to); if (segment <= 0) continue; const angle = segmentAngleDeg(from, to); while (nextAt <= traveled + segment && labels.length < maxLabels) { const f = (nextAt - traveled) / segment; labels.push({ point: [from[0] + (to[0] - from[0]) * f, from[1] + (to[1] - from[1]) * f], distance: nextAt, flipped: angle > 90 || angle < -90 || angle === 90, }); nextAt += intervalMeters; } traveled += segment; } return labels; } /** * Screen-space direction of a segment in degrees (atan2(dy, dx)). Map y runs * south like the screen's y-axis, so this is the on-screen direction. Angles * outside [-90, 90) would render text upside down or (exactly vertical) * reading downward. */ function segmentAngleDeg(from: MapPoint, to: MapPoint): number { const dx = to[0] - from[0]; const dy = to[1] - from[1]; if (dx === 0 && dy === 0) return 0; return (Math.atan2(dy, dx) * 180) / Math.PI; } /** * CSS rotation for a straight label running along the from->to direction: * normalized into [-90, 90] (never upside down) with vertical text reading * bottom-to-top. */ export function labelScreenAngle(from: MapPoint, to: MapPoint): number { const angle = segmentAngleDeg(from, to); if (angle > 90) return angle - 180; if (angle < -90) return angle + 180; if (angle === 90) return -90; return angle; } /** * Minor roads (dirt/trail) only carry a name label this close to the ground, * in meters per pixel (zoom >= 3 on an 8192 m world). */ export const MINOR_ROAD_LABEL_MAX_M_PER_PX = 4; /** * Tri-state road label rule: an explicit checkbox value wins; unset means * auto, where paved roads always label and dirt/trail roads label only at * close zoom (metersPerPx at or below MINOR_ROAD_LABEL_MAX_M_PER_PX; pass * null/undefined to evaluate the zoom-independent part). */ export function roadLabelShown( road: { labelVisible?: boolean | null; surface: string }, metersPerPx?: number | null, ): boolean { if (road.labelVisible != null) return road.labelVisible; if (road.surface === "paved") return true; return metersPerPx != null && metersPerPx <= MINOR_ROAD_LABEL_MAX_M_PER_PX; }