@mailwoman/map-tui 9.1.1 → 9.2.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/README.md CHANGED
@@ -13,3 +13,62 @@ whether) to draw them.
13
13
  Tiles are never bundled with this package. Every render takes a caller-supplied
14
14
  PMTiles path (local file or remote archive), so callers own their own tile
15
15
  sourcing and coverage.
16
+
17
+ ## The browser
18
+
19
+ The package also ships `map-tui`, a full-screen interactive map for the
20
+ terminal — the library's own frames, driven by a keyboard and a mouse.
21
+
22
+ ```sh
23
+ npx @mailwoman/map-tui --tiles planet.pmtiles
24
+ ```
25
+
26
+ It opens on the alternate screen, so your scrollback is untouched, and puts the
27
+ terminal back exactly as it found it on the way out — including after `Ctrl+C`.
28
+
29
+ ### Keys
30
+
31
+ | Key | Does |
32
+ | -------------------- | ----------------------------- |
33
+ | `←` `↑` `↓` `→` | Pan an eighth of the viewport |
34
+ | `h` `j` `k` `l` | Pan, for vim hands |
35
+ | `+` `=` `a` | Zoom in one level |
36
+ | `-` `_` `z` | Zoom out one level |
37
+ | `q`, `Esc`, `Ctrl+C` | Quit |
38
+
39
+ ### Mouse
40
+
41
+ If your terminal reports mouse events, the wheel zooms toward the pointer,
42
+ dragging pans, and a click centers the map on the cell you clicked.
43
+
44
+ ### Flags
45
+
46
+ | Flag | Does |
47
+ | --------------------- | ------------------------------------------------------------------------------------------------------------ |
48
+ | `--tiles <path\|url>` | PMTiles archive — a local path or an `https://` URL read via range requests. Defaults to `$MAILWOMAN_TILES`. |
49
+ | `--lat <deg>` | Initial center latitude (default `0`). |
50
+ | `--lon <deg>` | Initial center longitude (default `0`). |
51
+ | `--zoom <level>` | Initial zoom, 0–24 (default `2` — a world view). |
52
+ | `--help`, `-h` | Print the flags and key bindings. |
53
+ | `--version`, `-v` | Print the package version. |
54
+
55
+ ### Where to get an archive
56
+
57
+ Protomaps publishes daily planet builds and a region extractor at
58
+ [protomaps.com/downloads](https://protomaps.com/downloads). Any PMTiles archive
59
+ with the [protomaps-basemap](https://github.com/protomaps/basemaps) layer names
60
+ (`earth`, `water`, `roads`, `boundaries`, `places`, …) renders; other schemas
61
+ decode fine but draw only the layers this package styles.
62
+
63
+ Working in this repo, the committed test fixture — a hand-authored slice of
64
+ southeast Portland — is enough to see the browser run without downloading
65
+ anything:
66
+
67
+ ```sh
68
+ node map-tui/out/cli.js \
69
+ --tiles map-tui/test/fixtures/portland.pmtiles \
70
+ --lat 45.5034 --lon -122.6023 --zoom 12
71
+ ```
72
+
73
+ The fixture is not published: `test/` stays out of the tarball, so an installed
74
+ copy needs a real archive.
package/browser.ts ADDED
@@ -0,0 +1,536 @@
1
+ /**
2
+ * @copyright Sister Software.
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ */
6
+
7
+ /**
8
+ * The interactive map browser — a full-screen, alternate-screen terminal app over `MapRenderer`.
9
+ *
10
+ * `MapBrowser` owns exactly three things the frame-first library deliberately does not: terminal MODE (alternate
11
+ * screen, hidden cursor, raw stdin, mouse reporting), viewport STATE (center, zoom, drag anchor), and the write path.
12
+ * Frames still come from `MapRenderer.renderFrame` as values; `blitFrame` copies one into an `AsciifyTerminal`, whose
13
+ * damage diff decides what actually goes down the wire.
14
+ *
15
+ * The pane is the terminal minus its bottom row, which is the status bar. `AsciifyTerminal` is told that size and never
16
+ * addresses a cell outside it, so the two writers never fight over a cell.
17
+ *
18
+ * Every mode change made in {@link MapBrowser.start} is undone by {@link MapBrowser.restore}, which is idempotent so a
19
+ * signal handler, an `exit` hook and the normal path can all call it. A terminal left in raw mode with mouse reporting
20
+ * on is not a recoverable shell, so restore is the one operation that must survive any exit path.
21
+ */
22
+
23
+ import { AsciifyTerminal, cursorTo, SGR_RESET } from "@sister.software/asciify/tui"
24
+
25
+ import { blitFrame } from "./frame.ts"
26
+ import { decodeInputChunk, type MapTUIInput, MOUSE_DISABLE, MOUSE_ENABLE } from "./input.ts"
27
+ import { lonLatToWorldPx, worldPxToLonLat } from "./mercator.ts"
28
+ import { MapRenderer } from "./renderer.ts"
29
+ import type { TileSource } from "./tile-source.ts"
30
+
31
+ const ALT_SCREEN_ENTER = "\u001B[?1049h"
32
+ const ALT_SCREEN_EXIT = "\u001B[?1049l"
33
+ const CURSOR_HIDE = "\u001B[?25l"
34
+ const CURSOR_SHOW = "\u001B[?25h"
35
+ const CLEAR_SCREEN = "\u001B[2J"
36
+ const CLEAR_LINE = "\u001B[2K"
37
+ const REVERSE_VIDEO = "\u001B[7m"
38
+
39
+ /**
40
+ * Subpixel dimensions of one braille cell — the unit every viewport-space conversion below works in.
41
+ */
42
+ const SUBPIXELS_PER_COLUMN = 2
43
+ const SUBPIXELS_PER_ROW = 4
44
+
45
+ /**
46
+ * Rows reserved at the bottom of the terminal for the status bar.
47
+ */
48
+ const STATUS_BAR_ROWS = 1
49
+
50
+ /**
51
+ * Size assumed when the output reports none. A pty opened without a window size (util-linux `script`, some CI
52
+ * harnesses) reports 0 columns and 0 rows, which is neither an error nor a usable grid.
53
+ */
54
+ const FALLBACK_COLUMNS = 80
55
+ const FALLBACK_ROWS = 24
56
+
57
+ /**
58
+ * Floor on the pane's dimensions. Below this the renderer is being asked for a grid too small to mean anything, and a
59
+ * zero-sized one would divide by zero in the projection.
60
+ */
61
+ const MIN_COLUMNS = 4
62
+ const MIN_PANE_ROWS = 1
63
+
64
+ /**
65
+ * How much of the pane one arrow-key press travels. An eighth is far enough to feel like progress at a keystroke and
66
+ * short enough that the next frame still overlaps the last.
67
+ */
68
+ const PAN_FRACTION = 0.125
69
+
70
+ /**
71
+ * Web-Mercator's latitude cutoff — the projection is undefined at the poles, and the square tile pyramid ends here.
72
+ */
73
+ const MERCATOR_LATITUDE_LIMIT = 85.05112878
74
+
75
+ const COORDINATE_DIGITS = 4
76
+
77
+ /**
78
+ * The subset of a readable stream the browser drives. Structural so `process.stdin` satisfies it without the app being
79
+ * welded to it — the same reasoning asciify applies to its own output type.
80
+ */
81
+ export interface BrowserInput {
82
+ setRawMode?(mode: boolean): unknown
83
+ setEncoding(encoding: "utf8"): unknown
84
+ resume(): unknown
85
+ pause(): unknown
86
+ on(event: "data", listener: (chunk: string) => void): unknown
87
+ off(event: "data", listener: (chunk: string) => void): unknown
88
+ }
89
+
90
+ /**
91
+ * The subset of a writable terminal the browser drives.
92
+ */
93
+ export interface BrowserOutput {
94
+ write(chunk: string): unknown
95
+ columns?: number | undefined
96
+ rows?: number | undefined
97
+ on(event: "resize", listener: () => void): unknown
98
+ off(event: "resize", listener: () => void): unknown
99
+ }
100
+
101
+ export interface MapBrowserOptions {
102
+ source: TileSource
103
+ input: BrowserInput
104
+ output: BrowserOutput
105
+ lat: number
106
+ lon: number
107
+ zoom: number
108
+ }
109
+
110
+ interface DragAnchor {
111
+ column: number
112
+ row: number
113
+ centerLon: number
114
+ centerLat: number
115
+ zoom: number
116
+ moved: boolean
117
+ }
118
+
119
+ function clamp(value: number, min: number, max: number): number {
120
+ return Math.min(Math.max(value, min), max)
121
+ }
122
+
123
+ /**
124
+ * Wraps a longitude into [-180, 180) so panning past the antimeridian continues rather than running off the pyramid.
125
+ */
126
+ function wrapLongitude(lon: number): number {
127
+ const wrapped = (((lon + 180) % 360) + 360) % 360
128
+
129
+ return wrapped - 180
130
+ }
131
+
132
+ /**
133
+ * Clips a string to a cell budget, counting codepoints — the status bar's arrows are one cell each but two UTF-16
134
+ * units, and `String.prototype.slice` would cut one in half.
135
+ */
136
+ function clipToCells(text: string, cells: number): string {
137
+ const codePoints = Array.from(text)
138
+
139
+ return codePoints.length <= cells ? text : codePoints.slice(0, cells).join("")
140
+ }
141
+
142
+ export class MapBrowser {
143
+ private readonly source: TileSource
144
+ private readonly renderer: MapRenderer
145
+ private readonly input: BrowserInput
146
+ private readonly output: BrowserOutput
147
+ private readonly terminal: AsciifyTerminal
148
+
149
+ private centerLon: number
150
+ private centerLat: number
151
+ private zoom: number
152
+
153
+ private columns = FALLBACK_COLUMNS
154
+ private paneRows = FALLBACK_ROWS - STATUS_BAR_ROWS
155
+
156
+ private drag: DragAnchor | null = null
157
+ private renderInFlight = false
158
+ private renderQueued = false
159
+ private started = false
160
+ private restored = false
161
+ private error: string | null = null
162
+ private resolveExit: ((code: number) => void) | null = null
163
+
164
+ /**
165
+ * The trailing bytes of the last chunk that could not be decoded yet — a sequence the kernel split across two reads.
166
+ * Threading it back through `decodeInputChunk` is what keeps a split mouse report from being read as an Esc keypress
167
+ * (which used to quit); the decoder itself stays pure.
168
+ */
169
+ private pendingInput = ""
170
+
171
+ private readonly onData = (chunk: string): void => {
172
+ const decoded = decodeInputChunk(chunk, this.pendingInput)
173
+
174
+ this.pendingInput = decoded.pending
175
+
176
+ for (const event of decoded.events) {
177
+ this.handleInput(event)
178
+ }
179
+ }
180
+
181
+ private readonly onResize = (): void => {
182
+ this.measure()
183
+ this.output.write(CLEAR_SCREEN)
184
+ this.terminal.invalidate()
185
+ this.scheduleRender()
186
+ }
187
+
188
+ constructor(options: MapBrowserOptions) {
189
+ this.source = options.source
190
+ this.renderer = new MapRenderer(options.source)
191
+ this.input = options.input
192
+ this.output = options.output
193
+
194
+ this.centerLon = wrapLongitude(options.lon)
195
+ this.centerLat = clamp(options.lat, -MERCATOR_LATITUDE_LIMIT, MERCATOR_LATITUDE_LIMIT)
196
+ this.zoom = clamp(Math.round(options.zoom), options.source.minZoom, options.source.maxZoom)
197
+
198
+ this.terminal = new AsciifyTerminal(this.output, {
199
+ mode: "braille",
200
+ colorDepth: "truecolor",
201
+ synchronizedOutput: true,
202
+ })
203
+ }
204
+
205
+ /**
206
+ * Runs until the user quits, resolving with the process exit code (0 for a normal quit, 130 for Ctrl+C). The terminal
207
+ * is restored before this resolves.
208
+ */
209
+ async run(): Promise<number> {
210
+ this.start()
211
+
212
+ const code = await new Promise<number>((resolve) => {
213
+ this.resolveExit = resolve
214
+ })
215
+
216
+ this.restore()
217
+
218
+ return code
219
+ }
220
+
221
+ /**
222
+ * Asks the browser to exit with a code. Safe to call from a signal handler, and a no-op once an exit is already under
223
+ * way.
224
+ */
225
+ requestExit(code: number): void {
226
+ const resolve = this.resolveExit
227
+
228
+ if (!resolve) return
229
+
230
+ this.resolveExit = null
231
+ resolve(code)
232
+ }
233
+
234
+ /**
235
+ * Enters the alternate screen and takes over input. Paired with {@link restore}.
236
+ */
237
+ start(): void {
238
+ if (this.started) return
239
+
240
+ this.started = true
241
+ this.output.write(ALT_SCREEN_ENTER + CURSOR_HIDE + CLEAR_SCREEN + MOUSE_ENABLE)
242
+
243
+ this.input.setRawMode?.(true)
244
+ this.input.setEncoding("utf8")
245
+ this.input.resume()
246
+ this.input.on("data", this.onData)
247
+ this.output.on("resize", this.onResize)
248
+
249
+ this.measure()
250
+ this.scheduleRender()
251
+ }
252
+
253
+ /**
254
+ * Puts the terminal back exactly as it was found. Idempotent: the normal exit path, a signal handler and a
255
+ * process-level `exit` hook may each call it.
256
+ */
257
+ restore(): void {
258
+ if (!this.started || this.restored) return
259
+
260
+ this.restored = true
261
+
262
+ this.input.off("data", this.onData)
263
+ this.output.off("resize", this.onResize)
264
+ this.input.setRawMode?.(false)
265
+ this.input.pause()
266
+
267
+ this.output.write(MOUSE_DISABLE + SGR_RESET + CURSOR_SHOW + ALT_SCREEN_EXIT)
268
+ }
269
+
270
+ private handleInput(event: MapTUIInput): void {
271
+ switch (event.kind) {
272
+ case "quit":
273
+ return this.requestExit(0)
274
+
275
+ case "interrupt":
276
+ return this.requestExit(130)
277
+
278
+ case "pan":
279
+ return this.panBySteps(event.dx, event.dy)
280
+
281
+ case "zoom":
282
+ return this.zoomBy(event.delta, null)
283
+
284
+ case "wheel":
285
+ return this.zoomBy(event.delta, { column: event.column, row: event.row })
286
+
287
+ case "press":
288
+ return this.beginDrag(event.column, event.row)
289
+
290
+ case "drag":
291
+ return this.continueDrag(event.column, event.row)
292
+
293
+ case "release":
294
+ return this.endDrag()
295
+ }
296
+ }
297
+
298
+ private panBySteps(dx: number, dy: number): void {
299
+ const columnStep = Math.max(1, Math.round(this.columns * PAN_FRACTION))
300
+ const rowStep = Math.max(1, Math.round(this.paneRows * PAN_FRACTION))
301
+
302
+ this.panByCells(dx * columnStep, dy * rowStep)
303
+ this.scheduleRender()
304
+ }
305
+
306
+ /**
307
+ * Moves the center by a cell delta, through world pixels so the step is the same distance on screen at every latitude
308
+ * — the naive degrees-per-keypress version crawls at the equator and sprints near the poles.
309
+ */
310
+ private panByCells(columns: number, rows: number): void {
311
+ const center = lonLatToWorldPx(this.centerLon, this.centerLat, this.zoom)
312
+
313
+ const next = worldPxToLonLat(
314
+ center.x + columns * SUBPIXELS_PER_COLUMN,
315
+ center.y + rows * SUBPIXELS_PER_ROW,
316
+ this.zoom
317
+ )
318
+
319
+ this.setCenter(next.lon, next.lat)
320
+ }
321
+
322
+ private setCenter(lon: number, lat: number): void {
323
+ this.centerLon = wrapLongitude(lon)
324
+ this.centerLat = clamp(lat, -MERCATOR_LATITUDE_LIMIT, MERCATOR_LATITUDE_LIMIT)
325
+ }
326
+
327
+ /**
328
+ * Longitude/latitude at the center of a pane cell.
329
+ */
330
+ private cellToLonLat(column: number, row: number): { lon: number; lat: number } {
331
+ const center = lonLatToWorldPx(this.centerLon, this.centerLat, this.zoom)
332
+ const originX = center.x - (this.columns * SUBPIXELS_PER_COLUMN) / 2
333
+ const originY = center.y - (this.paneRows * SUBPIXELS_PER_ROW) / 2
334
+
335
+ return worldPxToLonLat(
336
+ originX + column * SUBPIXELS_PER_COLUMN + SUBPIXELS_PER_COLUMN / 2,
337
+ originY + row * SUBPIXELS_PER_ROW + SUBPIXELS_PER_ROW / 2,
338
+ this.zoom
339
+ )
340
+ }
341
+
342
+ /**
343
+ * Zooms one or more whole levels. With an anchor cell (the wheel's pointer), the center shifts so whatever was under
344
+ * the pointer stays under it; without one, the pane center holds.
345
+ */
346
+ private zoomBy(delta: number, anchor: { column: number; row: number } | null): void {
347
+ const next = clamp(this.zoom + delta, this.source.minZoom, this.source.maxZoom)
348
+
349
+ if (next === this.zoom) return
350
+
351
+ if (!anchor || !this.withinPane(anchor.column, anchor.row)) {
352
+ this.zoom = next
353
+ this.scheduleRender()
354
+
355
+ return
356
+ }
357
+
358
+ const target = this.cellToLonLat(anchor.column, anchor.row)
359
+
360
+ this.zoom = next
361
+
362
+ const landed = this.cellToLonLat(anchor.column, anchor.row)
363
+ const center = lonLatToWorldPx(this.centerLon, this.centerLat, this.zoom)
364
+ const targetPx = lonLatToWorldPx(target.lon, target.lat, this.zoom)
365
+ const landedPx = lonLatToWorldPx(landed.lon, landed.lat, this.zoom)
366
+
367
+ const corrected = worldPxToLonLat(
368
+ center.x + (targetPx.x - landedPx.x),
369
+ center.y + (targetPx.y - landedPx.y),
370
+ this.zoom
371
+ )
372
+
373
+ this.setCenter(corrected.lon, corrected.lat)
374
+ this.scheduleRender()
375
+ }
376
+
377
+ private withinPane(column: number, row: number): boolean {
378
+ return column >= 0 && column < this.columns && row >= 0 && row < this.paneRows
379
+ }
380
+
381
+ private beginDrag(column: number, row: number): void {
382
+ if (!this.withinPane(column, row)) return
383
+
384
+ this.drag = {
385
+ column,
386
+ row,
387
+ centerLon: this.centerLon,
388
+ centerLat: this.centerLat,
389
+ zoom: this.zoom,
390
+ moved: false,
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Pans relative to where the drag STARTED, not the previous motion report. Accumulating per-report deltas would
396
+ * drift, since each one is rounded to a whole cell.
397
+ */
398
+ private continueDrag(column: number, row: number): void {
399
+ const anchor = this.drag
400
+
401
+ if (!anchor || anchor.zoom !== this.zoom) return
402
+
403
+ anchor.moved = true
404
+
405
+ const center = lonLatToWorldPx(anchor.centerLon, anchor.centerLat, anchor.zoom)
406
+
407
+ const next = worldPxToLonLat(
408
+ center.x + (anchor.column - column) * SUBPIXELS_PER_COLUMN,
409
+ center.y + (anchor.row - row) * SUBPIXELS_PER_ROW,
410
+ anchor.zoom
411
+ )
412
+
413
+ this.setCenter(next.lon, next.lat)
414
+ this.scheduleRender()
415
+ }
416
+
417
+ /**
418
+ * A press and release with no motion between them is a click, which centers the map — mapscii's behavior, and the
419
+ * reason centering waits for the release rather than acting on the press.
420
+ */
421
+ private endDrag(): void {
422
+ const anchor = this.drag
423
+
424
+ this.drag = null
425
+
426
+ if (!anchor || anchor.moved) return
427
+
428
+ // A wheel event between press and release moved the map out from under the click, so the cell no longer names
429
+ // the place the user pointed at.
430
+ if (anchor.zoom !== this.zoom) return
431
+
432
+ const target = this.cellToLonLat(anchor.column, anchor.row)
433
+
434
+ this.setCenter(target.lon, target.lat)
435
+ this.scheduleRender()
436
+ }
437
+
438
+ /**
439
+ * Reads the terminal's size and re-sizes the pane around the status bar.
440
+ */
441
+ private measure(): void {
442
+ // `||` and not `??`: a pty with no window size reports 0, which is as unusable as absent.
443
+ const columns = Math.floor(this.output.columns || FALLBACK_COLUMNS)
444
+ const rows = Math.floor(this.output.rows || FALLBACK_ROWS)
445
+
446
+ this.columns = Math.max(MIN_COLUMNS, columns)
447
+ this.paneRows = Math.max(MIN_PANE_ROWS, rows - STATUS_BAR_ROWS)
448
+
449
+ this.terminal.setSize(this.columns, this.paneRows)
450
+ }
451
+
452
+ /**
453
+ * Requests a frame. Renders never overlap: a request arriving mid-render is coalesced into one more pass, so a held
454
+ * arrow key queues a single redraw rather than a backlog of them.
455
+ */
456
+ private scheduleRender(): void {
457
+ if (this.renderInFlight) {
458
+ this.renderQueued = true
459
+
460
+ return
461
+ }
462
+
463
+ void this.renderLoop()
464
+ }
465
+
466
+ private async renderLoop(): Promise<void> {
467
+ this.renderInFlight = true
468
+
469
+ try {
470
+ do {
471
+ this.renderQueued = false
472
+
473
+ await this.renderOnce()
474
+ } while (this.renderQueued && !this.restored)
475
+ } finally {
476
+ this.renderInFlight = false
477
+ }
478
+ }
479
+
480
+ private async renderOnce(): Promise<void> {
481
+ const viewport = {
482
+ centerLon: this.centerLon,
483
+ centerLat: this.centerLat,
484
+ zoom: this.zoom,
485
+ columns: this.columns,
486
+ rows: this.paneRows,
487
+ }
488
+
489
+ try {
490
+ const frame = await this.renderer.renderFrame(viewport)
491
+
492
+ // The terminal may have been restored while the tiles were in flight; writing then would paint over the
493
+ // user's shell.
494
+ if (this.restored) return
495
+
496
+ // A resize between the request and now leaves the frame the wrong shape for the pane — drop it and let the
497
+ // resize's own render answer instead.
498
+ if (frame.columns !== this.columns || frame.rows !== this.paneRows) return
499
+
500
+ this.error = null
501
+
502
+ blitFrame(this.terminal, frame)
503
+ this.terminal.flush()
504
+ this.drawStatusBar(frame.attribution)
505
+ } catch (error) {
506
+ this.error = error instanceof Error ? error.message : String(error)
507
+
508
+ if (!this.restored) {
509
+ this.drawStatusBar("")
510
+ }
511
+ }
512
+ }
513
+
514
+ private statusText(attribution: string): string {
515
+ if (this.error) return `⚠ ${this.error} q quit`
516
+
517
+ const lat = this.centerLat.toFixed(COORDINATE_DIGITS)
518
+ const lon = this.centerLon.toFixed(COORDINATE_DIGITS)
519
+ const status = `${lat},${lon} z${this.zoom} ←↑↓→ pan +/- zoom q quit`
520
+
521
+ if (!attribution.length) return status
522
+
523
+ const credited = `${status} © ${attribution}`
524
+
525
+ // Attribution is a courtesy to the tile source, never a reason to push the controls off the bar.
526
+ return Array.from(credited).length < this.columns ? credited : status
527
+ }
528
+
529
+ private drawStatusBar(attribution: string): void {
530
+ // One cell short of the width: writing the bottom-right cell leaves some terminals in a pending-wrap state.
531
+ const width = Math.max(0, this.columns - 1)
532
+ const line = clipToCells(this.statusText(attribution), width).padEnd(width)
533
+
534
+ this.output.write(`${cursorTo(0, this.paneRows)}${CLEAR_LINE}${REVERSE_VIDEO}${line}${SGR_RESET}`)
535
+ }
536
+ }