@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
@@ -0,0 +1,233 @@
1
+ import { errorMessage } from "@mailwoman/core/errors/schema"
2
+ import { parseArguments } from "@mailwoman/core/scripting/arguments"
3
+ /**
4
+ * @copyright Sister Software.
5
+ * @license AGPL-3.0
6
+ * @author Teffen Ellis, et al.
7
+ */
8
+
9
+ /**
10
+ * Argument parsing for the `map-tui` bin.
11
+ *
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.
15
+ */
16
+
17
+ /**
18
+ * Center longitude when `--lon` is omitted — the world view mapscii opens on.
19
+ */
20
+ const DEFAULT_LON = 0
21
+
22
+ /**
23
+ * Center latitude when `--lat` is omitted.
24
+ */
25
+ const DEFAULT_LAT = 0
26
+
27
+ /**
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.
30
+ */
31
+ const DEFAULT_ZOOM = 2
32
+
33
+ /**
34
+ * Widest zoom any tile archive uses.
35
+ */
36
+ const MIN_ZOOM = 0
37
+
38
+ /**
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.
41
+ */
42
+ const MAX_ZOOM = 24
43
+
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.
47
+ const MIN_LAT = -90
48
+ const MAX_LAT = 90
49
+ const MIN_LON = -180
50
+ const MAX_LON = 180
51
+
52
+ /**
53
+ * A view request: an archive to read and where to open it.
54
+ */
55
+ export interface BrowseArgs {
56
+ mode: "browse"
57
+ /**
58
+ * Path to the PMTiles archive.
59
+ */
60
+ tiles: string
61
+ lat: number
62
+ lon: number
63
+ /**
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.
66
+ */
67
+ zoom: number
68
+ }
69
+
70
+ export type CLIArgs = { mode: "help" } | { mode: "version" } | BrowseArgs
71
+
72
+ /**
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.
75
+ */
76
+ export class CLIArgsError extends Error {
77
+ override name = "CLIArgsError"
78
+ }
79
+
80
+ /**
81
+ * Environment keys the CLI reads. Passed in rather than read here so the parser stays pure.
82
+ */
83
+ export interface CLIEnvironment {
84
+ MAILWOMAN_TILES?: string | undefined
85
+ }
86
+
87
+ /**
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.
90
+ */
91
+ export const HELP_TEXT = `map-tui — the whole world in your terminal
92
+
93
+ Usage
94
+ npx @mailwoman/map-tui --tiles <archive.pmtiles> [options]
95
+
96
+ Options
97
+ --tiles <path|url> PMTiles archive — local path or https:// URL (default: $MAILWOMAN_TILES)
98
+ --lat <degrees> Initial center latitude, -90..90 (default: ${DEFAULT_LAT})
99
+ --lon <degrees> Initial center longitude, -180..180 (default: ${DEFAULT_LON})
100
+ --zoom <level> Initial zoom, ${MIN_ZOOM}..${MAX_ZOOM} (default: ${DEFAULT_ZOOM})
101
+ -h, --help Print this help and exit
102
+ -v, --version Print the package version and exit
103
+
104
+ Keys
105
+ arrows, hjkl Pan
106
+ +, =, a Zoom in
107
+ -, _, z Zoom out
108
+ q, Esc, Ctrl+C Quit
109
+
110
+ Mouse
111
+ Wheel zooms toward the pointer, drag pans, click centers.
112
+
113
+ Tiles are never bundled with this package. Download a planet or region
114
+ archive from https://protomaps.com/downloads and point --tiles at it.
115
+ `
116
+
117
+ /**
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.
120
+ */
121
+ function numericFlag(name: string, raw: string | undefined, fallback: number, min: number, max: number): number {
122
+ if (raw == null) return fallback
123
+
124
+ const value = Number(raw.trim())
125
+
126
+ if (!Number.isFinite(value) || !raw.trim().length) {
127
+ throw new CLIArgsError(`--${name} expects a number, got ${JSON.stringify(raw)}`)
128
+ }
129
+
130
+ if (value < min || value > max) {
131
+ throw new CLIArgsError(`--${name} must be between ${min} and ${max}, got ${value}`)
132
+ }
133
+
134
+ return value
135
+ }
136
+
137
+ /**
138
+ * Flags whose value is a number, and may therefore start with a dash.
139
+ */
140
+ const NUMERIC_FLAGS = new Set(["--lat", "--lon", "--zoom"])
141
+
142
+ /**
143
+ * Joins `--lon -122.6` into `--lon=-122.6` before `parseArgs` sees it.
144
+ *
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.
149
+ */
150
+ function joinNegativeNumbers(argv: readonly string[]): string[] {
151
+ const joined: string[] = []
152
+
153
+ for (let index = 0; index < argv.length; index++) {
154
+ const token = argv[index]!
155
+ const next = argv[index + 1]
156
+
157
+ if (NUMERIC_FLAGS.has(token) && next?.startsWith("-") && Number.isFinite(Number(next))) {
158
+ joined.push(`${token}=${next}`)
159
+ index += 1
160
+
161
+ continue
162
+ }
163
+
164
+ joined.push(token)
165
+ }
166
+
167
+ return joined
168
+ }
169
+
170
+ interface ParsedFlags {
171
+ tiles?: string | undefined
172
+ lat?: string | undefined
173
+ lon?: string | undefined
174
+ zoom?: string | undefined
175
+ help?: boolean | undefined
176
+ version?: boolean | undefined
177
+ }
178
+
179
+ /**
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`.
182
+ */
183
+ function readFlags(argv: readonly string[]): ParsedFlags {
184
+ try {
185
+ const { values } = parseArguments({
186
+ args: joinNegativeNumbers(argv),
187
+ options: {
188
+ tiles: { type: "string" },
189
+ lat: { type: "string" },
190
+ lon: { type: "string" },
191
+ zoom: { type: "string" },
192
+ help: { type: "boolean", short: "h" },
193
+ version: { type: "boolean", short: "v" },
194
+ },
195
+ allowPositionals: false,
196
+ strict: true,
197
+ })
198
+
199
+ return values
200
+ } catch (error) {
201
+ throw new CLIArgsError(`${errorMessage(error)}\nRun \`map-tui --help\` for the supported flags.`)
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Parses a `map-tui` command line.
207
+ *
208
+ * @throws {CLIArgsError} On an unknown flag, an unparseable or out-of-range number, or a missing archive path.
209
+ */
210
+ export function parseCLIArgs(argv: readonly string[], environment: CLIEnvironment = {}): CLIArgs {
211
+ const values = readFlags(argv)
212
+
213
+ if (values.help) return { mode: "help" }
214
+
215
+ if (values.version) return { mode: "version" }
216
+
217
+ const tiles = (values.tiles ?? environment.MAILWOMAN_TILES ?? "").trim()
218
+
219
+ if (!tiles.length) {
220
+ throw new CLIArgsError(
221
+ "No tile archive: pass --tiles <archive.pmtiles> or set MAILWOMAN_TILES.\n" +
222
+ "Planet and region archives: https://protomaps.com/downloads"
223
+ )
224
+ }
225
+
226
+ return {
227
+ mode: "browse",
228
+ tiles,
229
+ lat: numericFlag("lat", values.lat, DEFAULT_LAT, MIN_LAT, MAX_LAT),
230
+ lon: numericFlag("lon", values.lon, DEFAULT_LON, MIN_LON, MAX_LON),
231
+ zoom: Math.round(numericFlag("zoom", values.zoom, DEFAULT_ZOOM, MIN_ZOOM, MAX_ZOOM)),
232
+ }
233
+ }
package/lib/cli.ts ADDED
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @copyright Sister Software.
5
+ * @license AGPL-3.0
6
+ * @author Teffen Ellis, et al.
7
+ */
8
+
9
+ /**
10
+ * The `map-tui` bin — `npx @mailwoman/map-tui` opens a PMTiles archive as a full-screen terminal map.
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.
21
+ */
22
+
23
+ import { errorMessage } from "@mailwoman/core/errors/schema"
24
+ import { createRequire } from "@mailwoman/core/module/resolvers"
25
+
26
+ import { MapBrowser } from "#browser"
27
+ import { type CLIArgs, CLIArgsError, HELP_TEXT, parseCLIArgs } from "#cli-args"
28
+ import { TileSource } from "#tile-source"
29
+
30
+ /**
31
+ * Exit code for a command line that could not be parsed.
32
+ */
33
+ const EXIT_USAGE = 1
34
+
35
+ /**
36
+ * Reads the package's own version.
37
+ *
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.
41
+ */
42
+ function readVersion(): string {
43
+ const require = createRequire(import.meta.url)
44
+ const manifest = require("@mailwoman/map-tui/package.json") as { version?: string }
45
+
46
+ return manifest.version ?? "0.0.0"
47
+ }
48
+
49
+ /**
50
+ * Opens the archive, translating the filesystem's error into something that names the flag that was wrong.
51
+ */
52
+ async function openTiles(path: string): Promise<TileSource> {
53
+ try {
54
+ return await TileSource.open(path)
55
+ } catch (error) {
56
+ throw new CLIArgsError(
57
+ `Could not open the tile archive at ${path}: ${errorMessage(error)}\n` +
58
+ "Pass --tiles <archive.pmtiles>, or download one from https://protomaps.com/downloads"
59
+ )
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Runs the interactive browser against an already-open archive, resolving with its exit code.
65
+ */
66
+ async function browse(source: TileSource, args: { lat: number; lon: number; zoom: number }): Promise<number> {
67
+ const browser = new MapBrowser({
68
+ source,
69
+ input: process.stdin,
70
+ output: process.stdout,
71
+ lat: args.lat,
72
+ lon: args.lon,
73
+ zoom: args.zoom,
74
+ })
75
+
76
+ const onSignal = (): void => browser.requestExit(130)
77
+ const onExit = (): void => browser.restore()
78
+
79
+ process.on("SIGINT", onSignal)
80
+ process.on("SIGTERM", onSignal)
81
+ process.on("exit", onExit)
82
+
83
+ try {
84
+ return await browser.run()
85
+ } finally {
86
+ browser.restore()
87
+ process.off("SIGINT", onSignal)
88
+ process.off("SIGTERM", onSignal)
89
+ process.off("exit", onExit)
90
+ }
91
+ }
92
+
93
+ async function main(): Promise<number> {
94
+ let args: CLIArgs
95
+
96
+ 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
+ */
102
+ // oxlint-disable-next-line sister-software/no-process-globals -- see above.
103
+ args = parseCLIArgs(process.argv.slice(2), process.env)
104
+ } catch (error) {
105
+ if (!(error instanceof CLIArgsError)) throw error
106
+
107
+ process.stderr.write(`${error.message}\n`)
108
+
109
+ return EXIT_USAGE
110
+ }
111
+
112
+ if (args.mode === "help") {
113
+ process.stdout.write(HELP_TEXT)
114
+
115
+ return 0
116
+ }
117
+
118
+ if (args.mode === "version") {
119
+ process.stdout.write(`${readVersion()}\n`)
120
+
121
+ return 0
122
+ }
123
+
124
+ let opened: TileSource
125
+
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.
128
+ try {
129
+ opened = await openTiles(args.tiles)
130
+ } catch (error) {
131
+ process.stderr.write(`${errorMessage(error)}\n`)
132
+
133
+ return EXIT_USAGE
134
+ }
135
+
136
+ await using source = opened
137
+
138
+ return await browse(source, args)
139
+ }
140
+
141
+ process.exitCode = await main()
@@ -11,15 +11,13 @@
11
11
  * per cell. The braille dither/luminance work is asciify's — `FrameRasterizer` subclasses `AsciifyTerminal` with a
12
12
  * no-op sink purely to reach its protected `_computeBrailleCells`, `_cellChars`, `_cellColors`, so this module never
13
13
  * re-implements the dot math. `frameToANSILines` and `overlayText` then work on the plain `MapFrame` value, with no
14
- * further asciify dependency.
14
+ * further asciify dependency; `blitFrame` is the path back the other way, for callers driving a live terminal.
15
15
  */
16
16
 
17
- import { AsciifyTerminal } from "@sister.software/asciify/tui"
17
+ import { AsciifyTerminal, SGR_RESET } from "@sister.software/asciify/tui"
18
18
 
19
- import type { RGBAGrid } from "./raster.ts"
20
- import type { RGB } from "./style.ts"
21
-
22
- const SGR_RESET = "\u001B[0m"
19
+ import type { RGBAGrid } from "#raster"
20
+ import type { RGB } from "#style"
23
21
 
24
22
  /**
25
23
  * A rendered braille frame: one codepoint and one packed color per cell, row-major.
@@ -168,3 +166,32 @@ export function overlayText(
168
166
  export function rgbToPacked(color: RGB): number {
169
167
  return (color[0] << 16) | (color[1] << 8) | color[2]
170
168
  }
169
+
170
+ /**
171
+ * Codepoint written for a cell the frame left empty. `MapFrame` stores 0 there; `AsciifyTerminal` expects a real
172
+ * character, and normalizes a space to the inkless color itself.
173
+ */
174
+ const SPACE_CODEPOINT = 0x20
175
+
176
+ /**
177
+ * Writes a frame's cells into an `AsciifyTerminal`'s current frame. Call `flush()` afterwards to emit the damage.
178
+ *
179
+ * The frame's packed color is the exact representation asciify canonicalizes truecolor to, so this is a copy and not a
180
+ * conversion — the channels are unpacked here only because {@linkcode AsciifyTerminal.setCell} takes them apart. Cells
181
+ * beyond the terminal's own grid are dropped by `setCell`, so a frame larger than the pane clips rather than throws.
182
+ */
183
+ export function blitFrame(terminal: AsciifyTerminal, frame: MapFrame): void {
184
+ for (let row = 0; row < frame.rows; row++) {
185
+ for (let column = 0; column < frame.columns; column++) {
186
+ const cellIndex = row * frame.columns + column
187
+ const char = frame.chars[cellIndex]!
188
+ const color = frame.colors[cellIndex]!
189
+
190
+ terminal.setCell(column, row, char === 0 ? SPACE_CODEPOINT : char, [
191
+ (color >> 16) & 0xff,
192
+ (color >> 8) & 0xff,
193
+ color & 0xff,
194
+ ])
195
+ }
196
+ }
197
+ }
package/lib/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @copyright Sister Software.
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ */
6
+
7
+ export * from "#frame"
8
+ export * from "#mercator"
9
+ export * from "#mvt"
10
+ export * from "#raster"
11
+ export * from "#renderer"
12
+ export * from "#style"
13
+ export * from "#tile-source"