@mailwoman/map-tui 10.0.0 → 10.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/README.md +5 -6
- package/{lib/cli → cli}/args.ts +49 -28
- package/{lib/cli/index.ts → cli/main.ts} +12 -23
- package/lib/browser.ts +33 -76
- package/lib/frame.ts +50 -28
- package/lib/input.ts +46 -69
- package/lib/mercator.ts +13 -12
- package/lib/mvt.ts +2 -2
- package/lib/raster.ts +53 -27
- package/lib/renderer.ts +19 -38
- package/lib/style.ts +21 -15
- package/lib/tile-source.ts +30 -19
- package/out/browser.d.ts +24 -33
- package/out/browser.d.ts.map +1 -1
- package/out/browser.js +27 -72
- package/out/browser.js.map +1 -1
- package/out/cli/args.d.ts +18 -8
- package/out/cli/args.d.ts.map +1 -1
- package/out/cli/args.js +41 -25
- package/out/cli/args.js.map +1 -1
- package/out/cli/{index.d.ts → main.d.ts} +1 -1
- package/out/cli/main.d.ts.map +1 -0
- package/out/cli/{index.js → main.js} +13 -24
- package/out/cli/main.js.map +1 -0
- package/out/cli/tsconfig.tsbuildinfo +1 -0
- package/out/frame.d.ts +40 -23
- package/out/frame.d.ts.map +1 -1
- package/out/frame.js +48 -26
- package/out/frame.js.map +1 -1
- package/out/input.d.ts +18 -36
- package/out/input.d.ts.map +1 -1
- package/out/input.js +37 -63
- package/out/input.js.map +1 -1
- package/out/mercator.d.ts +12 -11
- package/out/mercator.d.ts.map +1 -1
- package/out/mercator.js +4 -10
- package/out/mercator.js.map +1 -1
- package/out/mvt.js +2 -2
- package/out/raster.d.ts +45 -23
- package/out/raster.d.ts.map +1 -1
- package/out/raster.js +44 -22
- package/out/raster.js.map +1 -1
- package/out/renderer.d.ts +6 -7
- package/out/renderer.d.ts.map +1 -1
- package/out/renderer.js +13 -22
- package/out/renderer.js.map +1 -1
- package/out/style.d.ts +17 -11
- package/out/style.d.ts.map +1 -1
- package/out/style.js +11 -9
- package/out/style.js.map +1 -1
- package/out/tile-source.d.ts +12 -8
- package/out/tile-source.d.ts.map +1 -1
- package/out/tile-source.js +21 -14
- package/out/tile-source.js.map +1 -1
- package/out/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +35 -83
- package/out/cli/index.d.ts.map +0 -1
- package/out/cli/index.js.map +0 -1
package/README.md
CHANGED
|
@@ -5,8 +5,8 @@ A frame-first terminal map renderer. It decodes vector tiles from a
|
|
|
5
5
|
resolves the result to a grid of terminal cells — braille-glyph map frames
|
|
6
6
|
suitable for TUIs and other character-grid displays.
|
|
7
7
|
|
|
8
|
-
The unit of output is a `MapFrame`: frames are values
|
|
9
|
-
|
|
8
|
+
The unit of output is a `MapFrame`: frames are values rather than side effects.
|
|
9
|
+
The renderer never writes to stdout, an Ink component, or any other presentation
|
|
10
10
|
layer directly — a caller reads the frame's cells and decides how (and
|
|
11
11
|
whether) to draw them.
|
|
12
12
|
|
|
@@ -60,12 +60,11 @@ with the [protomaps-basemap](https://github.com/protomaps/basemaps) layer names
|
|
|
60
60
|
(`earth`, `water`, `roads`, `boundaries`, `places`, …) renders; other schemas
|
|
61
61
|
decode fine but draw only the layers this package styles.
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
anything:
|
|
63
|
+
The committed test fixture, a hand-authored extract of southeast Portland, lets you run the browser
|
|
64
|
+
without downloading anything:
|
|
66
65
|
|
|
67
66
|
```sh
|
|
68
|
-
node map-tui/out/cli.js \
|
|
67
|
+
node map-tui/out/cli/main.js \
|
|
69
68
|
--tiles map-tui/test/fixtures/portland.pmtiles \
|
|
70
69
|
--lat 45.5034 --lon -122.6023 --zoom 12
|
|
71
70
|
```
|
package/{lib/cli → cli}/args.ts
RENAMED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { errorMessage } from "@mailwoman/core/errors/schema"
|
|
2
|
+
import { stringifyJSON } from "@mailwoman/core/json"
|
|
2
3
|
import { parseArguments } from "@mailwoman/core/scripting/arguments"
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* @copyright Sister Software.
|
|
5
7
|
* @license AGPL-3.0
|
|
@@ -9,9 +11,12 @@ import { parseArguments } from "@mailwoman/core/scripting/arguments"
|
|
|
9
11
|
/**
|
|
10
12
|
* Argument parsing for the `map-tui` bin.
|
|
11
13
|
*
|
|
12
|
-
* `parseCLIArgs` is pure
|
|
13
|
-
*
|
|
14
|
-
*
|
|
14
|
+
* `parseCLIArgs` is pure.
|
|
15
|
+
* It takes the argv array and an environment record, then returns a discriminated
|
|
16
|
+
* result (`help` / `version` / `browse`) or throws {@link CLIArgsError}.
|
|
17
|
+
*
|
|
18
|
+
* The bin (`./cli.ts`) reads `process.argv` and `process.env`.
|
|
19
|
+
* This keeps every rejection path testable without a subprocess.
|
|
15
20
|
*/
|
|
16
21
|
|
|
17
22
|
/**
|
|
@@ -25,8 +30,8 @@ const DEFAULT_LON = 0
|
|
|
25
30
|
const DEFAULT_LAT = 0
|
|
26
31
|
|
|
27
32
|
/**
|
|
28
|
-
* Zoom when `--zoom` is omitted. z2 shows a full hemisphere in a typical terminal,
|
|
29
|
-
* something recognizable rather than a single ocean tile.
|
|
33
|
+
* Zoom when `--zoom` is omitted. z2 shows a full hemisphere in a typical terminal,
|
|
34
|
+
* so a planet archive opens on something recognizable rather than a single ocean tile.
|
|
30
35
|
*/
|
|
31
36
|
const DEFAULT_ZOOM = 2
|
|
32
37
|
|
|
@@ -36,14 +41,16 @@ const DEFAULT_ZOOM = 2
|
|
|
36
41
|
const MIN_ZOOM = 0
|
|
37
42
|
|
|
38
43
|
/**
|
|
39
|
-
* Deepest zoom the Web-Mercator tile pyramid is defined for.
|
|
40
|
-
*
|
|
44
|
+
* Deepest zoom the Web-Mercator tile pyramid is defined for.
|
|
45
|
+
*
|
|
46
|
+
* The archive's own `maxZoom` clamps further at runtime.
|
|
47
|
+
* This is only the range a flag value must fall inside to be meaningful at all.
|
|
41
48
|
*/
|
|
42
49
|
const MAX_ZOOM = 24
|
|
43
50
|
|
|
44
|
-
//
|
|
45
|
-
// the
|
|
46
|
-
// value the viewport handles fine.
|
|
51
|
+
// The flag accepts any real latitude.
|
|
52
|
+
// The browser clamps the center to the projection's MERCATOR_LATITUDE_LIMIT (see ./browser.ts) —
|
|
53
|
+
// rejecting 87 here would refuse a value the viewport handles fine.
|
|
47
54
|
const MIN_LAT = -90
|
|
48
55
|
const MAX_LAT = 90
|
|
49
56
|
const MIN_LON = -180
|
|
@@ -61,8 +68,10 @@ export interface BrowseArgs {
|
|
|
61
68
|
lat: number
|
|
62
69
|
lon: number
|
|
63
70
|
/**
|
|
64
|
-
* Integer zoom level.
|
|
65
|
-
*
|
|
71
|
+
* Integer zoom level.
|
|
72
|
+
*
|
|
73
|
+
* The renderer draws whole tile-pyramid levels, so a fractional flag value is rounded here
|
|
74
|
+
* rather than preserved as a lie through the viewport.
|
|
66
75
|
*/
|
|
67
76
|
zoom: number
|
|
68
77
|
}
|
|
@@ -70,23 +79,30 @@ export interface BrowseArgs {
|
|
|
70
79
|
export type CLIArgs = { mode: "help" } | { mode: "version" } | BrowseArgs
|
|
71
80
|
|
|
72
81
|
/**
|
|
73
|
-
* A rejected command line.
|
|
74
|
-
*
|
|
82
|
+
* A rejected command line.
|
|
83
|
+
*
|
|
84
|
+
* The message is user-facing: it explains what was wrong and what to pass instead,
|
|
85
|
+
* since the bin prints it verbatim to stderr.
|
|
75
86
|
*/
|
|
76
87
|
export class CLIArgsError extends Error {
|
|
77
88
|
override name = "CLIArgsError"
|
|
78
89
|
}
|
|
79
90
|
|
|
80
91
|
/**
|
|
81
|
-
* Environment keys the CLI reads.
|
|
92
|
+
* Environment keys the CLI reads.
|
|
93
|
+
*
|
|
94
|
+
* Passed in rather than read here so the parser stays pure.
|
|
82
95
|
*/
|
|
83
96
|
export interface CLIEnvironment {
|
|
84
97
|
MAILWOMAN_TILES?: string | undefined
|
|
85
98
|
}
|
|
86
99
|
|
|
87
100
|
/**
|
|
88
|
-
* `--help` output.
|
|
89
|
-
*
|
|
101
|
+
* `--help` output.
|
|
102
|
+
*
|
|
103
|
+
* It doubles as the package's key reference, so the bindings listed here
|
|
104
|
+
* and the ones ./input.ts decodes are the same list said twice.
|
|
105
|
+
* A key added there without a line here is missing from CLI help.
|
|
90
106
|
*/
|
|
91
107
|
export const HELP_TEXT = `map-tui — the whole world in your terminal
|
|
92
108
|
|
|
@@ -115,8 +131,8 @@ archive from https://protomaps.com/downloads and point --tiles at it.
|
|
|
115
131
|
`
|
|
116
132
|
|
|
117
133
|
/**
|
|
118
|
-
* Reads one numeric flag, rejecting anything `Number` would quietly accept as garbage
|
|
119
|
-
* `Infinity`) as well as out-of-range values.
|
|
134
|
+
* Reads one numeric flag, rejecting anything `Number` would quietly accept as garbage
|
|
135
|
+
* (empty string, whitespace, `Infinity`) as well as out-of-range values.
|
|
120
136
|
*/
|
|
121
137
|
function numericFlag(name: string, raw: string | undefined, fallback: number, min: number, max: number): number {
|
|
122
138
|
if (raw == null) return fallback
|
|
@@ -124,7 +140,7 @@ function numericFlag(name: string, raw: string | undefined, fallback: number, mi
|
|
|
124
140
|
const value = Number(raw.trim())
|
|
125
141
|
|
|
126
142
|
if (!Number.isFinite(value) || !raw.trim().length) {
|
|
127
|
-
throw new CLIArgsError(`--${name} expects a number, got ${
|
|
143
|
+
throw new CLIArgsError(`--${name} expects a number, got ${stringifyJSON(raw)}`)
|
|
128
144
|
}
|
|
129
145
|
|
|
130
146
|
if (value < min || value > max) {
|
|
@@ -135,17 +151,21 @@ function numericFlag(name: string, raw: string | undefined, fallback: number, mi
|
|
|
135
151
|
}
|
|
136
152
|
|
|
137
153
|
/**
|
|
138
|
-
* Flags
|
|
154
|
+
* Flags with numeric values.
|
|
155
|
+
* Their values may start with a dash.
|
|
139
156
|
*/
|
|
140
157
|
const NUMERIC_FLAGS = new Set(["--lat", "--lon", "--zoom"])
|
|
141
158
|
|
|
142
159
|
/**
|
|
143
160
|
* Joins `--lon -122.6` into `--lon=-122.6` before `parseArgs` sees it.
|
|
144
161
|
*
|
|
145
|
-
* `node:util`'s parser refuses a separate value that starts with a dash
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
162
|
+
* `node:util`'s parser refuses a separate value that starts with a dash.
|
|
163
|
+
* It reports "argument is ambiguous" because a negative number can resemble a mistyped flag.
|
|
164
|
+
*
|
|
165
|
+
* Half the planet has a negative longitude, so the space-form has to work.
|
|
166
|
+
*
|
|
167
|
+
* The join occurs only when the next token parses as a finite number.
|
|
168
|
+
* `parseArgs` then handles a missing value such as `--lon --zoom 3` and returns its own error.
|
|
149
169
|
*/
|
|
150
170
|
function joinNegativeNumbers(argv: readonly string[]): string[] {
|
|
151
171
|
const joined: string[] = []
|
|
@@ -177,8 +197,8 @@ interface ParsedFlags {
|
|
|
177
197
|
}
|
|
178
198
|
|
|
179
199
|
/**
|
|
180
|
-
* `node:util`'s own rejections (unknown flag, missing value) name the flag but not the
|
|
181
|
-
* a {@link CLIArgsError} pointing at `--help`.
|
|
200
|
+
* `node:util`'s own rejections (unknown flag, missing value) name the flag but not the action,
|
|
201
|
+
* so they're re-thrown as a {@link CLIArgsError} pointing at `--help`.
|
|
182
202
|
*/
|
|
183
203
|
function readFlags(argv: readonly string[]): ParsedFlags {
|
|
184
204
|
try {
|
|
@@ -205,7 +225,8 @@ function readFlags(argv: readonly string[]): ParsedFlags {
|
|
|
205
225
|
/**
|
|
206
226
|
* Parses a `map-tui` command line.
|
|
207
227
|
*
|
|
208
|
-
* @throws {CLIArgsError} On an unknown flag, an unparseable or out-of-range number,
|
|
228
|
+
* @throws {CLIArgsError} On an unknown flag, an unparseable or out-of-range number,
|
|
229
|
+
* or a missing archive path.
|
|
209
230
|
*/
|
|
210
231
|
export function parseCLIArgs(argv: readonly string[], environment: CLIEnvironment = {}): CLIArgs {
|
|
211
232
|
const values = readFlags(argv)
|
|
@@ -7,17 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
10
|
+
* Opens a PMTiles archive as a full-screen terminal map through the `map-tui` binary.
|
|
11
11
|
*
|
|
12
|
-
* This file
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* unit test drive the parser with neither.
|
|
16
|
-
*
|
|
17
|
-
* Signal handling is not optional here. This is a raw-mode app on the alternate screen with mouse reporting on, and
|
|
18
|
-
* there is no framework underneath to put any of that back — a process killed between `start` and `restore` leaves the
|
|
19
|
-
* user with an unusable shell. So `restore` is wired to SIGINT, SIGTERM and `exit`, and it is idempotent for exactly
|
|
20
|
-
* that reason.
|
|
12
|
+
* This file is the only place in the package that touches `process`.
|
|
13
|
+
* It restores the terminal on SIGINT, SIGTERM and `exit`, because a process killed
|
|
14
|
+
* while in raw mode leaves the shell unusable.
|
|
21
15
|
*/
|
|
22
16
|
|
|
23
17
|
import { errorMessage } from "@mailwoman/core/errors/schema"
|
|
@@ -33,11 +27,10 @@ import { TileSource } from "#tile-source"
|
|
|
33
27
|
const EXIT_USAGE = 1
|
|
34
28
|
|
|
35
29
|
/**
|
|
36
|
-
* Reads the package
|
|
30
|
+
* Reads the package version.
|
|
37
31
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* graph knows which. `createRequire` parses the JSON itself, so no reader here has to.
|
|
32
|
+
* The package self-reference resolves correctly both from the installed `out/`
|
|
33
|
+
* directory and from the workspace.
|
|
41
34
|
*/
|
|
42
35
|
function readVersion(): string {
|
|
43
36
|
const require = createRequire(import.meta.url)
|
|
@@ -47,7 +40,7 @@ function readVersion(): string {
|
|
|
47
40
|
}
|
|
48
41
|
|
|
49
42
|
/**
|
|
50
|
-
* Opens the archive
|
|
43
|
+
* Opens the tile archive and reports a failure as a usage error that mentions `--tiles`.
|
|
51
44
|
*/
|
|
52
45
|
async function openTiles(path: string): Promise<TileSource> {
|
|
53
46
|
try {
|
|
@@ -61,7 +54,7 @@ async function openTiles(path: string): Promise<TileSource> {
|
|
|
61
54
|
}
|
|
62
55
|
|
|
63
56
|
/**
|
|
64
|
-
* Runs the interactive browser
|
|
57
|
+
* Runs the interactive browser on an open archive and resolves with its exit code.
|
|
65
58
|
*/
|
|
66
59
|
async function browse(source: TileSource, args: { lat: number; lon: number; zoom: number }): Promise<number> {
|
|
67
60
|
const browser = new MapBrowser({
|
|
@@ -94,11 +87,8 @@ async function main(): Promise<number> {
|
|
|
94
87
|
let args: CLIArgs
|
|
95
88
|
|
|
96
89
|
try {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
* `@mailwoman/core/env`'s typed readers would pull core's data-backed environment schema behind a CLI whose whole
|
|
100
|
-
* premise is `npx`, so the bin passes the raw records to the pure parser instead.
|
|
101
|
-
*/
|
|
90
|
+
// The bin reads raw argv and env here because `@mailwoman/core/env` depends on core's data-backed schema,
|
|
91
|
+
// Core's data-backed schema is too heavy for an `npx` entry point.
|
|
102
92
|
// oxlint-disable-next-line sister-software/no-process-globals -- see above.
|
|
103
93
|
args = parseCLIArgs(process.argv.slice(2), process.env)
|
|
104
94
|
} catch (error) {
|
|
@@ -123,8 +113,7 @@ async function main(): Promise<number> {
|
|
|
123
113
|
|
|
124
114
|
let opened: TileSource
|
|
125
115
|
|
|
126
|
-
//
|
|
127
|
-
// `using` declaration only once the open succeeded.
|
|
116
|
+
// The `using` declaration takes ownership only after the open succeeds.
|
|
128
117
|
try {
|
|
129
118
|
opened = await openTiles(args.tiles)
|
|
130
119
|
} catch (error) {
|
package/lib/browser.ts
CHANGED
|
@@ -4,22 +4,6 @@
|
|
|
4
4
|
* @author Teffen Ellis, et al.
|
|
5
5
|
*/
|
|
6
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
7
|
import { errorMessage } from "@mailwoman/core/errors/schema"
|
|
24
8
|
import { clamp } from "@mailwoman/core/numeric"
|
|
25
9
|
import { AsciifyTerminal, cursorTo, SGR_RESET } from "@sister.software/asciify/tui"
|
|
@@ -44,41 +28,26 @@ const CLEAR_SCREEN = "\u001B[2J"
|
|
|
44
28
|
const CLEAR_LINE = "\u001B[2K"
|
|
45
29
|
const REVERSE_VIDEO = "\u001B[7m"
|
|
46
30
|
|
|
47
|
-
/**
|
|
48
|
-
* Rows reserved at the bottom of the terminal for the status bar.
|
|
49
|
-
*/
|
|
50
31
|
const STATUS_BAR_ROWS = 1
|
|
51
32
|
|
|
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
33
|
const FALLBACK_COLUMNS = 80
|
|
57
34
|
const FALLBACK_ROWS = 24
|
|
58
35
|
|
|
59
36
|
/**
|
|
60
|
-
*
|
|
61
|
-
* zero-sized one would divide by zero in the projection.
|
|
37
|
+
* A zero-sized pane would divide by zero in the projection.
|
|
62
38
|
*/
|
|
63
39
|
const MIN_COLUMNS = 4
|
|
64
40
|
const MIN_PANE_ROWS = 1
|
|
65
41
|
|
|
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
42
|
const PAN_FRACTION = 0.125
|
|
71
43
|
|
|
72
|
-
/**
|
|
73
|
-
* Web-Mercator's latitude cutoff — the projection is undefined at the poles, and the square tile pyramid ends here.
|
|
74
|
-
*/
|
|
75
44
|
const MERCATOR_LATITUDE_LIMIT = 85.05112878
|
|
76
45
|
|
|
77
46
|
const COORDINATE_DIGITS = 4
|
|
78
47
|
|
|
79
48
|
/**
|
|
80
|
-
* The subset of a readable stream the browser
|
|
81
|
-
*
|
|
49
|
+
* The subset of a readable stream the browser uses, kept structural so that
|
|
50
|
+
* `process.stdin` and test doubles both satisfy it.
|
|
82
51
|
*/
|
|
83
52
|
export interface BrowserInput {
|
|
84
53
|
setRawMode?(mode: boolean): unknown
|
|
@@ -90,7 +59,7 @@ export interface BrowserInput {
|
|
|
90
59
|
}
|
|
91
60
|
|
|
92
61
|
/**
|
|
93
|
-
* The subset of a writable terminal the browser
|
|
62
|
+
* The subset of a writable terminal the browser uses.
|
|
94
63
|
*/
|
|
95
64
|
export interface BrowserOutput {
|
|
96
65
|
write(chunk: string): unknown
|
|
@@ -100,6 +69,9 @@ export interface BrowserOutput {
|
|
|
100
69
|
off(event: "resize", listener: () => void): unknown
|
|
101
70
|
}
|
|
102
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Options for {@link MapBrowser}: the tile source, the terminal streams and the initial viewport.
|
|
74
|
+
*/
|
|
103
75
|
export interface MapBrowserOptions {
|
|
104
76
|
source: TileSource
|
|
105
77
|
input: BrowserInput
|
|
@@ -119,8 +91,7 @@ interface DragAnchor {
|
|
|
119
91
|
}
|
|
120
92
|
|
|
121
93
|
/**
|
|
122
|
-
*
|
|
123
|
-
* units, and `String.prototype.slice` would split one in half.
|
|
94
|
+
* Code-point counts keep `slice` from splitting a surrogate pair.
|
|
124
95
|
*/
|
|
125
96
|
function clipToCells(text: string, cells: number): string {
|
|
126
97
|
const codePoints = Array.from(text)
|
|
@@ -128,6 +99,10 @@ function clipToCells(text: string, cells: number): string {
|
|
|
128
99
|
return codePoints.length <= cells ? text : codePoints.slice(0, cells).join("")
|
|
129
100
|
}
|
|
130
101
|
|
|
102
|
+
/**
|
|
103
|
+
* A full-screen terminal map browser built on `MapRenderer` that owns terminal modes,
|
|
104
|
+
* viewport state and output, leaving the bottom row as a status bar.
|
|
105
|
+
*/
|
|
131
106
|
export class MapBrowser {
|
|
132
107
|
private readonly source: TileSource
|
|
133
108
|
private readonly renderer: MapRenderer
|
|
@@ -151,9 +126,8 @@ export class MapBrowser {
|
|
|
151
126
|
private resolveExit: ((code: number) => void) | null = null
|
|
152
127
|
|
|
153
128
|
/**
|
|
154
|
-
* The
|
|
155
|
-
*
|
|
156
|
-
* (which used to quit); the decoder itself stays pure.
|
|
129
|
+
* The next `decodeInputChunk` call receives trailing bytes from an incomplete escape sequence
|
|
130
|
+
* so a mouse report split across reads does not decode as an Esc keypress.
|
|
157
131
|
*/
|
|
158
132
|
private pendingInput = ""
|
|
159
133
|
|
|
@@ -192,8 +166,8 @@ export class MapBrowser {
|
|
|
192
166
|
}
|
|
193
167
|
|
|
194
168
|
/**
|
|
195
|
-
* Runs until the user quits, resolving with the
|
|
196
|
-
*
|
|
169
|
+
* Runs until the user quits, resolving with the exit code (0 for a normal quit, 130 for Ctrl+C)
|
|
170
|
+
* after the terminal is restored.
|
|
197
171
|
*/
|
|
198
172
|
async run(): Promise<number> {
|
|
199
173
|
this.start()
|
|
@@ -208,8 +182,8 @@ export class MapBrowser {
|
|
|
208
182
|
}
|
|
209
183
|
|
|
210
184
|
/**
|
|
211
|
-
* Asks the browser to exit with a code
|
|
212
|
-
*
|
|
185
|
+
* Asks the browser to exit with a code, ignoring calls after the first
|
|
186
|
+
* so that a signal handler may call it.
|
|
213
187
|
*/
|
|
214
188
|
requestExit(code: number): void {
|
|
215
189
|
const resolve = this.resolveExit
|
|
@@ -220,9 +194,6 @@ export class MapBrowser {
|
|
|
220
194
|
resolve(code)
|
|
221
195
|
}
|
|
222
196
|
|
|
223
|
-
/**
|
|
224
|
-
* Enters the alternate screen and takes over input. Paired with {@link restore}.
|
|
225
|
-
*/
|
|
226
197
|
start(): void {
|
|
227
198
|
if (this.started) return
|
|
228
199
|
|
|
@@ -239,10 +210,6 @@ export class MapBrowser {
|
|
|
239
210
|
this.scheduleRender()
|
|
240
211
|
}
|
|
241
212
|
|
|
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
213
|
restore(): void {
|
|
247
214
|
if (!this.started || this.restored) return
|
|
248
215
|
|
|
@@ -293,8 +260,8 @@ export class MapBrowser {
|
|
|
293
260
|
}
|
|
294
261
|
|
|
295
262
|
/**
|
|
296
|
-
* Moves the center by a cell delta
|
|
297
|
-
*
|
|
263
|
+
* Moves the center by a cell delta in world pixels, so one step covers the
|
|
264
|
+
* same screen distance at every latitude.
|
|
298
265
|
*/
|
|
299
266
|
private panByCells(columns: number, rows: number): void {
|
|
300
267
|
const center = lonLatToWorldPx(this.centerLon, this.centerLat, this.zoom)
|
|
@@ -313,9 +280,6 @@ export class MapBrowser {
|
|
|
313
280
|
this.centerLat = clamp(lat, -MERCATOR_LATITUDE_LIMIT, MERCATOR_LATITUDE_LIMIT)
|
|
314
281
|
}
|
|
315
282
|
|
|
316
|
-
/**
|
|
317
|
-
* Longitude/latitude at the center of a pane cell.
|
|
318
|
-
*/
|
|
319
283
|
private cellToLonLat(column: number, row: number): { lon: number; lat: number } {
|
|
320
284
|
const center = lonLatToWorldPx(this.centerLon, this.centerLat, this.zoom)
|
|
321
285
|
const originX = center.x - (this.columns * SUBPIXEL_COLUMNS_PER_CELL) / 2
|
|
@@ -329,8 +293,7 @@ export class MapBrowser {
|
|
|
329
293
|
}
|
|
330
294
|
|
|
331
295
|
/**
|
|
332
|
-
* Zooms
|
|
333
|
-
* the pointer stays under it; without one, the pane center holds.
|
|
296
|
+
* Zooms by whole levels, shifting the center so the point under an anchor cell stays under it.
|
|
334
297
|
*/
|
|
335
298
|
private zoomBy(delta: number, anchor: { column: number; row: number } | null): void {
|
|
336
299
|
const next = clamp(this.zoom + delta, this.source.minZoom, this.source.maxZoom)
|
|
@@ -381,8 +344,8 @@ export class MapBrowser {
|
|
|
381
344
|
}
|
|
382
345
|
|
|
383
346
|
/**
|
|
384
|
-
* Pans relative to
|
|
385
|
-
* drift
|
|
347
|
+
* Pans relative to the drag's starting point, because summing per-report deltas
|
|
348
|
+
* would drift as each report is rounded to a whole cell.
|
|
386
349
|
*/
|
|
387
350
|
private continueDrag(column: number, row: number): void {
|
|
388
351
|
const anchor = this.drag
|
|
@@ -404,8 +367,7 @@ export class MapBrowser {
|
|
|
404
367
|
}
|
|
405
368
|
|
|
406
369
|
/**
|
|
407
|
-
* A press and release
|
|
408
|
-
* reason centering waits for the release rather than acting on the press.
|
|
370
|
+
* A press and release without motion counts as a click that centers the map on the clicked cell.
|
|
409
371
|
*/
|
|
410
372
|
private endDrag(): void {
|
|
411
373
|
const anchor = this.drag
|
|
@@ -414,8 +376,7 @@ export class MapBrowser {
|
|
|
414
376
|
|
|
415
377
|
if (!anchor || anchor.moved) return
|
|
416
378
|
|
|
417
|
-
// A
|
|
418
|
-
// the place the user pointed at.
|
|
379
|
+
// A zoom between press and release means the cell no longer covers the point the user clicked.
|
|
419
380
|
if (anchor.zoom !== this.zoom) return
|
|
420
381
|
|
|
421
382
|
const target = this.cellToLonLat(anchor.column, anchor.row)
|
|
@@ -424,11 +385,8 @@ export class MapBrowser {
|
|
|
424
385
|
this.scheduleRender()
|
|
425
386
|
}
|
|
426
387
|
|
|
427
|
-
/**
|
|
428
|
-
* Reads the terminal's size and re-sizes the pane around the status bar.
|
|
429
|
-
*/
|
|
430
388
|
private measure(): void {
|
|
431
|
-
// `||`
|
|
389
|
+
// The `||` operator also replaces the 0 that a pty without a window size reports.
|
|
432
390
|
const columns = Math.floor(this.output.columns || FALLBACK_COLUMNS)
|
|
433
391
|
const rows = Math.floor(this.output.rows || FALLBACK_ROWS)
|
|
434
392
|
|
|
@@ -439,8 +397,8 @@ export class MapBrowser {
|
|
|
439
397
|
}
|
|
440
398
|
|
|
441
399
|
/**
|
|
442
|
-
* Requests a frame
|
|
443
|
-
*
|
|
400
|
+
* Requests a frame, collapsing requests that arrive during a render into one
|
|
401
|
+
* follow-up so renders never overlap.
|
|
444
402
|
*/
|
|
445
403
|
private scheduleRender(): void {
|
|
446
404
|
if (this.renderInFlight) {
|
|
@@ -478,12 +436,10 @@ export class MapBrowser {
|
|
|
478
436
|
try {
|
|
479
437
|
const frame = await this.renderer.renderFrame(viewport)
|
|
480
438
|
|
|
481
|
-
//
|
|
482
|
-
// user's shell.
|
|
439
|
+
// A write after restore would draw over the user's shell.
|
|
483
440
|
if (this.restored) return
|
|
484
441
|
|
|
485
|
-
// A resize
|
|
486
|
-
// resize's own render answer instead.
|
|
442
|
+
// A resize during the render makes this frame the wrong shape and schedules its own render.
|
|
487
443
|
if (frame.columns !== this.columns || frame.rows !== this.paneRows) return
|
|
488
444
|
|
|
489
445
|
this.error = null
|
|
@@ -511,12 +467,13 @@ export class MapBrowser {
|
|
|
511
467
|
|
|
512
468
|
const credited = `${status} © ${attribution}`
|
|
513
469
|
|
|
514
|
-
//
|
|
470
|
+
// The attribution is dropped when it would push the controls off the bar.
|
|
515
471
|
return Array.from(credited).length < this.columns ? credited : status
|
|
516
472
|
}
|
|
517
473
|
|
|
518
474
|
private drawStatusBar(attribution: string): void {
|
|
519
|
-
//
|
|
475
|
+
// The line stops one cell short because writing the bottom-right cell leaves
|
|
476
|
+
// some terminals in a pending-wrap state.
|
|
520
477
|
const width = Math.max(0, this.columns - 1)
|
|
521
478
|
const line = clipToCells(this.statusText(attribution), width).padEnd(width)
|
|
522
479
|
|