@mailwoman/map-tui 9.1.0 → 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 +59 -0
- package/browser.ts +536 -0
- package/cli-args.ts +232 -0
- package/cli.ts +143 -0
- package/frame.ts +30 -1
- package/input.ts +288 -0
- package/out/browser.d.ts +126 -0
- package/out/browser.d.ts.map +1 -0
- package/out/browser.js +386 -0
- package/out/browser.js.map +1 -0
- package/out/cli-args.d.ts +52 -0
- package/out/cli-args.d.ts.map +1 -0
- package/out/cli-args.js +168 -0
- package/out/cli-args.js.map +1 -0
- package/out/cli.d.ts +8 -0
- package/out/cli.d.ts.map +1 -0
- package/out/cli.js +122 -0
- package/out/cli.js.map +1 -0
- package/out/frame.d.ts +18 -0
- package/out/frame.d.ts.map +1 -1
- package/out/frame.js +27 -1
- package/out/frame.js.map +1 -1
- package/out/input.d.ts +93 -0
- package/out/input.d.ts.map +1 -0
- package/out/input.js +214 -0
- package/out/input.js.map +1 -0
- package/out/renderer.d.ts +9 -2
- package/out/renderer.d.ts.map +1 -1
- package/out/renderer.js +42 -15
- package/out/renderer.js.map +1 -1
- package/out/style.d.ts +19 -8
- package/out/style.d.ts.map +1 -1
- package/out/style.js +47 -2
- package/out/style.js.map +1 -1
- package/out/tile-source.d.ts +23 -2
- package/out/tile-source.d.ts.map +1 -1
- package/out/tile-source.js +44 -11
- package/out/tile-source.js.map +1 -1
- package/out/tsconfig.test.tsbuildinfo +1 -1
- package/out/tsconfig.tsbuildinfo +1 -1
- package/package.json +77 -6
- package/renderer.ts +76 -24
- package/style.ts +63 -10
- package/tile-source.ts +71 -16
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
|
+
}
|