- 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
183 lines
6.2 KiB
TypeScript
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;
|
|
}
|