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/composition.ts
import { RasterPlane, type RasterLayerViews } from "./raster";
import { PlacesView } from "../../labels/src/place-pages";
import type { WorkspaceMap } from "./workspace-map";
import { defaultOverlays, type OverlayFrame } from "./overlays";
import { FeatureOverlay } from "./features";
import type { AtlasMap, MapStats } from "./map";
import type { MapFeature, Bounds } from "../../features/src/index";
import type { Location } from "../../geocoding/src/model";
import type {
  Position,
  WalkingResult,
  Reachable,
} from "../../routing/src/index";
import { MAP_LAYERS, LAYER_MASKS, type Peak } from "../../format/src/index";
import { FLAT_SCENE, parseScene, type MapScene } from "./style";
import { Renderer } from "../pkg/atlas_renderer";
import { TileScheduler } from "./tile-scheduler";
import { MapInput } from "./input";
import { MapLabels } from "./labels";
import { PAPER, compileStyle, type MapStyle } from "./style";
import {
  cameraBounds,
  fitZoom,
  groundDelta,
  screenToWorld,
  worldProjector,
  type Camera,
} from "./camera";
import {
  clipArrays,
  contains,
  coverageBounds,
  coverageOf,
  CoveredTiles,
  planComposition,
  polygonsOf,
  type Channel,
  type CompositionSource,
} from "./composition-plan";
import {
  project,
  unproject,
  tileLayer,
  type Place,
  type MapLayer,
} from "../../format/src/index";
import { AtlasReader } from "../../format/src/reader";
import { DEFAULT_DECODED_BUDGETS } from "../../format/src/cache";
import { decodePlaces, PLACE_LABEL_KEY } from "../../labels/src/places";
export interface CompositionMapSource extends CompositionSource {
  reader: AtlasReader;
  color: string;
  rasterOpacity?: number;
  rasterLayers?: RasterLayerViews;
}
interface Session {
  source: CompositionMapSource;
  tiles: CoveredTiles;
  raster: RasterPlane;
  scheduler: TileScheduler;
  labels: MapLabels;
  shown: string[];
  prefix: string;
  clips: Map<Channel, number>;
  places?: Place[];
  placesView?: PlacesView;
  placesLoading: boolean;
  placesFailed: boolean;
  dead: boolean;
}
export interface CompositionStats extends MapStats {
  zoom: number;
  tiles: number;
  missing: number;
  loading: number;
  sources: number;
  triangles: number;
  bytes: number;
}
/** One WASM/WebGL renderer and camera. Sessions own immutable source identities;
 * the composition owns display precedence, never semantic graph compatibility. */
