import { type GeoJsonBbox, type GeoJsonFeatureCollection, type GeoJsonPosition } from "@trackunit/geo-json-utils"; type ClippedSegment = readonly [number, number, number, number]; /** * - `"fits"` — label fits on the chosen edge. * - `"clipped"` — placed for layout but hidden (edge too narrow for auto placement). * - `"overflow"` — label is shown even when it extends past the edge (force placement). */ export type EdgeFitMode = "fits" | "clipped" | "overflow"; export type EdgeIdentity = readonly [GeoJsonPosition, GeoJsonPosition]; export type EdgeLabelAnchor = "left" | "center" | "right"; export type EdgeLabelLayout = Readonly<{ type: "edge"; directionPx: readonly [number, number]; anchor: EdgeLabelAnchor; outwardSide: "above" | "below"; }> | Readonly<{ type: "point"; }>; export type EdgePlacement = Readonly<{ position: GeoJsonPosition; layout: EdgeLabelLayout; fitMode: EdgeFitMode; edgeIdentity: EdgeIdentity | null; /** Pixel width available for the label between both-side insets. */ availableWidthPx: number; }>; /** * Per-edge insets from decorations sitting on the edge's start/end vertices. * Provided by the edge reservation system to avoid labels overlapping decorations. */ export type EdgeInsets = Readonly<{ startPx: number; endPx: number; }>; /** * Liang-Barsky line-segment clipping against an axis-aligned rectangle. * Returns clipped endpoints [x0, y0, x1, y1] or null if the segment is entirely outside. */ export declare const clipSegmentToRect: (x0: number, y0: number, x1: number, y1: number, xMin: number, yMin: number, xMax: number, yMax: number) => ClippedSegment | null; export { extractEdges } from "@trackunit/geo-json-utils"; export { computeGeometryCentroid } from "@trackunit/geo-json-utils"; /** * Compute the screen-space angle (in degrees) of a line segment, accounting for * Web Mercator distortion. Normalized to [-90, 90] so text reads left-to-right. * * In Web Mercator, 1 degree of latitude spans more pixels than 1 degree of longitude * at latitudes away from the equator (by a factor of sec(lat)). The screen-space * deltas are therefore: * dx_screen ∝ dlng * dy_screen ∝ dlat * sec(midLat) */ export declare const edgeScreenAngleDeg: (x0: number, y0: number, x1: number, y1: number, midLatDeg: number) => number; /** * Compute the pixel length of a line segment at a given zoom level, * accounting for Web Mercator latitude distortion. * * Web Mercator tile math: worldSize = 256 * 2^zoom pixels for 360 degrees of longitude. * Latitude pixels scale by sec(lat). */ export declare const edgePixelLength: (x0: number, y0: number, x1: number, y1: number, zoom: number, midLatDeg: number, tileSize?: number) => number; /** * Determine which side of an edge the geometry interior lies on, in screen space. * Returns "above" if the label should extend above the edge (interior is below), * or "below" if the label should extend below (interior is above). * * Uses the cross product to find which side the centroid is on, then computes * the outward normal's y-component to determine the screen direction. * In y-down screen coords: right normal of (dx, dy) = (-dy, dx). */ export declare const computeOutwardSide: (cx0: number, cy0: number, cx1: number, cy1: number, centroid: GeoJsonPosition, midLatDeg: number) => "above" | "below"; export { extractFirstPointCoordinate } from "@trackunit/geo-json-utils"; export { isPositionInsideRing as isPointInsideRing } from "@trackunit/geo-json-utils"; /** * Per-candidate information passed to a label placement resolver. * * All pixel coordinates are viewport-relative (origin = top-left of viewport). * `outwardBbox` and `inwardBbox` are also viewport-relative bounding boxes for * the rotated label rectangle when placed on the outward and inward side respectively. */ export type EdgeCandidateInfo = Readonly<{ candidateId: number; edgeIdx: number; edge: EdgeIdentity; pxLen: number; angleDeg: number; availableWidthPx: number; labelFits: boolean; outwardSide: "above" | "below"; startInsetPx: number; endInsetPx: number; directionPx: readonly [number, number]; /** Viewport-relative pixel coordinate of the reading-direction start. */ readingStartPx: readonly [number, number]; /** Viewport-relative pixel coordinate of the reading-direction end. */ readingEndPx: readonly [number, number]; /** Viewport-relative bounding box when label is placed on the outward side. */ outwardBbox: Readonly<{ minX: number; maxX: number; minY: number; maxY: number; }>; /** Viewport-relative bounding box when label is placed on the inward side. */ inwardBbox: Readonly<{ minX: number; maxX: number; minY: number; maxY: number; }>; }>; /** * All information the resolver needs to choose a placement. * * `isForced` is true when this is the second-pass retry with `minPixelWidth: 0` * — meaning no edge was wide enough for the label in the normal pass. * The default resolver uses this to center the label (`anchorT: 0.5`) on forced * placements. Custom resolvers can use it to adapt their strategy accordingly. */ export type EdgePlacementContext = Readonly<{ candidates: ReadonlyArray; viewportWidth: number; viewportHeight: number; labelPixelWidth: number; labelAnchor: EdgeLabelAnchor; previousEdgeIdentity?: EdgeIdentity; isForced: boolean; /** * t ∈ [0, 1] along the reading direction of the candidate matching * `previousEdgeIdentity`, corresponding to the geographic position of the * last known anchor. Only set when `previousAnchorGeo` was provided to * `findBestEdgePosition` and the previous edge is still in the candidate pool. * * The default resolver uses this to pin a left-anchored label no further * left than it was on the previous frame ("don't slide back" behaviour). */ previousAnchorClippedT?: number; /** * The `candidateId` of the candidate that `previousAnchorClippedT` was measured * against. With geodesic segmentation a single edge yields multiple candidates * (one per arc segment) that all share the same `edge` identity; `previousAnchorClippedT` * is a fraction along *this specific* candidate's reading frame. The resolver must * pin the label to this same candidate, otherwise the hold fraction would be applied * in a different segment's reading frame and the label would jump. */ previousAnchorClippedCandidateId?: number; /** * The `outwardSide` ("above" | "below") of the label on the previous frame. * The default resolver uses this to avoid flipping the label to the other * side of the line unless the current side no longer fits in the viewport. */ previousLayoutSide?: "above" | "below"; }>; /** * The resolver's answer: which candidate to use and on which side. * * - `anchorT` (0–1 along the reading direction) sets the geographic position * on the edge where the label is pinned. Defaults to the position derived * from `labelAnchor` when omitted. * - `anchor` overrides which part of the label element is pinned to that geo * position (left edge / center / right edge of the pill). Defaults to the * config `labelAnchor` when omitted. Pair with `anchorT` for visual centering * — e.g. `anchorT: 0.5, anchor: "center"` places the center of the pill at * the midpoint of the edge. */ export type EdgePlacementDecision = Readonly<{ edgeIdx: number; candidateId?: number; side: "outward" | "inward"; anchorT?: number; anchor?: EdgeLabelAnchor; }>; /** * Callback signature for custom edge-label placement strategies. * Return `null` to suppress the label entirely. */ export type EdgeLabelPlacementResolver = (context: EdgePlacementContext) => EdgePlacementDecision | null; /** * Find the best position to place an edge label for shape features. * * Clips all edges against the viewport, optionally filters by screen angle * (when `maxReadableAngleDeg` is provided) and pixel length (must be at least * `minPixelWidth` at the current zoom), and returns the anchor position + angle of * the best qualifying edge. * * Selection tiers (applied in order after viewport-inside preference): * 0. Hysteresis — if `previousEdgeIdentity` is set and a candidate from that * edge still fits the label, stick with it to avoid visual jumps during panning. * 1. Panning — when hysteresis fails (previous edge left viewport), score * candidates by blending normalized angle (|angle|/90) with normalized * proximity to the previous midpoint (dist/maxDist), weighted equally * (α = 0.5). Nearby candidates win unless much steeper. * 2. Initial — when no previous edge exists, pick the least-steep * fitting edge (pure min |angleDeg|). * 3. Longest fallback — if no edge fits the label, pick the longest. * * Both ends of the edge are inset so the label doesn't start or end right at * a corner (minimum 6px each, or the edge's decoration reservation if larger). * Both endpoint reservations (`startPx`/`endPx` from `edgeInsets`) are * subtracted from the usable length: the anchor-side inset reserves space at * the text origin, and the far-end inset reserves space at the opposite vertex. * * `fitMode` is determined by comparing usable length against `labelPixelWidth`: * - `"fits"` — label fits within the edge minus both endpoint reservations * - `"clipped"` — label doesn't fit on this edge * * When `labelPlacementResolver` is provided it receives the full * `EdgePlacementContext` (all candidates with both-side bboxes in viewport-relative * coordinates) and returns an `EdgePlacementDecision` (or `null` to suppress). * When omitted, `defaultEdgeLabelPlacementResolver` is used. * * The `outwardSide` indicates which side of the edge is away from the shape * interior, computed via centroid cross product. */ export type FindBestEdgePositionConfig = Readonly<{ features: GeoJsonFeatureCollection; viewportBounds: Readonly; zoom: number; minPixelWidth: number; labelPixelWidth: number; tileSize?: number; maxReadableAngleDeg?: number; previousEdgeIdentity?: EdgeIdentity; /** Geographic position of the previous anchor — used to implement "don't slide back left" hysteresis. */ previousAnchorGeo?: GeoJsonPosition; /** * The layout side ("above" | "below") of the label on the previous frame. * Used to prevent the label from flipping sides unless the current side * no longer fits within the viewport. */ previousLayoutSide?: "above" | "below"; edgeInsets?: ReadonlyArray; labelAnchor?: EdgeLabelAnchor; labelPlacementResolver?: EdgeLabelPlacementResolver; /** True when this is the forced retry pass (minPixelWidth was relaxed to 0). Passed to EdgePlacementContext. */ isForced?: boolean; /** * When `true`, the anchor geo-position is computed via great-circle interpolation * and `directionPx` uses the arc tangent at that point rather than the straight * Mercator chord direction. Defaults to `false` (Mercator straight-line). * * Set this to match `ShapeStyle.geodesic` so the label tracks the visible arc. */ geodesic?: boolean; }>; /** * Default edge-label placement strategy. * * Builds a valid pool from candidates where at least one side (outward or * inward) fits within the viewport. Applies the standard selection tiers * (hysteresis → proximity blend → least-steep → longest fallback) to pick * the best candidate, then chooses the outward side when it fits — falling * back to the inward side otherwise. * * This replaces the previous anchor-nudging approach: instead of pushing the * anchor along the edge to avoid horizontal overflow, it simply flips the label * to the inward side of the edge, which naturally avoids the overflow. */ export declare const defaultEdgeLabelPlacementResolver: (context: EdgePlacementContext) => EdgePlacementDecision | null; export declare const findBestEdgePosition: ({ features, viewportBounds, zoom, minPixelWidth, labelPixelWidth, tileSize, maxReadableAngleDeg, previousEdgeIdentity, previousAnchorGeo, previousLayoutSide, edgeInsets, labelAnchor, labelPlacementResolver, isForced, geodesic, }: FindBestEdgePositionConfig) => EdgePlacement | null;