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