Back to Atlas map viewer
SOURCE / PINNED RELEASE

Made of little things.

Atlas map viewer

Release
1d2807e80b44…
Author-recorded commit
43810ed2b368…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-4757dbabc86

Archive hash verified: 8db6876d96c460ac…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

packages/offline/src/geometry.ts
import { unproject } from "../../format/src/index";
import { validPosition, type Position } from "../../geocoding/src/model";
export type Polygon = Position[];
const EPS = 1e-12;
const cross = (a: Position, b: Position) => a[0] * b[1] - a[1] * b[0];
const sub = (a: Position, b: Position): Position => [a[0] - b[0], a[1] - b[1]];
export function bounds(p: Polygon): [number, number, number, number] {
  return [
    Math.min(...p.map((v) => v[0])),
    Math.min(...p.map((v) => v[1])),
    Math.max(...p.map((v) => v[0])),
    Math.max(...p.map((v) => v[1])),
  ];
}
function onSegment(p: Position, a: Position, b: Position) {
  return (
    Math.abs(cross(sub(p, a), sub(b, a))) < EPS &&
    p[0] >= Math.min(a[0], b[0]) - EPS &&
    p[0] <= Math.max(a[0], b[0]) + EPS &&
    p[1] >= Math.min(a[1], b[1]) - EPS &&
    p[1] <= Math.max(a[1], b[1]) + EPS
  );
}
export function inside(p: Position, polygon: Polygon) {
  let yes = false;
  for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
    const a = polygon[j]!,
      b = polygon[i]!;
    if (onSegment(p, a, b)) return true;
    if (
      a[1] > p[1] !== b[1] > p[1] &&
      p[0] < ((b[0] - a[0]) * (p[1] - a[1])) / (b[1] - a[1]) + a[0]
    )
      yes = !yes;
  }
  return yes;
}
function intersection(
  a: Position,
  b: Position,
  c: Position,
  d: Position,
): number[] {
  const r = sub(b, a),
    s = sub(d, c),
    q = sub(c, a),
    den = cross(r, s);
  if (Math.abs(den) < EPS) {
    if (Math.abs(cross(q, r)) > EPS) return [];
    const k = Math.abs(r[0]) > Math.abs(r[1]) ? 0 : 1;
    if (Math.abs(r[k]!) < EPS) return [];
    return [c, d]
      .filter((p) => onSegment(p, a, b))
      .map((p) => (p[k]! - a[k]!) / r[k]!);
  }
  const t = cross(q, s) / den,
    u = cross(q, r) / den;
  return t >= -EPS && t <= 1 + EPS && u >= -EPS && u <= 1 + EPS
    ? [Math.max(0, Math.min(1, t))]
    : [];
}
export function validatePolygon(p: Polygon) {
  if (p.length < 3 || p.length > 128 || !p.every(validPosition))
    throw new Error("Draw 3–128 valid corners.");
  const b = bounds(p);
  if (b[2] - b[0] > 180)
    throw new Error("Date-line crossing polygons are not supported yet.");
  let area = 0;
  for (let i = 0; i < p.length; i++) {
    const a = p[i]!,
      b = p[(i + 1) % p.length]!;
    if (Math.hypot(a[0] - b[0], a[1] - b[1]) < 1e-8)
      throw new Error("Corners must be distinct.");
    area += cross(a, b);
    for (let j = i + 1; j < p.length; j++) {
      if (j === i + 1 || (i === 0 && j === p.length - 1)) continue;
      const c = p[j]!,
        d = p[(j + 1) % p.length]!;
      if (
        intersection(a, b, c, d).length ||
        onSegment(a, c, d) ||
        onSegment(b, c, d)
      )
        throw new Error("Polygon edges must not cross or touch.");
    }
  }
  if (Math.abs(area) < 1e-10) throw new Error("Draw an area, not a line.");
}
/** Returns every interior interval; concave polygons can split a segment more than once. */
export function clipSegment(
  a: Position,
  b: Position,
  p: Polygon,
): [number, number][] {
  const box = bounds(p);
  if (
    Math.max(a[0], b[0]) < box[0] ||
    Math.min(a[0], b[0]) > box[2] ||
    Math.max(a[1], b[1]) < box[1] ||
    Math.min(a[1], b[1]) > box[3]
  )
    return [];
  const ts = [0, 1];
  for (let i = 0; i < p.length; i++)
    ts.push(...intersection(a, b, p[i]!, p[(i + 1) % p.length]!));
  ts.sort((a, b) => a - b);
  const unique = ts.filter((t, i) => i === 0 || t - ts[i - 1]! > EPS),
    result: [number, number][] = [];
  for (let i = 1; i < unique.length; i++) {
    const lo = unique[i - 1]!,
      hi = unique[i]!,
      t = (lo + hi) / 2;
    if (inside(interpolate(a, b, t), p)) {
      const previous = result[result.length - 1];
      if (previous && Math.abs(previous[1] - lo) < EPS) previous[1] = hi;
      else result.push([lo, hi]);
    }
  }
  return result;
}
export const interpolate = (a: Position, b: Position, t: number): Position => [
  a[0] + (b[0] - a[0]) * t,
  a[1] + (b[1] - a[1]) * t,
];
export function intersectsRectangle(
  p: Polygon,
  b: [number, number, number, number],
) {
  if (
    p.some((v) => v[0] >= b[0] && v[0] <= b[2] && v[1] >= b[1] && v[1] <= b[3])
  )
    return true;
  const corners: Polygon = [
    [b[0], b[1]],
    [b[2], b[1]],
    [b[2], b[3]],
    [b[0], b[3]],
  ];
  if (corners.some((v) => inside(v, p))) return true;
  return p.some(
    (v, i) => clipSegment(v, p[(i + 1) % p.length]!, corners).length > 0,
  );
}

/** A cell belongs to the selection if it or one of its padding neighbours intersects the polygon. */
export function selectedCell(
  polygon: Polygon,
  z: number,
  x: number,
  y: number,
  padding = 0,
) {
  const n = 2 ** z,
    sw = unproject(
      Math.max(0, x - padding) / n,
      Math.min(n, y + 1 + padding) / n,
    ),
    ne = unproject(
      Math.min(n, x + 1 + padding) / n,
      Math.max(0, y - padding) / n,
    );
  return intersectsRectangle(polygon, [...sw, ...ne]);
}