@mailwoman/map-tui 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/renderer.ts ADDED
@@ -0,0 +1,297 @@
1
+ /**
2
+ * @copyright Sister Software.
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ */
6
+
7
+ /**
8
+ * Viewport-to-braille-frame renderer for map-tui's debug view.
9
+ *
10
+ * `MapRenderer.renderFrame` is the package's single public entry point: given a `Viewport` (center lon/lat, zoom, cell
11
+ * columns/rows), it fetches the covering tiles from a `TileSource`, rasterizes styled geometry (./style.ts,
12
+ * ./raster.ts) into a subpixel RGBA grid, converts that grid to braille cells (./frame.ts), then overlays collected
13
+ * labels and marker/ring annotations on top. Draw order is fill → line → label per the layer style table, with overlays
14
+ * (ring, labels, markers) layered afterward in that order — markers deliberately skip the label collision bitmap so a
15
+ * requested marker always wins the cell.
16
+ */
17
+
18
+ import { type MapFrame, overlayText, rasterizeToFrame, rgbToPacked } from "./frame.ts"
19
+ import { lonLatToWorldPx, metersPerPixel, TILE_SIZE } from "./mercator.ts"
20
+ import type { DecodedFeature } from "./mvt.ts"
21
+ import { drawCircle, drawPolyline, fillPolygon, RGBAGrid } from "./raster.ts"
22
+ import { type LayerStyle, type RGB, stylesFor } from "./style.ts"
23
+ import type { DecodedTile, TileSource } from "./tile-source.ts"
24
+
25
+ export interface Viewport {
26
+ centerLon: number
27
+ centerLat: number
28
+ zoom: number
29
+ columns: number
30
+ rows: number
31
+ }
32
+
33
+ export interface MarkerSpec {
34
+ lon: number
35
+ lat: number
36
+ char?: string
37
+ color?: RGB
38
+ }
39
+
40
+ export interface RingSpec {
41
+ lon: number
42
+ lat: number
43
+ radiusMeters: number
44
+ }
45
+
46
+ const DEFAULT_MARKER_CHAR = "●"
47
+ const DEFAULT_MARKER_COLOR: RGB = [255, 80, 80]
48
+
49
+ /**
50
+ * Subpixel dimensions per braille cell: 2 columns wide, 4 rows tall.
51
+ */
52
+ const SUBPIXEL_COLUMNS_PER_CELL = 2
53
+ const SUBPIXEL_ROWS_PER_CELL = 4
54
+
55
+ /**
56
+ * Minimum ring radius (in device pixels) worth drawing — smaller than this, the midpoint circle algorithm degenerates
57
+ * to a single point or nothing useful.
58
+ */
59
+ const MIN_RING_RADIUS_PX = 2
60
+
61
+ interface ProjectedPoint {
62
+ x: number
63
+ y: number
64
+ }
65
+
66
+ interface PendingLabel {
67
+ text: string
68
+ column: number
69
+ row: number
70
+ color: number
71
+ }
72
+
73
+ /**
74
+ * Origin of the render's subpixel grid, in world (Mercator) pixels — everything projected onto the grid is offset by
75
+ * this pair.
76
+ */
77
+ interface GridOrigin {
78
+ x: number
79
+ y: number
80
+ }
81
+
82
+ /**
83
+ * Everything a point projection needs, bundled so the per-feature rasterizers below take one parameter instead of four
84
+ * — that's what keeps `rasterizeFeature` under `max-params` (8) once `style`, `renderZoom`, and `pendingLabels` join
85
+ * it. Threaded through as a value rather than closed over so those rasterizers stay free functions (no nesting inside
86
+ * `renderFrame` deep enough to trip `max-depth`).
87
+ */
88
+ interface TileProjection {
89
+ tileX: number
90
+ tileY: number
91
+ scale: number
92
+ origin: GridOrigin
93
+ }
94
+
95
+ function clamp(value: number, min: number, max: number): number {
96
+ return Math.min(Math.max(value, min), max)
97
+ }
98
+
99
+ function projectPoint(projection: TileProjection, gx: number, gy: number): ProjectedPoint {
100
+ return {
101
+ x: projection.tileX * TILE_SIZE + gx * projection.scale - projection.origin.x,
102
+ y: projection.tileY * TILE_SIZE + gy * projection.scale - projection.origin.y,
103
+ }
104
+ }
105
+
106
+ function rasterizeFill(grid: RGBAGrid, feature: DecodedFeature, projection: TileProjection, color: RGB): void {
107
+ const rings = feature.geometry.map((ring) => ring.map((point) => projectPoint(projection, point.x, point.y)))
108
+
109
+ fillPolygon(grid, rings, color)
110
+ }
111
+
112
+ function rasterizeLine(
113
+ grid: RGBAGrid,
114
+ feature: DecodedFeature,
115
+ projection: TileProjection,
116
+ color: RGB,
117
+ width: number
118
+ ): void {
119
+ for (const part of feature.geometry) {
120
+ const points = part.map((point) => projectPoint(projection, point.x, point.y))
121
+
122
+ drawPolyline(grid, points, color, width)
123
+ }
124
+ }
125
+
126
+ function collectLabels(
127
+ feature: DecodedFeature,
128
+ property: string,
129
+ color: RGB,
130
+ projection: TileProjection,
131
+ pendingLabels: PendingLabel[]
132
+ ): void {
133
+ const text = String(feature.properties[property] ?? "")
134
+
135
+ if (!text.length) return
136
+
137
+ const packedColor = rgbToPacked(color)
138
+
139
+ for (const part of feature.geometry) {
140
+ for (const point of part) {
141
+ const projected = projectPoint(projection, point.x, point.y)
142
+
143
+ pendingLabels.push({
144
+ text,
145
+ column: Math.floor(projected.x / SUBPIXEL_COLUMNS_PER_CELL),
146
+ row: Math.floor(projected.y / SUBPIXEL_ROWS_PER_CELL),
147
+ color: packedColor,
148
+ })
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Rasterizes (or, for labels, collects) one feature under one style. The `style.kind` dispatch is the only branching
155
+ * here — the actual per-kind work lives in {@link rasterizeFill}/{@link rasterizeLine}/{@link collectLabels} so this
156
+ * stays a flat one-level `if`/`else` regardless of how deeply its caller is already nested.
157
+ */
158
+ function rasterizeFeature(
159
+ grid: RGBAGrid,
160
+ feature: DecodedFeature,
161
+ style: LayerStyle,
162
+ renderZoom: number,
163
+ projection: TileProjection,
164
+ pendingLabels: PendingLabel[]
165
+ ): void {
166
+ if (style.kind === "fill") {
167
+ rasterizeFill(grid, feature, projection, style.color)
168
+ } else if (style.kind === "line") {
169
+ rasterizeLine(grid, feature, projection, style.color, style.width(renderZoom))
170
+ } else {
171
+ collectLabels(feature, style.property, style.color, projection, pendingLabels)
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Rasterizes every layer/style/feature in one tile matching `kind`. Pulled out of {@link MapRenderer.renderFrame} so
177
+ * that method's own tile loop doesn't accumulate this function's three nested loops on top of its own.
178
+ */
179
+ function rasterizeTileForKind(
180
+ grid: RGBAGrid,
181
+ tile: DecodedTile,
182
+ tileX: number,
183
+ tileY: number,
184
+ kind: LayerStyle["kind"],
185
+ renderZoom: number,
186
+ origin: GridOrigin,
187
+ pendingLabels: PendingLabel[]
188
+ ): void {
189
+ for (const layer of tile.layers) {
190
+ const styles = stylesFor(layer.name, renderZoom).filter((style) => style.kind === kind)
191
+
192
+ if (!styles.length) continue
193
+
194
+ const projection: TileProjection = { tileX, tileY, scale: TILE_SIZE / layer.extent, origin }
195
+
196
+ for (const style of styles) {
197
+ for (const feature of layer.features) {
198
+ rasterizeFeature(grid, feature, style, renderZoom, projection, pendingLabels)
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Walks a viewport's rendering pipeline: tile fetch, style-ordered rasterization, braille conversion, then overlay
206
+ * annotations (ring, labels, markers). One `MapRenderer` can render any number of viewports against the same
207
+ * `TileSource` — it holds no per-frame state itself.
208
+ */
209
+ export class MapRenderer {
210
+ private readonly source: TileSource
211
+
212
+ constructor(source: TileSource) {
213
+ this.source = source
214
+ }
215
+
216
+ async renderFrame(viewport: Viewport, overlays?: { markers?: MarkerSpec[]; ring?: RingSpec }): Promise<MapFrame> {
217
+ const { centerLon, centerLat, columns, rows } = viewport
218
+
219
+ const subpixelW = columns * SUBPIXEL_COLUMNS_PER_CELL
220
+ const subpixelH = rows * SUBPIXEL_ROWS_PER_CELL
221
+
222
+ const renderZoom = clamp(Math.round(viewport.zoom), this.source.minZoom, this.source.maxZoom)
223
+ const center = lonLatToWorldPx(centerLon, centerLat, renderZoom)
224
+ const origin: GridOrigin = { x: center.x - subpixelW / 2, y: center.y - subpixelH / 2 }
225
+
226
+ const zoomTileCount = 2 ** renderZoom
227
+ const minTileX = clamp(Math.floor(origin.x / TILE_SIZE), 0, zoomTileCount - 1)
228
+ const maxTileX = clamp(Math.floor((origin.x + subpixelW) / TILE_SIZE), 0, zoomTileCount - 1)
229
+ const minTileY = clamp(Math.floor(origin.y / TILE_SIZE), 0, zoomTileCount - 1)
230
+ const maxTileY = clamp(Math.floor((origin.y + subpixelH) / TILE_SIZE), 0, zoomTileCount - 1)
231
+
232
+ const tileCoords: Array<{ x: number; y: number }> = []
233
+
234
+ for (let tileY = minTileY; tileY <= maxTileY; tileY++) {
235
+ for (let tileX = minTileX; tileX <= maxTileX; tileX++) {
236
+ tileCoords.push({ x: tileX, y: tileY })
237
+ }
238
+ }
239
+
240
+ const tiles = await Promise.all(tileCoords.map((coord) => this.source.getTile(renderZoom, coord.x, coord.y)))
241
+
242
+ const grid = new RGBAGrid(subpixelW, subpixelH)
243
+ const pendingLabels: PendingLabel[] = []
244
+
245
+ for (const kind of ["fill", "line", "label"] as const) {
246
+ for (let i = 0; i < tileCoords.length; i++) {
247
+ const tile = tiles[i]
248
+
249
+ if (!tile) continue
250
+
251
+ const { x: tileX, y: tileY } = tileCoords[i]!
252
+
253
+ rasterizeTileForKind(grid, tile, tileX, tileY, kind, renderZoom, origin, pendingLabels)
254
+ }
255
+ }
256
+
257
+ if (overlays?.ring) {
258
+ const { lon, lat, radiusMeters } = overlays.ring
259
+ const radiusPx = radiusMeters / metersPerPixel(lat, renderZoom)
260
+
261
+ if (radiusPx >= MIN_RING_RADIUS_PX) {
262
+ const ringCenterWorld = lonLatToWorldPx(lon, lat, renderZoom)
263
+ const ringColor: RGB = [255, 255, 255]
264
+
265
+ drawCircle(grid, ringCenterWorld.x - origin.x, ringCenterWorld.y - origin.y, radiusPx, ringColor)
266
+ }
267
+ }
268
+
269
+ const frame = rasterizeToFrame(grid, columns, rows, this.source.attribution)
270
+
271
+ const occupied = new Uint8Array(columns * rows)
272
+
273
+ for (const label of pendingLabels) {
274
+ overlayText(frame, label.column, label.row, label.text, label.color, occupied)
275
+ }
276
+
277
+ if (overlays?.markers) {
278
+ for (const marker of overlays.markers) {
279
+ const markerWorld = lonLatToWorldPx(marker.lon, marker.lat, renderZoom)
280
+ const px = markerWorld.x - origin.x
281
+ const py = markerWorld.y - origin.y
282
+ const column = Math.floor(px / SUBPIXEL_COLUMNS_PER_CELL)
283
+ const row = Math.floor(py / SUBPIXEL_ROWS_PER_CELL)
284
+
285
+ overlayText(
286
+ frame,
287
+ column,
288
+ row,
289
+ marker.char ?? DEFAULT_MARKER_CHAR,
290
+ rgbToPacked(marker.color ?? DEFAULT_MARKER_COLOR)
291
+ )
292
+ }
293
+ }
294
+
295
+ return frame
296
+ }
297
+ }
package/style.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @copyright Sister Software.
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ */
6
+
7
+ /**
8
+ * Protomaps-basemap style table for the map-tui debug view.
9
+ *
10
+ * Defines fill, line, and label styles for each of the nine protomaps-basemap layers, gated by zoom level. Color
11
+ * palette calibrated for dark-terminal rendering: dim fills (read as stipple density via dithering), bright lines and
12
+ * labels.
13
+ */
14
+
15
+ export type RGB = readonly [red: number, green: number, blue: number]
16
+
17
+ export interface FillStyle {
18
+ kind: "fill"
19
+ color: RGB
20
+ minZoom: number
21
+ }
22
+
23
+ export interface LineStyle {
24
+ kind: "line"
25
+ color: RGB
26
+ minZoom: number
27
+ width: (zoom: number) => number
28
+ }
29
+
30
+ export interface LabelStyle {
31
+ kind: "label"
32
+ color: RGB
33
+ minZoom: number
34
+ property: string
35
+ }
36
+
37
+ export type LayerStyle = FillStyle | LineStyle | LabelStyle
38
+
39
+ // Zoom level at which roads render with 2px width instead of 1px.
40
+ const ROAD_WIDTH_THRESHOLD = 14
41
+
42
+ const STYLE_TABLE: Record<string, LayerStyle[]> = {
43
+ earth: [{ kind: "fill", color: [40, 44, 36], minZoom: 0 }],
44
+ landcover: [{ kind: "fill", color: [36, 52, 32], minZoom: 4 }],
45
+ landuse: [{ kind: "fill", color: [48, 48, 40], minZoom: 10 }],
46
+ water: [{ kind: "fill", color: [24, 48, 90], minZoom: 0 }],
47
+ buildings: [{ kind: "fill", color: [70, 66, 60], minZoom: 13 }],
48
+ boundaries: [{ kind: "line", color: [140, 110, 160], minZoom: 0, width: () => 1 }],
49
+ roads: [
50
+ { kind: "line", color: [170, 170, 150], minZoom: 6, width: (zoom) => (zoom >= ROAD_WIDTH_THRESHOLD ? 2 : 1) },
51
+ ],
52
+ places: [{ kind: "label", color: [235, 235, 220], minZoom: 2, property: "name" }],
53
+ pois: [{ kind: "label", color: [180, 200, 160], minZoom: 14, property: "name" }],
54
+ }
55
+
56
+ /**
57
+ * Styles applying to a protomaps-basemap layer at a zoom, draw-ordered (fills < lines < labels). Empty for
58
+ * unstyled/gated layers.
59
+ */
60
+ export function stylesFor(layerName: string, zoom: number): LayerStyle[] {
61
+ return (STYLE_TABLE[layerName] ?? []).filter((style) => zoom >= style.minZoom)
62
+ }
package/tile-source.ts ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * @copyright Sister Software.
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ */
6
+
7
+ /**
8
+ * PMTiles archive reader for map-tui.
9
+ *
10
+ * TileSource wraps a local `.pmtiles` file (node:fs/promises FileHandle) behind the pmtiles `Source` interface, decodes
11
+ * each requested tile's MVT payload via ./mvt.ts, and keeps a small LRU cache of decoded tiles so repeated draws of the
12
+ * same viewport don't re-decode.
13
+ */
14
+
15
+ import { type FileHandle, open } from "node:fs/promises"
16
+
17
+ import { PMTiles, type RangeResponse, type Source } from "pmtiles"
18
+
19
+ import { type DecodedLayer, decodeMVT } from "./mvt.ts"
20
+
21
+ export interface DecodedTile {
22
+ layers: DecodedLayer[]
23
+ }
24
+
25
+ class FilePMTilesSource implements Source {
26
+ private readonly path: string
27
+ private readonly handle: FileHandle
28
+
29
+ constructor(path: string, handle: FileHandle) {
30
+ this.path = path
31
+ this.handle = handle
32
+ }
33
+
34
+ getKey(): string {
35
+ return this.path
36
+ }
37
+
38
+ async getBytes(offset: number, length: number): Promise<RangeResponse> {
39
+ const buffer = Buffer.alloc(length)
40
+ const { bytesRead } = await this.handle.read(buffer, 0, length, offset)
41
+
42
+ return { data: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + bytesRead) }
43
+ }
44
+ }
45
+
46
+ const TILE_CACHE_LIMIT = 64
47
+
48
+ /**
49
+ * A single LRU cache slot. Wrapping the decoded tile in an object lets `getTile` tell "cached and known absent" (`{
50
+ * tile: null }`) apart from "not yet cached" (no entry in the Map) using plain presence, with no comparison against
51
+ * `undefined` needed.
52
+ */
53
+ interface CacheEntry {
54
+ tile: DecodedTile | null
55
+ }
56
+
57
+ export class TileSource {
58
+ readonly minZoom: number
59
+ readonly maxZoom: number
60
+
61
+ /**
62
+ * Plain-text attribution from archive metadata (HTML tags stripped); empty string when absent.
63
+ */
64
+ readonly attribution: string
65
+
66
+ private readonly handle: FileHandle
67
+ private readonly pmtiles: PMTiles
68
+ private readonly cache = new Map<string, CacheEntry>()
69
+
70
+ private constructor(handle: FileHandle, pmtiles: PMTiles, minZoom: number, maxZoom: number, attribution: string) {
71
+ this.handle = handle
72
+ this.pmtiles = pmtiles
73
+ this.minZoom = minZoom
74
+ this.maxZoom = maxZoom
75
+ this.attribution = attribution
76
+ }
77
+
78
+ static async open(path: string): Promise<TileSource> {
79
+ const handle = await open(path, "r")
80
+ const pmtiles = new PMTiles(new FilePMTilesSource(path, handle))
81
+
82
+ const [header, metadata] = await Promise.all([pmtiles.getHeader(), pmtiles.getMetadata()])
83
+
84
+ const attribution =
85
+ typeof metadata === "object" &&
86
+ metadata !== null &&
87
+ "attribution" in metadata &&
88
+ typeof (metadata as { attribution: unknown }).attribution === "string"
89
+ ? (metadata as { attribution: string }).attribution.replaceAll(/<[^>]+>/gu, "").trim()
90
+ : ""
91
+
92
+ return new TileSource(handle, pmtiles, header.minZoom, header.maxZoom, attribution)
93
+ }
94
+
95
+ /**
96
+ * Decoded tile, LRU-cached (64 entries). null = tile absent from the archive.
97
+ */
98
+ async getTile(z: number, x: number, y: number): Promise<DecodedTile | null> {
99
+ const key = `${z}/${x}/${y}`
100
+ const cached = this.cache.get(key)
101
+
102
+ if (cached != null) {
103
+ // Refresh recency by re-inserting at the end of iteration order.
104
+ this.cache.delete(key)
105
+ this.cache.set(key, cached)
106
+
107
+ return cached.tile
108
+ }
109
+
110
+ const response = await this.pmtiles.getZxy(z, x, y)
111
+
112
+ const tile: DecodedTile | null = response != null ? { layers: decodeMVT(new Uint8Array(response.data)) } : null
113
+
114
+ this.cache.set(key, { tile })
115
+
116
+ if (this.cache.size > TILE_CACHE_LIMIT) {
117
+ const oldestKey = this.cache.keys().next().value
118
+
119
+ if (typeof oldestKey === "string") {
120
+ this.cache.delete(oldestKey)
121
+ }
122
+ }
123
+
124
+ return tile
125
+ }
126
+
127
+ async close(): Promise<void> {
128
+ await this.handle.close()
129
+ }
130
+ }