1
0
Fork 0
polaris-task-force/src/lib/map/style.ts
Z8MB1E cc0a7facd0 feat(map): political zone types, road label tri-state, authoring snapping lib
- map-zones type gains territory and country border (informational political
  boundaries that never gate placement, default fills amber 0.1 / violet 0.06)
- map-roads labelVisible is tri-state: unset means auto, where paved roads
  always label and dirt/trail only label at 4 m/px or finer (roadLabelShown)
- migration clears the old implicit label default on non-paved roads
- new pure snapping lib: findSnap (vertices own their neighborhood, edges must
  be twice as close), closestPointOnSegment, segmentMidpoints
- tests for label angles, tri-state rule, snapping, political zones
2026-09-17 16:27:55 -04:00

183 lines
6.2 KiB
TypeScript

/**
* 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;
}