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/renderer/src/labels.ts
import { BudgetCache } from "../../format/src/cache";
import { project } from "../../format/src/index";
import { AtlasReader } from "../../format/src/reader";
import { worldProjector, cameraBounds, type Camera } from "./camera";
import type { MapStyle } from "./style";
import {
  decodeLabels,
  roadMinZoom,
  poiMinZoom,
  poiColor,
  type LabelBlock,
  type RoadLabel,
} from "../../labels/src/index";
import type { Position, Location } from "../../geocoding/src/model";
type Box = [number, number, number, number];
export interface LabelStats {
  places?: number;
  roads: number;
  pois: number;
  loading: number;
  missing: number;
}
export function overlaps(a: Box, b: Box) {
  return a[0] < b[2] && a[2] > b[0] && a[1] < b[3] && a[3] > b[1];
}
/** Straight text spans only run where the road is long enough and bends gently. */
export function roadPlacement(
  points: Position[],
  textWidth: number,
  width: number,
  height: number,
  fontScale = 1,
): { x: number; y: number; angle: number; box: Box } | null {
  const visible: Position[][] = [];
  for (let i = 1; i < points.length; i++) {
    const a = points[i - 1]!,
      b = points[i]!,
      dx = b[0] - a[0],
      dy = b[1] - a[1];
    let lo = 0,
      hi = 1;
    for (const [p, q] of [
      [-dx, a[0] - 8],
      [dx, width - 8 - a[0]],
      [-dy, a[1] - 8],
      [dy, height - 8 - a[1]],
    ]) {
      if (p === 0) {
        if (q! < 0) {
          hi = -1;
          break;
        }
      } else {
        const t = q! / p!;
        if (p! < 0) lo = Math.max(lo, t);
        else hi = Math.min(hi, t);
      }
    }
    if (hi <= lo) continue;
    const start: Position = [a[0] + dx * lo, a[1] + dy * lo],
      end: Position = [a[0] + dx * hi, a[1] + dy * hi];
    const prior = visible.at(-1),
      last = prior?.at(-1);
    if (
      prior &&
      last &&
      Math.hypot(last[0] - start[0], last[1] - start[1]) < 0.01
    )
      prior.push(end);
    else visible.push([start, end]);
  }
  const spans = visible
    .map((p) => ({
      p,
      l: p.slice(1).map((v, i) => Math.hypot(v[0] - p[i]![0], v[1] - p[i]![1])),
    }))
    .sort(
      (a, b) => b.l.reduce((n, v) => n + v, 0) - a.l.reduce((n, v) => n + v, 0),
    );
  for (const { p, l } of spans) {
    const total = l.reduce((n, v) => n + v, 0);
    if (total < textWidth + 22) continue;
    const at = (d: number): Position => {
      for (let i = 0; i < l.length; i++) {
        if (d <= l[i]!) {
          const t = l[i]! ? d / l[i]! : 0;
          return [
            p[i]![0] + (p[i + 1]![0] - p[i]![0]) * t,
            p[i]![1] + (p[i + 1]![1] - p[i]![1]) * t,
          ];
        }
        d -= l[i]!;
      }
      return p.at(-1)!;
    };
    const a = at((total - textWidth) / 2),
      b = at((total + textWidth) / 2),
      mid = at(total / 2);
    if (Math.hypot(b[0] - a[0], b[1] - a[1]) < textWidth * 0.93) continue;
    let angle = Math.atan2(b[1] - a[1], b[0] - a[0]);
    if (angle > Math.PI / 2) angle -= Math.PI;
    if (angle < -Math.PI / 2) angle += Math.PI;
    const x = (a[0] + b[0]) / 2,
      y = (a[1] + b[1]) / 2;
    if (Math.hypot(mid[0] - x, mid[1] - y) > 4) continue;
    const w =
        (Math.abs(Math.cos(angle)) * textWidth) / 2 +
        Math.abs(Math.sin(angle)) * 7 * fontScale +
        5,
      h =
        (Math.abs(Math.sin(angle)) * textWidth) / 2 +
        Math.abs(Math.cos(angle)) * 7 * fontScale +
        5;
    if (x - w < 5 || y - h < 5 || x + w > width - 5 || y + h > height - 5)
      continue;
    return { x, y, angle, box: [x - w, y - h, x + w, y + h] };
  }
  return null;
}
export class MapLabels {
  roads = true;
  pois = true;
  private dead = false;
  private widths = new Map<string, number>();
  private geometry = new WeakMap<
    RoadLabel,
    { points: Position[]; bounds: Box }
  >();
  private candidates = {
    signature: "",
    roads: [] as RoadLabel[],
    pois: [] as Location[],
  };
  private measure(ctx: CanvasRenderingContext2D, text: string) {
    const key = ctx.font + "|" + text;
    let width = this.widths.get(key);
    if (width === undefined) {
      width = ctx.measureText(text).width;
      if (this.widths.size >= 8192) this.widths.clear();
      this.widths.set(key, width);
    }
    return width;
  }
  private roadGeometry(road: RoadLabel) {
    let geometry = this.geometry.get(road);
    if (!geometry) {
      const points = road.coordinates.map((p) => project(...p));
      let w = Infinity,
        n = Infinity,
        e = -Infinity,
        s = -Infinity;
      for (const [x, y] of points) {
        w = Math.min(w, x);
        n = Math.min(n, y);
        e = Math.max(e, x);
        s = Math.max(s, y);
      }
      geometry = { points, bounds: [w, n, e, s] };
      this.geometry.set(road, geometry);
    }
    return geometry;
  }
  private blocks: BudgetCache<string, { labels: LabelBlock; bytes: number }>;
  get memory() {
    return this.blocks.stats;
  }
  private pending = new Set<string>();
  private failed = new Set<string>();
  private wanted: string[] = [];
  private keys: Set<string>;
  private directorySignature = "";
  private directoryLoading = false;
  private directoryFailed = false;
  private hits: { box: Box; poi: Location }[] = [];
  constructor(
    readonly reader: AtlasReader,
    readonly invalidate: () => void,
    readonly error: (message: string) => void,
    budget = 16 * 1024 * 1024,
  ) {
    // Includes parsed strings/coordinates and lazily projected road geometry.
    this.blocks = new BudgetCache(budget, (block) => block.bytes);
    if (typeof document !== "undefined")
      document.fonts?.ready.then(() => {
        if (!this.dead) {
          this.widths.clear();
          this.invalidate();
        }
      });
    this.keys = new Set(
      reader.index.blocks
        .filter((b) => b.key.startsWith("labels/"))
        .map((b) => b.key),
    );
  }
  retry() {
    this.directorySignature = "";
    this.directoryFailed = false;
    this.failed.clear();
  }
  pick(x: number, y: number) {
    return this.hits.find(
      (h) => x >= h.box[0] && x <= h.box[2] && y >= h.box[1] && y <= h.box[3],
    )?.poi;
  }
  draw(
    ctx: CanvasRenderingContext2D,
    camera: Camera,
    style: MapStyle["labels"],
    occupied: Box[],
    accepts: (box: Box) => boolean = () => true,
  ): LabelStats {
    this.hits = [];
    const { zoom, width, height } = camera;
    const stats: LabelStats = { roads: 0, pois: 0, loading: 0, missing: 0 };
    const z = this.reader.index.manifest.labelZoom;
    if (z === undefined || zoom < 13 || (!this.roads && !this.pois)) {
      this.wanted = [];
      this.blocks.protect([]);
      this.candidates = { signature: "", roads: [], pois: [] };
      return stats;
    }
    const n = 2 ** z,
      [west, north, east, south] = cameraBounds(camera);
    const range = [
      Math.max(0, Math.floor(west * n)),
      Math.min(n - 1, Math.floor(east * n)),
      Math.max(0, Math.floor(north * n)),
      Math.min(n - 1, Math.floor(south * n)),
    ] as const;
    const directorySignature = [
      ...range,
      Math.min(17, Math.floor(zoom)),
      this.roads,
      this.pois,
    ].join(",");
    if (
      !this.directoryLoading &&
      directorySignature !== this.directorySignature &&
      this.reader.index.pages.some((p) => p.prefix?.startsWith("labels/"))
    ) {
      this.directorySignature = directorySignature;
      this.directoryLoading = true;
      this.directoryFailed = false;
      void (async () => {
        const keys = new Set(
          this.reader.index.blocks
            .filter((b) => b.key.startsWith("labels/"))
            .map((b) => b.key),
        );
        for (const kind of ["roads", "pois"] as const)
          if (this[kind])
            for (const tier of [13, 14, 15, 16, 17])
              if (zoom >= tier)
                for (const b of await this.reader.spatial(
                  `labels/${kind}/${tier}`,
                  z,
                  ...range,
                ))
                  keys.add(b.key);
        if (!this.dead) this.keys = keys;
      })()
        .catch((e) => {
          if (!this.dead) {
            this.directoryFailed = true;
            this.error(String(e));
          }
        })
        .finally(() => {
          this.directoryLoading = false;
          if (!this.dead) this.invalidate();
        });
    }
    this.wanted = [];
    for (
      let x = Math.max(0, Math.floor(west * n));
      x <= Math.min(n - 1, Math.floor(east * n));
      x++
    )
      for (
        let y = Math.max(0, Math.floor(north * n));
        y <= Math.min(n - 1, Math.floor(south * n));
        y++
      ) {
        const key = `labels/${z}/${x}/${y}`;
        if (this.keys.has(key)) this.wanted.push(key); // Legacy combined labels.
        for (const kind of ["roads", "pois"] as const) {
          if (!this[kind]) continue;
          for (const tier of [13, 14, 15, 16, 17]) {
            const part = `${key}/${kind}/${tier}`;
            if (zoom >= tier && this.keys.has(part)) this.wanted.push(part);
          }
        }
      }
    this.blocks.protect(this.wanted);
    for (const key of this.failed)
      if (!this.wanted.includes(key)) this.failed.delete(key);
    for (const key of this.wanted) {
      if (
        this.blocks.has(key) ||
        this.pending.has(key) ||
        this.failed.has(key) ||
        this.pending.size >= 4
      )
        continue;
      this.pending.add(key);
      this.reader
        .block(key)
        .then((bytes) => {
          if (!this.dead)
            this.blocks.set(key, {
              labels: decodeLabels(bytes),
              bytes: bytes.length * 4,
            });
        })
        .catch((e) => {
          if (!this.dead) {
            this.failed.add(key);
            this.error(String(e));
          }
        })
        .finally(() => {
          this.pending.delete(key);
          if (!this.dead) this.invalidate();
        });
    }
    stats.loading = this.pending.size + (this.directoryLoading ? 1 : 0);
    stats.missing =
      this.wanted.filter((k) => !this.blocks.has(k)).length +
      (this.directoryFailed ? 1 : 0);
    const toScreen = worldProjector(camera);
    const screen = (p: Position) => toScreen(project(...p));
    const signature = this.wanted
      .map((k) => `${k}:${this.blocks.has(k)}`)
      .join("|");
    if (signature !== this.candidates.signature) {
      const roads: RoadLabel[] = [],
        pois: Location[] = [];
      for (const key of this.wanted) {
        const b = this.blocks.get(key)?.labels;
        if (b) {
          roads.push(...b.roads);
          pois.push(...b.pois);
        }
      }
      roads.sort(
        (a, b) =>
          roadMinZoom(a.category) - roadMinZoom(b.category) ||
          a.id.localeCompare(b.id),
      );
      pois.sort(
        (a, b) =>
          poiMinZoom(a.category) - poiMinZoom(b.category) ||
          a.id.localeCompare(b.id),
      );
      this.candidates = { signature, roads, pois };
    }
    const { roads, pois } = this.candidates;
    ctx.save();
    ctx.font = `500 ${12 * style.scale}px "Avenir Next", sans-serif`;
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.lineJoin = "round";
    const named = new Map<string, Position[]>();
    if (this.roads)
      for (const road of roads) {
        if (zoom < roadMinZoom(road.category)) continue;
        const geometry = this.roadGeometry(road),
          bounds = geometry.bounds;
        if (
          bounds[2] < west ||
          bounds[0] > east ||
          bounds[3] < north ||
          bounds[1] > south
        )
          continue;
        const placement = roadPlacement(
          geometry.points.map(toScreen),
          this.measure(ctx, road.name),
          width,
          height,
          style.scale,
        );
        if (!placement) continue;
        const { x, y, angle, box } = placement;
        if (
          !accepts(box) ||
          occupied.some((b) => overlaps(b, box)) ||
          named
            .get(road.name)
            ?.some((p) => Math.hypot(p[0] - x, p[1] - y) < 280)
        )
          continue;
        ctx.save();
        ctx.translate(x, y);
        ctx.rotate(angle);
        ctx.strokeStyle = style.halo;
        ctx.lineWidth = 3;
        ctx.strokeText(road.name, 0, 0);
        ctx.fillStyle = style.color;
        ctx.fillText(road.name, 0, 0);
        ctx.restore();
        occupied.push(box);
        named.set(road.name, [...(named.get(road.name) ?? []), [x, y]]);
        stats.roads++;
      }
    ctx.font = `500 ${11 * style.scale}px "Avenir Next", sans-serif`;
    ctx.textAlign = "left";
    const seen = new Set<string>();
    if (this.pois)
      for (const poi of pois) {
        if (zoom < poiMinZoom(poi.category) || seen.has(poi.id)) continue;
        const [x, y] = screen(poi.coordinates);
        if (x < 6 || y < 6 || x > width - 6 || y > height - 6) continue;
        const text =
            poi.name.length > 45 ? poi.name.slice(0, 42) + "…" : poi.name,
          w = this.measure(ctx, text);
        const box: Box = [
          x - 7,
          y - 9 * style.scale,
          x + w + 16,
          y + 9 * style.scale,
        ];
        if (
          !accepts(box) ||
          box[0] < 6 ||
          box[1] < 6 ||
          box[2] > width - 6 ||
          box[3] > height - 6 ||
          occupied.some((b) => overlaps(b, box))
        )
          continue;
        // Avoid a node and area label for the same establishment occupying the same place.
        const duplicate = `${poi.name.toLocaleLowerCase()}|${Math.round(x / 55)}|${Math.round(y / 40)}`;
        if (seen.has(duplicate)) continue;
        seen.add(duplicate);
        seen.add(poi.id);
        ctx.beginPath();
        ctx.arc(x, y, 4, 0, Math.PI * 2);
        ctx.fillStyle = poiColor(poi.category);
        ctx.fill();
        ctx.strokeStyle = style.halo;
        ctx.lineWidth = 2;
        ctx.stroke();
        ctx.strokeStyle = style.halo;
        ctx.lineWidth = 3;
        ctx.strokeText(text, x + 10, y);
        ctx.fillStyle = style.color;
        ctx.fillText(text, x + 10, y);
        occupied.push(box);
        this.hits.push({ box, poi });
        stats.pois++;
      }
    ctx.restore();
    return stats;
  }
  destroy() {
    this.dead = true;
    this.blocks.clear();
    this.widths.clear();
    this.candidates = { signature: "", roads: [], pois: [] };
  }
}