@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.
Files changed (58) hide show
  1. package/README.md +5 -6
  2. package/{lib/cli → cli}/args.ts +49 -28
  3. package/{lib/cli/index.ts → cli/main.ts} +12 -23
  4. package/lib/browser.ts +33 -76
  5. package/lib/frame.ts +50 -28
  6. package/lib/input.ts +46 -69
  7. package/lib/mercator.ts +13 -12
  8. package/lib/mvt.ts +2 -2
  9. package/lib/raster.ts +53 -27
  10. package/lib/renderer.ts +19 -38
  11. package/lib/style.ts +21 -15
  12. package/lib/tile-source.ts +30 -19
  13. package/out/browser.d.ts +24 -33
  14. package/out/browser.d.ts.map +1 -1
  15. package/out/browser.js +27 -72
  16. package/out/browser.js.map +1 -1
  17. package/out/cli/args.d.ts +18 -8
  18. package/out/cli/args.d.ts.map +1 -1
  19. package/out/cli/args.js +41 -25
  20. package/out/cli/args.js.map +1 -1
  21. package/out/cli/{index.d.ts → main.d.ts} +1 -1
  22. package/out/cli/main.d.ts.map +1 -0
  23. package/out/cli/{index.js → main.js} +13 -24
  24. package/out/cli/main.js.map +1 -0
  25. package/out/cli/tsconfig.tsbuildinfo +1 -0
  26. package/out/frame.d.ts +40 -23
  27. package/out/frame.d.ts.map +1 -1
  28. package/out/frame.js +48 -26
  29. package/out/frame.js.map +1 -1
  30. package/out/input.d.ts +18 -36
  31. package/out/input.d.ts.map +1 -1
  32. package/out/input.js +37 -63
  33. package/out/input.js.map +1 -1
  34. package/out/mercator.d.ts +12 -11
  35. package/out/mercator.d.ts.map +1 -1
  36. package/out/mercator.js +4 -10
  37. package/out/mercator.js.map +1 -1
  38. package/out/mvt.js +2 -2
  39. package/out/raster.d.ts +45 -23
  40. package/out/raster.d.ts.map +1 -1
  41. package/out/raster.js +44 -22
  42. package/out/raster.js.map +1 -1
  43. package/out/renderer.d.ts +6 -7
  44. package/out/renderer.d.ts.map +1 -1
  45. package/out/renderer.js +13 -22
  46. package/out/renderer.js.map +1 -1
  47. package/out/style.d.ts +17 -11
  48. package/out/style.d.ts.map +1 -1
  49. package/out/style.js +11 -9
  50. package/out/style.js.map +1 -1
  51. package/out/tile-source.d.ts +12 -8
  52. package/out/tile-source.d.ts.map +1 -1
  53. package/out/tile-source.js +21 -14
  54. package/out/tile-source.js.map +1 -1
  55. package/out/tsconfig.test.tsbuildinfo +1 -1
  56. package/package.json +35 -83
  57. package/out/cli/index.d.ts.map +0 -1
  58. 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, not side effects.
9
- Rendering never writes to stdout, an Ink component, or any other presentation
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
- 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:
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
  ```
@@ -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: it takes the argv slice and an environment record, and answers with a discriminated result
13
- * (`help` / `version` / `browse`) or throws {@link CLIArgsError}. Reading `process.argv` / `process.env` is the bin's
14
- * job (./cli.ts), which keeps every rejection path testable without a subprocess.
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, so a planet archive opens on
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. The archive's own `maxZoom` clamps further at runtime;
40
- * this is only the range a flag value must fall inside to be meaningful at all.
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
- // True geographic bounds, not Web-Mercator's ±85.05113: the flag accepts any real latitude, and the browser clamps
45
- // the CENTER to the projection's MERCATOR_LATITUDE_LIMIT itself (see ./browser.ts) — rejecting 87 here would refuse a
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. The renderer draws whole tile-pyramid levels, so a fractional flag value is rounded here rather
65
- * than carried as a lie through the viewport.
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. The message is user-facing: it says what was wrong AND what to pass instead, since the bin
74
- * prints it verbatim to stderr.
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. Passed in rather than read here so the parser stays pure.
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. It doubles as the package's key reference, so the bindings listed here and the ones ./input.ts
89
- * decodes are the same list said twice — a key added there without a line here is a key nobody finds.
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 (empty string, whitespace,
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 ${JSON.stringify(raw)}`)
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 whose value is a number, and may therefore start with a dash.
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 — it cannot tell a negative number from a
146
- * mistyped flag, and says so ("argument is ambiguous"). Half the planet has a negative longitude, so the space-form has
147
- * to work. The join is conditional on the next token parsing as a finite number, which leaves a genuinely missing value
148
- * (`--lon --zoom 3`) to `parseArgs` and its own error.
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 remedy, so they're re-thrown as
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, or a missing archive path.
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
- * The `map-tui` bin — `npx @mailwoman/map-tui` opens a PMTiles archive as a full-screen terminal map.
10
+ * Opens a PMTiles archive as a full-screen terminal map through the `map-tui` binary.
11
11
  *
12
- * This file owns everything process-shaped so the rest of the package stays testable without one: argv and the
13
- * environment (parsed by ./cli-args.ts), the archive handle, signal handlers, and the exit code. `MapBrowser` takes
14
- * streams rather than reaching for `process` itself, which is what lets the PTY smoke test drive the real bin and a
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's own version.
30
+ * Reads the package version.
37
31
  *
38
- * Self-reference (`@mailwoman/map-tui/...`, which the package's `exports` publishes) rather than a path relative to
39
- * this file: the bin runs from `out/` when installed and from the workspace root in development, and only the package
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, translating the filesystem's error into something that names the flag that was wrong.
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 against an already-open archive, resolving with its exit code.
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
- * `parseCLIArgs` keeps argv and the environment read on this one line, and nowhere else in the package —
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
- // A bad `--tiles` path is a usage error, not a crash — the guard stays around the open, and ownership passes to the
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
- * 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.
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 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.
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 drives.
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
- * 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.
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 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.
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 process exit code (0 for a normal quit, 130 for Ctrl+C). The terminal
196
- * is restored before this resolves.
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. Safe to call from a signal handler, and a no-op once an exit is already under
212
- * way.
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, 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.
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 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.
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 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.
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 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.
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 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.
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
- // `||` and not `??`: a pty with no window size reports 0, which is as unusable as absent.
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. 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.
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
- // The terminal may have been restored while the tiles were in flight; writing then would paint over the
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 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.
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
- // Attribution is a courtesy to the tile source, never a reason to push the controls off the bar.
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
- // One cell short of the width: writing the bottom-right cell leaves some terminals in a pending-wrap state.
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