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.
import { RasterPlane } from "./raster";
import { PlacesView } from "../../labels/src/place-pages";
import { defaultOverlays } from "./overlays";
import { MapInput } from "./input";
import type { Reachable } from "../../routing/src/index";
import { TileScheduler } from "./tile-scheduler";
import { PLACE_LABEL_KEY, decodePlaces } from "../../labels/src/places";
import type { Place } from "../../format/src/index";
import {
PAPER,
FLAT_SCENE,
compileStyle,
parseScene,
type MapStyle,
type MapScene,
} from "./style";
import {
cameraBounds,
fitZoom,
groundDelta,
metersPerPixel,
screenToWorld,
type Camera,
} from "./camera";
import { FeatureOverlay } from "./features";
import type { MapFeature, Bounds } from "../../features/src/index";
import { MapLabels, type LabelStats } from "./labels";
import type { Location } from "../../geocoding/src/model";
import init, { Renderer } from "../pkg/atlas_renderer";
import { AtlasReader } from "../../format/src/reader";
import {
MAP_LAYERS,
LAYER_MASKS,
project,
unproject,
type Peak,
type MapLayer,
} from "../../format/src/index";
import type { Position, WalkingResult } from "../../routing/src/index";
export const MAX_VIEW_ZOOM = 20;
export interface MapOptions {
style?: MapStyle;
scene?: MapScene;
layers?: readonly MapLayer[];
placeNames?: boolean;
streetNames?: boolean;
pois?: boolean;
cacheBudgets?: { meshes?: number; labels?: number };
}
export interface MapStats {
zoom: number;
tiles: number;
wanted: number;
missing: number;
triangles: number;
fallbackTiles: number;
drawMs: number;
loading: number;
labels: LabelStats;
}
export class AtlasMap {
renderer!: Renderer;
readonly overlays = defaultOverlays();
private featureOverlay = new FeatureOverlay();
onFeatureSelect?: (id: string) => void;
setFeatures(features: MapFeature[], selected: Set<string>) {
this.featureOverlay.features = features;
this.featureOverlay.selected = new Set(selected);
this.invalidate();
}
private style = compileStyle(PAPER).style;
private scene: MapScene = { ...FLAT_SCENE };
get camera(): Camera {
return {
center: this.center,
zoom: this.zoom,
width: this.canvas.clientWidth,
height: this.canvas.clientHeight,
pitch: this.scene.pitch,
bearing: this.scene.bearing,
};
}
setStyle(style: MapStyle) {
const compiled = compileStyle(style);
this.renderer.set_style(
compiled.background,
compiled.colors,
compiled.widths,
);
this.style = compiled.style;
this.invalidate();
}
onSceneChange?: (scene: MapScene) => void;
setScene(scene: MapScene) {
this.scene = parseScene(scene);
this.onSceneChange?.({ ...this.scene });
this.invalidate();
}
viewBounds(): Bounds {
const [w, n, e, south] = cameraBounds(this.camera);
const sw = unproject(w, south),
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]),
];
}
center: [number, number] = [0.5, 0.5];
zoom = 10;
private reachable: Reachable | null = null;
setReachable(value: Reachable | null) {
this.reachable = value;
this.invalidate();
}
private routingCells: Array<{ key: string; state: string }> = [];
setRoutingCells(cells: Array<{ key: string; state: string }>) {
this.routingCells = cells;
this.invalidate();
}
grid = false;
private cacheView = false;
private storedKeys: ReadonlySet<string> = new Set();
setCacheView(
enabled: boolean,
storedKeys: ReadonlySet<string> = this.storedKeys,
) {
this.cacheView = enabled;
this.storedKeys = storedKeys;
this.invalidate();
}
selected: Peak | null = null;
onPoiSelect?: (poi: Location) => void;
private placeNames = true;
private placesView?: PlacesView;
private places: Place[] | undefined;
private placesLoading = false;
private placesFailed = false;
private labels: MapLabels;
private labelStats: LabelStats = {
roads: 0,
pois: 0,
loading: 0,
missing: 0,
};
setLabels(roads: boolean, pois: boolean, places = this.placeNames) {
this.placeNames = places;
this.labels.roads = roads;
this.labels.pois = pois;
this.invalidate();
}
onPickModeChange?: (hint: string | null) => void;
private selection: { points: Position[]; closed: boolean } | null = null;
setSelection(points: Position[], closed = true) {
this.selection = { points, closed };
this.invalidate();
}
private picker: {
owner: string;
choose: (position: Position) => void;
cancel: () => void;
finish?: () => void;
} | null = null;
private location: {
coordinates: Position;
name: string;
street?: Position[];
requested?: Position;
} | null = null;
private endpoints: [Position | null, Position | null] = [null, null];
private route: Extract<WalkingResult, { status: "found" }> | null = null;
private layerMask = 255;
private tiles!: TileScheduler;
private dead = false;
private frame = 0;
private input!: MapInput;
private observer: ResizeObserver;
private lastDraw = 0;
private triangles = 0;
private overlay: HTMLCanvasElement;
private raster: RasterPlane;
private constructor(
readonly canvas: HTMLCanvasElement,
readonly reader: AtlasReader,
readonly onStats: (stats: MapStats) => void,
readonly onError: (message: string) => void,
options: MapOptions,
) {
this.labels = new MapLabels(
reader,
() => this.invalidate(),
onError,
options.cacheBudgets?.labels,
);
this.overlay = document.createElement("canvas");
this.overlay.className = "map-overlay";
canvas.after(this.overlay);
this.raster = new RasterPlane(
reader,
this.overlay,
() => this.invalidate(),
onError,
);
this.observer = new ResizeObserver(() => this.invalidate());
this.observer.observe(canvas);
}
static async create(
canvas: HTMLCanvasElement,
reader: AtlasReader,
onStats: (stats: MapStats) => void,
onError: (message: string) => void,
options: MapOptions = {},
) {
await init({ module_or_path: "/wasm/atlas_renderer_bg.wasm" });
const map = new AtlasMap(canvas, reader, onStats, onError, options);
map.renderer = new Renderer(canvas);
map.tiles = new TileScheduler(
reader,
{
upload: (b, bytes) => {
map.renderer.add_tile_part(b.key, b.z!, b.x!, b.y!, bytes);
return map.renderer.tile_bytes(b.key);
},
remove: (key) => map.renderer.remove_tile(key),
show: (keys) => map.renderer.set_visible(keys.join(",")),
},
() => map.invalidate(),
onError,
options.cacheBudgets?.meshes,
);
map.setStyle(options.style ?? PAPER);
map.setScene(options.scene ?? FLAT_SCENE);
map.setLayers(options.layers ?? 255);
map.setLabels(
options.streetNames ?? true,
options.pois ?? true,
options.placeNames ?? true,
);
map.events();
map.fit();
return map;
}
private events() {
this.input = new MapInput(this.canvas, {
pan: (x, y) => {
const [dx, dy] = groundDelta(
x,
y,
this.scene.pitch,
this.scene.bearing,
);
const scale = 256 * 2 ** this.zoom;
this.center = [
this.center[0] - dx / scale,
this.center[1] - dy / scale,
];
this.invalidate();
},
orbit: (x, y) => this.orbit(x, y),
zoom: (delta, x, y) => this.zoomAt(this.zoom + 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;
}
const poi = this.labels.pick(x, y);
if (poi) this.onPoiSelect?.(poi);
},
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));
},
});
}
private orbit(dx: number, dy: number) {
const pitch = Math.max(0, Math.min(65, this.scene.pitch - dy * 0.35));
const bearing =
((((this.scene.bearing + dx * 0.3 + 540) % 360) + 360) % 360) - 180;
this.setScene({
...this.scene,
pitch,
bearing,
extrude: this.scene.extrude || pitch > 0,
});
}
resetView() {
this.setScene({ ...this.scene, pitch: 0, bearing: 0, extrude: false });
}
fit() {
const [w, s, e, n] = this.reader.index.manifest.bounds;
const a = project(w, n);
const b = project(e, s);
this.center = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
this.zoom = Math.max(
this.reader.index.manifest.minZoom,
Math.min(
this.reader.index.manifest.maxZoom,
fitZoom(
[a[0], a[1], b[0], b[1]],
this.canvas.clientWidth - 90,
this.canvas.clientHeight - 100,
this.scene.pitch,
this.scene.bearing,
),
),
);
this.selected = null;
this.invalidate();
}
beginPicking(
owner: string,
choose: (position: 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.location = { coordinates, name, street, requested };
this.selected = null;
this.center = project(...coordinates);
this.zoom = Math.max(
Math.max(0, this.reader.index.manifest.minZoom - 1),
Math.min(MAX_VIEW_ZOOM, zoom),
);
this.invalidate();
}
clearLocation() {
this.location = null;
this.invalidate();
}
setEndpoints(start: Position | null, end: Position | null) {
this.endpoints = [start, end];
this.route = null;
this.invalidate();
}
setRoute(route: Extract<WalkingResult, { status: "found" }> | null) {
this.route = route;
this.invalidate();
}
fitRoute() {
if (!this.route) return;
this.fitPositions([
...this.route.coordinates,
...this.endpoints.filter((p): p is Position => p !== null),
]);
}
fitPositions(positions: Position[]) {
if (!positions.length) return;
const points = positions.map((p) => project(...p));
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const [x, y] of points) {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
this.center = [(minX + maxX) / 2, (minY + maxY) / 2];
this.zoom = Math.max(
this.reader.index.manifest.minZoom,
Math.min(
this.reader.index.manifest.maxZoom + 1,
fitZoom(
[minX, minY, maxX, maxY],
this.canvas.clientWidth - 140,
this.canvas.clientHeight - 340,
this.scene.pitch,
this.scene.bearing,
),
),
);
this.selected = null;
this.invalidate();
}
zoomBy(delta: number) {
this.zoomAt(this.zoom + delta, 0, 0);
}
private zoomAt(zoom: number, x: number, y: number) {
zoom = Math.max(
Math.max(0, this.reader.index.manifest.minZoom - 1),
Math.min(MAX_VIEW_ZOOM, zoom),
);
[x, y] = groundDelta(x, y, this.scene.pitch, this.scene.bearing);
const before = 256 * 2 ** this.zoom;
const after = 256 * 2 ** zoom;
this.center = [
this.center[0] + x / before - x / after,
this.center[1] + y / before - y / after,
];
this.zoom = zoom;
this.invalidate();
}
focus(peak: Peak) {
this.location = null;
this.selected = peak;
this.center = project(...peak.coordinates);
this.zoom = Math.max(this.zoom, 13);
this.invalidate();
}
setLayers(layers: number | readonly MapLayer[]) {
const mask =
typeof layers === "number"
? layers
: layers.reduce((mask, l) => mask | LAYER_MASKS[l], 0);
this.layerMask = mask;
this.renderer.set_layers(mask);
this.invalidate();
}
setOffline(offline: boolean) {
this.reader.offline = offline;
this.retryLoads();
}
/** Retry missing data while preserving the camera, reader and resident meshes. */
retryLoads() {
if (this.dead) return;
this.tiles.retry();
this.raster.retry();
this.labels.retry();
this.placesFailed = false;
this.placesView?.retry();
this.invalidate();
}
setGrid(grid: boolean) {
this.grid = grid;
this.invalidate();
}
private visibleTiles() {
const { minZoom, maxZoom } = this.reader.index.manifest;
const z = Math.max(minZoom, Math.min(maxZoom, Math.floor(this.zoom)));
const n = 2 ** z;
// Include bases just outside the view whose roofs rise into it.
const padding =
((this.scene.extrude && (this.layerMask & 4) !== 0 && z >= 13
? this.scene.heightScale *
((this.reader.index.manifest.buildingHeights?.maxMeters ?? 0) +
this.scene.fallbackHeight)
: 0) /
metersPerPixel(this.camera)) *
Math.sin((this.scene.pitch * Math.PI) / 180);
const [w, north, e, south] = cameraBounds(this.camera, padding);
const range: [number, number, number, number, number] = [
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)),
];
const layers = MAP_LAYERS.filter(
(l) => (this.layerMask & LAYER_MASKS[l]) !== 0,
);
return {
range,
layers,
bounds: [w, north, e, south] as [number, number, number, number],
};
}
get memory() {
return {
reader: this.reader.memory,
meshes: this.tiles.memory,
labels: this.labels.memory,
};
}
invalidate() {
if (!this.frame && !this.dead)
this.frame = requestAnimationFrame(() => {
this.frame = 0;
this.update();
});
}
private update() {
if (this.dead) return;
const width = this.canvas.clientWidth;
const height = this.canvas.clientHeight;
const ratio = Math.min(window.devicePixelRatio || 1, 2);
if (width === 0 || height === 0) return;
for (const c of [this.canvas, this.overlay]) {
if (c.width !== Math.round(width * ratio))
c.width = Math.round(width * ratio);
if (c.height !== Math.round(height * ratio))
c.height = Math.round(height * ratio);
}
const state = this.tiles.update(this.visibleTiles());
this.raster.draw(this.camera, ratio);
const start = performance.now();
this.triangles = this.renderer.render(
...this.center,
this.zoom,
width,
height,
ratio,
this.scene.pitch,
this.scene.bearing,
this.scene.extrude ? this.scene.heightScale : 0,
this.scene.fallbackHeight,
this.scene.shading,
);
this.lastDraw = performance.now() - start;
this.drawOverlay(width, height, ratio, state.view);
this.onStats({
zoom: this.zoom,
tiles: state.tiles + this.raster.stats.tiles,
wanted: state.wanted + this.raster.stats.wanted,
missing: state.missing + this.raster.stats.missing,
triangles: this.triangles,
fallbackTiles: state.presented.filter((b) => b.z !== state.view.zoom)
.length,
drawMs: this.lastDraw,
loading: state.loading + this.raster.stats.loading,
labels: this.labelStats,
});
}
private visiblePlaces(): Place[] {
if (!this.placeNames) return [];
this.placesView ??= new PlacesView(
this.reader,
() => this.invalidate(),
(message) => this.onError(message),
);
const values = this.placesView.update(cameraBounds(this.camera), this.zoom);
this.placesLoading = this.placesView.loading;
this.places = values;
return values;
}
private drawOverlay(
width: number,
height: number,
ratio: number,
view: import("./tile-presentation").TileView,
) {
const ctx = this.overlay.getContext("2d")!;
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
ctx.clearRect(0, 0, width, height);
const frame = {
ctx,
camera: this.camera,
style: this.style,
view,
resident: (key: string) => this.tiles.has(key),
cached: (key: string) =>
this.storedKeys.has(key) ||
this.reader.cache.has(key) ||
this.tiles.has(key),
labels: this.labels,
features: this.featureOverlay,
places: this.visiblePlaces(),
placesLoading: this.placesLoading,
placesMissing: this.placeNames && (this.placesView?.missing ?? false),
state: {
routingCells: this.routingCells,
reachable: this.reachable,
selection: this.selection,
grid: this.grid,
cache: this.cacheView,
route: this.route,
endpoints: this.endpoints,
location: this.location,
selected: this.selected,
},
labelStats: { roads: 0, pois: 0, loading: 0, missing: 0 },
};
this.overlays.draw(frame);
this.labelStats = frame.labelStats;
}
destroy() {
if (this.picker) this.endPicking(this.picker.owner);
this.dead = true;
this.placesView?.destroy();
this.labels.destroy();
this.overlays.destroy();
this.tiles.destroy();
this.raster.destroy();
cancelAnimationFrame(this.frame);
this.input.destroy();
this.observer.disconnect();
this.renderer.clear();
this.renderer.free();
this.overlay.remove();
}
}