export class CompositionMap implements WorkspaceMap {
  reader!: AtlasReader; // Explicitly selected source for scoped tools.
  readonly overlays = defaultOverlays();
  private debugOverlays = defaultOverlays();
  private featureOverlay = new FeatureOverlay();
  private scene: MapScene = { ...FLAT_SCENE };
  private state: OverlayFrame["state"] = {
    routingCells: [],
    reachable: null,
    selection: null,
    grid: false,
    cache: false,
    route: null,
    endpoints: [null, null],
    location: null,
    selected: null,
  };
  private storedKeys = new Map<string, ReadonlySet<string>>();
  private picker: {
    owner: string;
    choose: (p: Position) => void;
    cancel: () => void;
    finish?: () => void;
  } | null = null;
  private globalLayers: readonly MapLayer[] = MAP_LAYERS;
  private placeNames = true;
  private streetNames = true;
  private pois = true;
  onFeatureSelect?: (id: string) => void;
  onPoiSelect?: (poi: Location) => void;
  onPickModeChange?: (hint: string | null) => void;
  onSceneChange?: (scene: MapScene) => void;
  setFeatures(features: MapFeature[], selected: Set<string>) {
    this.featureOverlay.features = features;
    this.featureOverlay.selected = new Set(selected);
    this.invalidate();
  }
  setReachable(value: Reachable | null) {
    this.state.reachable = value;
    this.invalidate();
  }
  setRoutingCells(cells: Array<{ key: string; state: string }>) {
    this.state.routingCells = cells;
    this.invalidate();
  }
  setSelection(points: Position[], closed = true) {
    this.state.selection = { points, closed };
    this.invalidate();
  }
  setEndpoints(start: Position | null, end: Position | null) {
    this.state.endpoints = [start, end];
    this.state.route = null;
    this.invalidate();
  }
  setRoute(route: Extract<WalkingResult, { status: "found" }> | null) {
    this.state.route = route;
    this.invalidate();
  }
  fitRoute() {
    if (this.state.route)
      this.fitPositions([
        ...this.state.route.coordinates,
        ...this.state.endpoints.filter((p): p is Position => !!p),
      ]);
  }
  fitPositions(positions: Position[]) {
    if (!positions.length) return;
    let w = Infinity,
      n = Infinity,
      e = -Infinity,
      s = -Infinity;
    for (const point of positions) {
      const [x, y] = project(...point);
      w = Math.min(w, x);
      n = Math.min(n, y);
      e = Math.max(e, x);
      s = Math.max(s, y);
    }
    this.center = [(w + e) / 2, (n + s) / 2];
    this.zoom = Math.max(
      0,
      Math.min(
        20,
        fitZoom(
          [w, n, e, s],
          Math.max(160, this.canvas.clientWidth - 140),
          Math.max(160, this.canvas.clientHeight - 220),
          this.pitch,
          this.bearing,
        ),
      ),
    );
    this.invalidate();
  }
  beginPicking(
    owner: string,
    choose: (p: Position) => void,
    cancel: () => void,
    hint: string,
    finish?: () => void,
  ) {
    if (this.picker && this.picker.owner !== owner) {
      const previous = this.picker;
      this.picker = null;
      previous.cancel();
    }
    this.picker = { owner, choose, cancel, finish };
    this.canvas.classList.add("picking");
    this.onPickModeChange?.(hint);
  }
  endPicking(owner: string) {
    if (this.picker?.owner !== owner) return;
    this.picker = null;
    this.canvas.classList.remove("picking");
    this.onPickModeChange?.(null);
  }
  focusLocation(
    coordinates: Position,
    name: string,
    zoom = 17,
    street?: Position[],
    requested?: Position,
  ) {
    this.state.location = { coordinates, name, street, requested };
    this.state.selected = null;
    this.center = project(...coordinates);
    this.zoom = Math.max(0, Math.min(20, zoom));
    this.invalidate();
  }
  clearLocation() {
    this.state.location = null;
    this.invalidate();
  }
  focus(peak: Peak) {
    this.state.location = null;
    this.state.selected = peak;
    this.center = project(...peak.coordinates);
    this.zoom = Math.max(this.zoom, 13);
    this.invalidate();
  }
  viewBounds(): Bounds {
    const [w, n, e, s] = cameraBounds(this.camera),
      sw = unproject(w, s),
      ne = unproject(e, n);
    return [
      Math.max(-180, sw[0]),
      Math.max(-85, sw[1]),
      Math.min(180, ne[0]),
      Math.min(85, ne[1]),
    ];
  }
  setScene(scene: MapScene) {
    // Stencil ownership is on the ground plane; 3D remains available in single-map mode.
    this.scene = { ...parseScene(scene), extrude: false };
    this.pitch = this.scene.pitch;
    this.bearing = this.scene.bearing;
    this.onSceneChange?.({ ...this.scene });
    this.invalidate();
  }
  resetView() {
    this.setScene({ ...this.scene, pitch: 0, bearing: 0 });
  }
  setLayers(layers: number | readonly MapLayer[]) {
    this.globalLayers =
      typeof layers === "number"
        ? MAP_LAYERS.filter((l) => (LAYER_MASKS[l] & layers) !== 0)
        : layers;
    this.revision++;
    this.invalidate();
  }
  setLabels(roads: boolean, pois: boolean, places = true) {
    this.streetNames = roads;
    this.pois = pois;
    this.placeNames = places;
    for (const s of this.sessions.values()) {
      s.labels.roads = roads;
      s.labels.pois = pois;
    }
    this.revision++;
    this.invalidate();
  }
  setOffline(offline: boolean) {
    for (const s of this.sessions.values()) {
      s.source.reader.offline = offline;
      s.scheduler.retry();
      s.raster.retry();
      s.labels.retry();
      s.placesView?.retry();
      s.placesFailed = false;
    }
    this.invalidate();
  }
  setGrid(enabled: boolean) {
    this.state.grid = enabled;
    this.invalidate();
  }
  setCacheView(enabled: boolean, _keys?: ReadonlySet<string>) {
    this.state.cache = enabled;
    this.invalidate();
  }
  async refreshCacheView(enabled: boolean) {
    this.setCacheView(enabled);
    if (!enabled) return;
    await Promise.all(
      [...this.sessions.values()].map(async (s) => {
        const snapshot = s.source.manifest.snapshot;
        const keys = await s.source.reader.options.store?.keys(snapshot);
        if (!s.dead) this.storedKeys.set(snapshot, new Set(keys));
      }),
    );
    this.invalidate();
  }
  get memory(): AtlasMap["memory"] {
    type Stats = AtlasMap["memory"]["labels"];
    const sum = (items: Stats[]): Stats =>
      items.reduce(
        (a, b) => ({
          bytes: a.bytes + b.bytes,
          budget: a.budget + b.budget,
          entries: a.entries + b.entries,
          evictions: a.evictions + b.evictions,
          overage: a.overage + b.overage,
        }),
        { bytes: 0, budget: 0, entries: 0, evictions: 0, overage: 0 },
      );
    const sessions = [...this.sessions.values()],
      readers = sessions.map((s) => s.source.reader.memory);
    const decoded = Object.fromEntries(
      Object.keys(DEFAULT_DECODED_BUDGETS).map((k) => [
        k,
        sum(readers.map((r) => r.decoded[k as keyof typeof r.decoded])),
      ]),
    ) as AtlasMap["memory"]["reader"]["decoded"];
    return {
      reader: { decoded, directory: sum(readers.map((r) => r.directory)) },
      meshes: {
        ...sum(sessions.map((s) => s.scheduler.memory)),
        uploads: sessions.reduce((n, s) => n + s.scheduler.memory.uploads, 0),
        prepareMs: sessions.reduce(
          (n, s) => n + s.scheduler.memory.prepareMs,
          0,
        ),
      },
      labels: sum(sessions.map((s) => s.labels.memory)),
    };
  }
  private renderer!: Renderer;
  private sessions = new Map<string, Session>();
  private sources: CompositionMapSource[] = [];
  private input: MapInput;
  private observer: ResizeObserver;
  private frame = 0;
  private serial = 0;
  private revision = 0;
  private planned = "";
  private planRevision = 0;
  private dead = false;
  private style = PAPER;
  private overlay: HTMLCanvasElement;
  center: [number, number] = [0.5, 0.5];
  zoom = 4;
  pitch = 0;
  bearing = 0;
  coverage = true;
  constructor(
    readonly canvas: HTMLCanvasElement,
    private onStats: (stats: CompositionStats) => void,
    private onError: (id: string, error: string) => void,
  ) {
    this.overlays.remove("labels");
    this.overlays.remove("tile-grid");
    this.overlays.remove("cache-coverage");
    for (const id of [
      "labels",
      "routing-cells",
      "reachability",
      "selection",
      "route",
      "location",
      "peak",
      "scale",
    ])
      this.debugOverlays.remove(id);
    this.overlay = document.createElement("canvas");
    this.overlay.className = "composition-overlay";
    canvas.after(this.overlay);
    this.input = new MapInput(canvas, {
      pan: (x, y) => {
        const d = groundDelta(x, y, this.pitch, this.bearing),
          w = 256 * 2 ** this.zoom;
        this.center = [this.center[0] - d[0] / w, this.center[1] - d[1] / w];
        this.invalidate();
      },
      orbit: (x, y) => {
        this.bearing = (this.bearing + x * 0.35) % 360;
        this.pitch = Math.max(0, Math.min(60, this.pitch - y * 0.25));
        this.setScene({
          ...this.scene,
          pitch: this.pitch,
          bearing: this.bearing,
        });
      },
      zoom: (delta, x, y) => this.zoomBy(delta, x, y),
      click: (x, y) => {
        if (this.picker) {
          this.picker.choose(unproject(...screenToWorld([x, y], this.camera)));
          return;
        }
        const feature = this.featureOverlay.pick(
          this.overlay.getContext("2d")!,
          x,
          y,
        );
        if (feature) {
          this.onFeatureSelect?.(feature);
          return;
        }
        if (!this.pois) return;
        const world = screenToWorld([x, y], this.camera);
        for (const s of this.sessions.values()) {
          const mask = s.tiles.parts.get("labels");
          if (!mask || !contains(mask, world)) continue;
          const poi = s.labels.pick(x, y);
          if (poi) {
            this.onPoiSelect?.(poi);
            break;
          }
        }
      },
      command: (command) => {
        if (command === "reset") {
          this.resetView();
          return;
        }
        const picker = this.picker;
        if (!picker) return;
        if (command === "cancel") {
          this.endPicking(picker.owner);
          picker.cancel();
        } else if (command === "finish" && picker.finish) picker.finish();
        else picker.choose(unproject(...this.center));
      },
    });
    this.observer = new ResizeObserver(() => this.invalidate());
    this.observer.observe(canvas);
  }
  static async create(
    canvas: HTMLCanvasElement,
    stats: (s: CompositionStats) => void,
    error: (id: string, e: string) => void,
  ) {
    // The caller initializes the renderer with its chosen WASM source.
    const map = new CompositionMap(canvas, stats, error);
    map.renderer = new Renderer(canvas);
    map.setStyle(PAPER);
    return map;
  }
  get camera(): Camera {
    return {
      center: this.center,
      zoom: this.zoom,
      width: this.canvas.clientWidth,
      height: this.canvas.clientHeight,
      pitch: this.pitch,
      bearing: this.bearing,
    };
  }
  setStyle(style: MapStyle) {
    const p = compileStyle(style);
    this.style = p.style;
    this.renderer.set_style(p.background, p.colors, p.widths);
    this.invalidate();
  }
  reset() {
    this.resetView();
  }
  zoomBy(delta: number, x = 0, y = 0) {
    const before = 2 ** this.zoom;
    this.zoom = Math.max(0, Math.min(20, this.zoom + delta));
    const d = groundDelta(x, y, this.pitch, this.bearing),
      change = 1 / (256 * before) - 1 / (256 * 2 ** this.zoom);
    this.center = [
      this.center[0] + d[0] * change,
      this.center[1] + d[1] * change,
    ];
    this.invalidate();
  }
  fit(ids?: string[]) {
    const sources = this.sources.filter(
      (s) => s.enabled && (!ids || ids.includes(s.id)),
    );
    if (!sources.length) return;
    const boxes = sources.map((s) => coverageBounds(coverageOf(s.manifest)));
    const b: [number, number, number, number] = [
      Math.min(...boxes.map((b) => b[0])),
      Math.min(...boxes.map((b) => b[1])),
      Math.max(...boxes.map((b) => b[2])),
      Math.max(...boxes.map((b) => b[3])),
    ];
    this.center = [(b[0] + b[2]) / 2, (b[1] + b[3]) / 2];
    this.zoom = Math.max(
      0,
      Math.min(
        19,
        fitZoom(
          b,
          this.canvas.clientWidth - 100,
          this.canvas.clientHeight - 100,
          this.pitch,
          this.bearing,
        ),
      ),
    );
    this.invalidate();
  }
  setSources(sources: CompositionMapSource[]) {
    this.sources = sources.filter((s) => s.enabled);
    for (const [id, session] of this.sessions)
      if (!this.sources.some((s) => s.id === id)) {
        session.dead = true;
        session.placesView?.destroy();
        session.scheduler.destroy();
        session.raster.destroy();
        session.labels.destroy();
        this.sessions.delete(id);
      }
    for (const source of this.sources) {
      const existing = this.sessions.get(source.id);
      if (existing) {
        existing.source = source;
        continue;
      }
      const prefix = `${++this.serial}|`,
        tiles = new CoveredTiles(source.reader);
      const session = {} as Session;
      Object.assign(session, {
        source,
        tiles,
        prefix,
        shown: [],
        clips: new Map(),
        placesLoading: false,
        placesFailed: false,
        dead: false,
      });
      session.raster = new RasterPlane(
        source.reader,
        this.overlay,
        () => this.invalidate(),
        (e) => this.onError(source.id, e),
      );
      session.labels = new MapLabels(
        source.reader,
        () => this.invalidate(),
        (e) => this.onError(source.id, e),
        2 * 1024 * 1024,
      );
      session.labels.roads = this.streetNames;
      session.labels.pois = this.pois;
      session.scheduler = new TileScheduler(
        tiles,
        {
          upload: (b, bytes) => {
            const k = prefix + b.key;
            this.renderer.add_tile_part(k, b.z!, b.x!, b.y!, bytes);
            return this.renderer.tile_bytes(k);
          },
          remove: (k) => this.renderer.remove_tile(prefix + k),
          show: (keys) => {
            session.shown = keys;
          },
        },
        () => this.invalidate(),
        (e) => this.onError(source.id, e),
        12 * 1024 * 1024,
      );
      this.sessions.set(source.id, session);
    }
    // Earlier sources paint above later sources, also after a priority reorder.
    for (const source of [...this.sources].reverse())
      this.sessions.get(source.id)?.raster.placeBefore(this.overlay);
    this.revision++;
    this.planned = "";
    this.invalidate();
  }
  private plan() {
    const key = `${this.revision}/${Math.floor(this.zoom)}`;
    if (key === this.planned) return;
    this.planned = key;
    this.planRevision++;
    this.renderer.clear_clips();
    for (const s of this.sessions.values()) {
      s.tiles.parts.clear();
      s.clips.clear();
    }
    let id = 0;
    for (const part of planComposition(
      this.sources.map((s) => ({
        ...s,
        layers: s.layers.filter((l) => this.globalLayers.includes(l)),
        labels: s.labels && (this.placeNames || this.streetNames || this.pois),
      })),
      Math.floor(this.zoom),
    )) {
      const s = this.sessions.get(part.source)!;
      s.tiles.parts.set(part.channel, part.coverage);
      if (part.channel !== "labels") {
        const a = clipArrays(part.coverage);
        this.renderer.set_clip(++id, a.coordinates, a.rings, a.polygons);
        s.clips.set(part.channel, id);
      }
    }
  }
  invalidate() {
    if (!this.dead && !this.frame)
      this.frame = requestAnimationFrame(() => {
        this.frame = 0;
        if (this.renderer) this.draw();
      });
  }
  private draw() {
    this.plan();
    const camera = this.camera,
      { width, height } = camera,
      ratio = Math.min(devicePixelRatio, 2);
    for (const c of [this.canvas, this.overlay])
      if (
        c.width !== Math.round(width * ratio) ||
        c.height !== Math.round(height * ratio)
      ) {
        c.width = Math.round(width * ratio);
        c.height = Math.round(height * ratio);
      }
    const ctx = this.overlay.getContext("2d")!;
    ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
    ctx.clearRect(0, 0, width, height);
    const view = cameraBounds(camera),
      visible: string[] = [],
      stats: CompositionStats = {
        zoom: this.zoom,
        tiles: 0,
        wanted: 0,
        fallbackTiles: 0,
        drawMs: 0,
        labels: { roads: 0, pois: 0, loading: 0, missing: 0, places: 0 },
        missing: 0,
        loading: 0,
        sources: 0,
        triangles: 0,
        bytes: 0,
      };
    const frames: OverlayFrame[] = [];
    for (const s of this.sessions.values()) {
      s.raster.draw(
        camera,
        ratio,
        s.source.rasterOpacity ?? 0.6,
        s.source.rasterLayers,
      );
      const b = coverageBounds(coverageOf(s.source.manifest));
      const w = Math.max(view[0], b[0]),
        north = Math.max(view[1], b[1]),
        e = Math.min(view[2], b[2]),
        south = Math.min(view[3], b[3]);
      const active =
        w < e && north < south && this.zoom >= s.source.manifest.minZoom;
      const z = Math.max(
          s.source.manifest.minZoom,
          Math.min(s.source.manifest.maxZoom, Math.floor(this.zoom)),
        ),
        n = 2 ** z;
      const layers = active ? ([...s.clips.keys()] as MapLayer[]) : [];
      const result = s.scheduler.update({
        revision: this.planRevision,
        range: [
          z,
          Math.max(0, Math.floor(w * n)),
          Math.min(n - 1, Math.floor(e * n)),
          Math.max(0, Math.floor(north * n)),
          Math.min(n - 1, Math.floor(south * n)),
        ],
        layers,
        bounds: view,
      });
      for (const k of s.shown) {
        const clip = s.clips.get(tileLayer(k)!);
        if (clip !== undefined) {
          this.renderer.set_tile_clip(s.prefix + k, clip);
          visible.push(s.prefix + k);
        }
      }
      frames.push({
        ctx,
        camera,
        style: this.style,
        view: result.view,
        resident: (k) => s.scheduler.has(k),
        cached: (k) =>
          !!this.storedKeys.get(s.source.manifest.snapshot)?.has(k) ||
          s.source.reader.cache.has(k) ||
          s.scheduler.has(k),
        labels: s.labels,
        features: this.featureOverlay,
        places: [],
        placesLoading: false,
        placesMissing: false,
        state: this.state,
        labelStats: { roads: 0, pois: 0, loading: 0, missing: 0 },
      });
      stats.wanted += result.wanted + s.raster.stats.wanted;
      stats.fallbackTiles += result.presented.filter(
        (b) => b.z !== result.view.zoom,
      ).length;
      stats.tiles += result.tiles + s.raster.stats.tiles;
      stats.missing += result.missing + s.raster.stats.missing;
      stats.loading += result.loading + s.raster.stats.loading;
      stats.bytes += s.source.reader.bytesRead;
      if (active && (layers.length || s.tiles.parts.has("labels")))
        stats.sources++;
    }
    this.renderer.set_visible(visible.join(","));
    // Ground-plane composition: coverage masks have one unambiguous boundary.
    const drawStart = performance.now();
    stats.triangles = this.renderer.render(
      ...this.center,
      this.zoom,
      width,
      height,
      ratio,
      this.pitch,
      this.bearing,
      0,
      12,
      0,
    );
    stats.drawMs = performance.now() - drawStart;
    for (const f of frames) this.debugOverlays.draw(f);
    this.featureOverlay.draw(ctx, camera);
    const occupied: [number, number, number, number][] = [],
      toScreen = worldProjector(camera);
    for (const source of this.sources) {
      const s = this.sessions.get(source.id)!;
      const mask = s.tiles.parts.get("labels");
      const b = mask && coverageBounds(mask);
      if (
        mask &&
        b &&
        b[0] < view[2] &&
        b[2] > view[0] &&
        b[1] < view[3] &&
        b[3] > view[1]
      ) {
        const accepts = (box: [number, number, number, number]) =>
          [
            [box[0], box[1]],
            [box[2], box[1]],
            [box[2], box[3]],
            [box[0], box[3]],
          ].every((p) =>
            contains(mask, screenToWorld(p as [number, number], camera)),
          );
        if (this.placeNames) {
          s.placesView ??= new PlacesView(
            source.reader,
            () => this.invalidate(),
            (message) => this.onError(source.id, message),
          );
          s.places = s.placesView.update(view, this.zoom);
          s.placesLoading = s.placesView.loading;
        }
        ctx.font = '500 12px "Avenir Next", sans-serif';
        ctx.textAlign = "center";
        for (const place of this.placeNames ? (s.places ?? []) : []) {
          const [x, y] = toScreen(project(...place.coordinates)),
            half = ctx.measureText(place.name).width / 2 + 4;
          const box: [number, number, number, number] = [
            x - half,
            y - 13,
            x + half,
            y + 5,
          ];
          if (
            x < 20 ||
            y < 20 ||
            x > width - 20 ||
            y > height - 20 ||
            !accepts(box) ||
            occupied.some(
              (b) =>
                b[0] < box[2] &&
                b[2] > box[0] &&
                b[1] < box[3] &&
                b[3] > box[1],
            )
          )
            continue;
          ctx.lineWidth = 3;
          ctx.strokeStyle = this.style.labels.halo;
          ctx.fillStyle = this.style.labels.color;
          ctx.strokeText(place.name, x, y);
          ctx.fillText(place.name, x, y);
          occupied.push(box);
          stats.labels.places!++;
        }
        const labels = s.labels.draw(
          ctx,
          camera,
          this.style.labels,
          occupied,
          accepts,
        );
        stats.labels.roads += labels.roads;
        stats.labels.pois += labels.pois;
        stats.labels.loading += labels.loading;
        stats.labels.missing += labels.missing;
        stats.loading += labels.loading + (s.placesLoading ? 1 : 0);
        stats.missing += labels.missing;
      }
      if (this.coverage) {
        ctx.strokeStyle = source.color;
        ctx.fillStyle = source.color;
        ctx.lineWidth = 2;
        ctx.setLineDash([6, 4]);
        const poly = coverageOf(source.manifest);
        for (const rings of polygonsOf(poly))
          for (const ring of rings) {
            ctx.beginPath();
            ring.forEach((p, i) => {
              const [x, y] = toScreen(p as [number, number]);
              i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
            });
            ctx.closePath();
            ctx.stroke();
          }
        ctx.setLineDash([]);
      }
    }
    if (frames[0]) this.overlays.draw(frames[0]);
    this.onStats(stats);
  }
  destroy() {
    if (this.dead) return;
    if (this.picker) this.endPicking(this.picker.owner);
    this.overlays.destroy();
    this.debugOverlays.destroy();
    this.dead = true;
    cancelAnimationFrame(this.frame);
    this.input.destroy();
    this.observer.disconnect();
    for (const s of this.sessions.values()) {
      s.dead = true;
      s.placesView?.destroy();
      s.scheduler.destroy();
      s.raster.destroy();
      s.labels.destroy();
    }
    this.renderer.free();
    this.overlay.remove();
  }
}