@mailwoman/map-tui 9.1.1 → 9.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +59 -0
  2. package/lib/browser.ts +525 -0
  3. package/lib/cli-args.ts +233 -0
  4. package/lib/cli.ts +141 -0
  5. package/{frame.ts → lib/frame.ts} +33 -6
  6. package/lib/index.ts +13 -0
  7. package/lib/input.ts +288 -0
  8. package/{mercator.ts → lib/mercator.ts} +20 -0
  9. package/{mvt.ts → lib/mvt.ts} +2 -2
  10. package/{raster.ts → lib/raster.ts} +1 -1
  11. package/{renderer.ts → lib/renderer.ts} +86 -36
  12. package/lib/style.ts +115 -0
  13. package/{tile-source.ts → lib/tile-source.ts} +76 -20
  14. package/out/browser.d.ts +126 -0
  15. package/out/browser.d.ts.map +1 -0
  16. package/out/browser.js +373 -0
  17. package/out/browser.js.map +1 -0
  18. package/out/cli-args.d.ts +47 -0
  19. package/out/cli-args.d.ts.map +1 -0
  20. package/out/cli-args.js +171 -0
  21. package/out/cli-args.js.map +1 -0
  22. package/out/cli.d.ts +8 -0
  23. package/out/cli.d.ts.map +1 -0
  24. package/out/cli.js +119 -0
  25. package/out/cli.js.map +1 -0
  26. package/out/frame.d.ts +20 -2
  27. package/out/frame.d.ts.map +1 -1
  28. package/out/frame.js +28 -3
  29. package/out/frame.js.map +1 -1
  30. package/out/index.d.ts +7 -7
  31. package/out/index.d.ts.map +1 -1
  32. package/out/index.js +7 -7
  33. package/out/index.js.map +1 -1
  34. package/out/input.d.ts +93 -0
  35. package/out/input.d.ts.map +1 -0
  36. package/out/input.js +214 -0
  37. package/out/input.js.map +1 -0
  38. package/out/mercator.d.ts +13 -0
  39. package/out/mercator.d.ts.map +1 -1
  40. package/out/mercator.js +16 -0
  41. package/out/mercator.js.map +1 -1
  42. package/out/mvt.d.ts.map +1 -1
  43. package/out/mvt.js +2 -2
  44. package/out/mvt.js.map +1 -1
  45. package/out/raster.d.ts +1 -1
  46. package/out/raster.d.ts.map +1 -1
  47. package/out/raster.js.map +1 -1
  48. package/out/renderer.d.ts +11 -14
  49. package/out/renderer.d.ts.map +1 -1
  50. package/out/renderer.js +46 -26
  51. package/out/renderer.js.map +1 -1
  52. package/out/style.d.ts +22 -11
  53. package/out/style.d.ts.map +1 -1
  54. package/out/style.js +49 -4
  55. package/out/style.js.map +1 -1
  56. package/out/tile-source.d.ts +25 -4
  57. package/out/tile-source.d.ts.map +1 -1
  58. package/out/tile-source.js +46 -14
  59. package/out/tile-source.js.map +1 -1
  60. package/out/tsconfig.test.tsbuildinfo +1 -1
  61. package/package.json +105 -6
  62. package/index.ts +0 -13
  63. package/out/tsconfig.tsbuildinfo +0 -1
  64. package/style.ts +0 -62
package/lib/input.ts ADDED
@@ -0,0 +1,288 @@
1
+ /**
2
+ * @copyright Sister Software.
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ */
6
+
7
+ /**
8
+ * Terminal input decoding for the interactive map browser.
9
+ *
10
+ * `decodeInputChunk` turns a raw-mode stdin chunk into zero or more {@link MapTUIInput} events. It stays pure — the
11
+ * caller owns the only state there is, the unresolved trailing fragment the function hands back — so one chunk in gives
12
+ * the same events out every time.
13
+ *
14
+ * THE FALLBACK IS THE DANGEROUS PART. An ESC this decoder had no rule for used to mean "the Esc key", i.e. QUIT, which
15
+ * made every unrecognized escape sequence a quit: F1 (`ESC O P`), an OSC reply the terminal sends unasked (`ESC ] 11 ;
16
+ * rgb:… BEL`), and — worst, because it needs no exotic key at all — a mouse report split across two stdin reads, whose
17
+ * first half ends inside the sequence. So the fallback now separates three cases:
18
+ *
19
+ * - A sequence this decoder recognizes is consumed and acted on (arrows, SGR mouse).
20
+ * - A sequence it does NOT recognize is consumed WHOLE and ignored: CSI (`ESC [ … final`), SS3 (`ESC O final`), and the
21
+ * string family (OSC/DCS/SOS/PM/APC, terminated by BEL or ST). Re-scanning their bodies as characters is how a `q`
22
+ * inside a cursor-position report quit the app.
23
+ * - A chunk that ENDS mid-sequence — including a lone trailing ESC, which is byte-for-byte the start of one — is not
24
+ * decoded at all: it comes back as {@link DecodedInput.pending} for the caller to prepend to the next chunk. Quit is
25
+ * emitted only for an ESC that is neither, i.e. one whose following byte cannot continue a sequence.
26
+ *
27
+ * Holding costs a lone Esc keypress its effect until the next byte arrives. That is the right side of the trade for a
28
+ * browser whose advertised quit keys are `q` and Ctrl+C: the alternative is a drag ending the session because the
29
+ * kernel split a read.
30
+ *
31
+ * Mouse reports are SGR-encoded (DEC private mode 1006), which is what {@link MOUSE_ENABLE} asks for. Their coordinates
32
+ * are 1-based on the wire and 0-based in every event here — the off-by-one lives at this boundary and nowhere else.
33
+ */
34
+
35
+ /**
36
+ * Enables mouse reporting: button events (1000), drag/button-motion tracking (1002), and SGR extended coordinates
37
+ * (1006) so columns past 223 survive.
38
+ */
39
+ export const MOUSE_ENABLE = "\u001B[?1000h\u001B[?1002h\u001B[?1006h"
40
+
41
+ /**
42
+ * Disables mouse reporting, in the reverse order it was enabled.
43
+ */
44
+ export const MOUSE_DISABLE = "\u001B[?1006l\u001B[?1002l\u001B[?1000l"
45
+
46
+ /**
47
+ * A decoded input event. Pan and zoom carry direction and magnitude only — how far a step moves the map is the
48
+ * browser's decision, not the decoder's.
49
+ */
50
+ export type MapTUIInput =
51
+ | { kind: "quit" }
52
+ /**
53
+ * Ctrl+C. Distinct from `quit` because the process must exit 130, and because raw mode means no SIGINT is raised.
54
+ */
55
+ | { kind: "interrupt" }
56
+ | { kind: "pan"; dx: number; dy: number }
57
+ | { kind: "zoom"; delta: number }
58
+ | { kind: "wheel"; delta: number; column: number; row: number }
59
+ | { kind: "press"; column: number; row: number }
60
+ | { kind: "drag"; column: number; row: number }
61
+ | { kind: "release" }
62
+
63
+ const ESC = "\u001B"
64
+ const CTRL_C = "\u0003"
65
+
66
+ /* oxlint-disable no-control-regex -- ESC (U+001B) is the byte every pattern below exists to match. A decoder of
67
+ terminal escape sequences cannot avoid the control character the sequences are made of. */
68
+
69
+ /**
70
+ * SGR mouse report: `ESC [ < button ; column ; row (M|m)`, where `M` is a press/motion and `m` a release.
71
+ */
72
+ const MOUSE_SGR_PATTERN = /\u001B\[<(\d+);(\d+);(\d+)([Mm])/y
73
+
74
+ /**
75
+ * Cursor keys, in both normal (`ESC [ A`) and application (`ESC O A`) modes — a terminal may be left in either.
76
+ */
77
+ const ARROW_PATTERN = /\u001B(?:\[|O)([ABCD])/y
78
+
79
+ /**
80
+ * Any other CSI sequence, consumed whole and ignored. Without this, an unhandled sequence's body would be re-scanned as
81
+ * individual key presses, and a stray `q` inside one would quit the app.
82
+ */
83
+ const UNKNOWN_CSI_PATTERN = /\u001B\[[\d;<>?]*[\u0020-\u002F]*[\u0040-\u007E]/y
84
+
85
+ /**
86
+ * Any other SS3 sequence (`ESC O <final>`) — F1–F4 on xterm, and the numeric keypad in application mode. Two bytes
87
+ * shorter than a CSI, and until this pattern existed the likeliest key on a keyboard to quit the browser by accident.
88
+ */
89
+ const UNKNOWN_SS3_PATTERN = /\u001BO[\u0040-\u007E]/y
90
+
91
+ /**
92
+ * The string-sequence family: OSC (`ESC ]`), DCS (`ESC P`), SOS (`ESC X`), PM (`ESC ^`), APC (`ESC _`), each running to
93
+ * a BEL or an ST (`ESC \`). A terminal sends these UNASKED — an OSC colour or clipboard reply lands on stdin with no
94
+ * key pressed — so consuming them is not a nicety.
95
+ */
96
+ const STRING_SEQUENCE_PATTERN = /\u001B[P\]X^_][\s\S]*?(?:\u0007|\u001B\\)/y
97
+
98
+ /**
99
+ * Every "unrecognized but complete" sequence, in the order they are tried. Sharing one list is what keeps a new
100
+ * sequence family from being added to the consumer and forgotten in the incomplete test below.
101
+ */
102
+ const UNRECOGNIZED_PATTERNS = [UNKNOWN_CSI_PATTERN, UNKNOWN_SS3_PATTERN, STRING_SEQUENCE_PATTERN] as const
103
+
104
+ /**
105
+ * A chunk that STOPS inside a sequence. The end-anchors are what make these "incomplete" rather than "unrecognized":
106
+ * each requires the WHOLE remainder of the chunk to be a legal prefix and nothing more. The first covers both a lone
107
+ * trailing ESC and an `ESC O` still waiting for its final byte.
108
+ */
109
+ const PARTIAL_PATTERNS = [/\u001BO?$/y, /\u001B\[[\d;<>?]*[\u0020-\u002F]*$/y, /\u001B[P\]X^_][^\u0007]*$/y] as const
110
+
111
+ /* oxlint-enable no-control-regex */
112
+
113
+ /**
114
+ * Wheel reports set bit 6 of the button field; the low bit then separates up (0) from down (1).
115
+ */
116
+ const WHEEL_FLAG = 64
117
+
118
+ /**
119
+ * Motion reports set bit 5. With mode 1002 that means "moved with a button held" — a drag.
120
+ */
121
+ const MOTION_FLAG = 32
122
+
123
+ const BUTTON_MASK = 3
124
+ const LEFT_BUTTON = 0
125
+
126
+ /**
127
+ * The longest fragment worth holding for the next chunk (64 KB). See the drop site: this bounds an unterminated string
128
+ * sequence, not a real key.
129
+ */
130
+ const MAX_PENDING_LENGTH = 65_536
131
+
132
+ const ARROW_INPUTS: Record<string, MapTUIInput> = {
133
+ A: { kind: "pan", dx: 0, dy: -1 },
134
+ B: { kind: "pan", dx: 0, dy: 1 },
135
+ C: { kind: "pan", dx: 1, dy: 0 },
136
+ D: { kind: "pan", dx: -1, dy: 0 },
137
+ }
138
+
139
+ /**
140
+ * Single-character bindings. `a`/`z` are mapscii's zoom keys, `+`/`-` the ones every other map uses, and `hjkl` the vim
141
+ * pan set mapscii also accepts. `y` joins `z` for zoom-out because on a QWERTZ keyboard it sits where `z` does on
142
+ * QWERTY — mapscii binds both for the same reason.
143
+ */
144
+ const CHARACTER_INPUTS: Record<string, MapTUIInput> = {
145
+ q: { kind: "quit" },
146
+ Q: { kind: "quit" },
147
+ "+": { kind: "zoom", delta: 1 },
148
+ "=": { kind: "zoom", delta: 1 },
149
+ a: { kind: "zoom", delta: 1 },
150
+ "-": { kind: "zoom", delta: -1 },
151
+ _: { kind: "zoom", delta: -1 },
152
+ z: { kind: "zoom", delta: -1 },
153
+ y: { kind: "zoom", delta: -1 },
154
+ h: { kind: "pan", dx: -1, dy: 0 },
155
+ j: { kind: "pan", dx: 0, dy: 1 },
156
+ k: { kind: "pan", dx: 0, dy: -1 },
157
+ l: { kind: "pan", dx: 1, dy: 0 },
158
+ }
159
+
160
+ /**
161
+ * One decoded chunk: its events, plus whatever trailing bytes could not be decoded YET.
162
+ */
163
+ export interface DecodedInput {
164
+ events: MapTUIInput[]
165
+ /**
166
+ * An unresolved escape fragment from the end of the chunk — prepend it to the next one. Empty when the chunk ended
167
+ * cleanly, which is the overwhelmingly common case.
168
+ */
169
+ pending: string
170
+ }
171
+
172
+ /**
173
+ * The length of the unrecognized-but-complete sequence at `index`, or null when there isn't one.
174
+ */
175
+ function consumeUnrecognized(buffer: string, index: number): number | null {
176
+ for (const pattern of UNRECOGNIZED_PATTERNS) {
177
+ pattern.lastIndex = index
178
+
179
+ if (pattern.exec(buffer)) return pattern.lastIndex
180
+ }
181
+
182
+ return null
183
+ }
184
+
185
+ /**
186
+ * True when everything from `index` to the end of the chunk is a legal PREFIX of a sequence — i.e. the terminal is
187
+ * mid-sequence and the rest is in the next read.
188
+ */
189
+ function isIncompleteSequence(buffer: string, index: number): boolean {
190
+ for (const pattern of PARTIAL_PATTERNS) {
191
+ pattern.lastIndex = index
192
+
193
+ if (pattern.exec(buffer)) return true
194
+ }
195
+
196
+ return false
197
+ }
198
+
199
+ /**
200
+ * Builds the event for one SGR mouse report.
201
+ */
202
+ function mouseInput(button: number, column: number, row: number, final: string): MapTUIInput | null {
203
+ if (button & WHEEL_FLAG) {
204
+ return { kind: "wheel", delta: button & 1 ? -1 : 1, column, row }
205
+ }
206
+
207
+ if (final === "m") return { kind: "release" }
208
+
209
+ if ((button & BUTTON_MASK) !== LEFT_BUTTON) return null
210
+
211
+ return button & MOTION_FLAG ? { kind: "drag", column, row } : { kind: "press", column, row }
212
+ }
213
+
214
+ /**
215
+ * Decodes one raw-mode stdin chunk into input events. Unrecognized bytes are dropped; an unresolved trailing escape
216
+ * fragment is returned rather than decoded, and the caller passes it back as `pending` with the next chunk.
217
+ */
218
+ export function decodeInputChunk(chunk: string, pending = ""): DecodedInput {
219
+ const events: MapTUIInput[] = []
220
+ const buffer = pending + chunk
221
+ let index = 0
222
+
223
+ while (index < buffer.length) {
224
+ const character = buffer[index]!
225
+
226
+ if (character !== ESC) {
227
+ const input = character === CTRL_C ? { kind: "interrupt" as const } : CHARACTER_INPUTS[character]
228
+
229
+ if (input) {
230
+ events.push(input)
231
+ }
232
+
233
+ index += 1
234
+
235
+ continue
236
+ }
237
+
238
+ MOUSE_SGR_PATTERN.lastIndex = index
239
+ const mouse = MOUSE_SGR_PATTERN.exec(buffer)
240
+
241
+ if (mouse) {
242
+ const input = mouseInput(Number(mouse[1]), Number(mouse[2]) - 1, Number(mouse[3]) - 1, mouse[4]!)
243
+
244
+ if (input) {
245
+ events.push(input)
246
+ }
247
+
248
+ index = MOUSE_SGR_PATTERN.lastIndex
249
+
250
+ continue
251
+ }
252
+
253
+ ARROW_PATTERN.lastIndex = index
254
+ const arrow = ARROW_PATTERN.exec(buffer)
255
+
256
+ if (arrow) {
257
+ events.push(ARROW_INPUTS[arrow[1]!]!)
258
+ index = ARROW_PATTERN.lastIndex
259
+
260
+ continue
261
+ }
262
+
263
+ const consumed = consumeUnrecognized(buffer, index)
264
+
265
+ if (consumed !== null) {
266
+ index = consumed
267
+
268
+ continue
269
+ }
270
+
271
+ // The buffer stops inside a sequence — hand the fragment back instead of guessing at it.
272
+ if (isIncompleteSequence(buffer, index)) {
273
+ const fragment = buffer.slice(index)
274
+
275
+ // …unless it has stopped being plausible. An unterminated string sequence would otherwise grow the held
276
+ // fragment for the life of the process. Dropping is the safe failure: it emits nothing, where flushing
277
+ // the fragment back through the decoder would read its body as keys, which is the bug this all exists
278
+ // for. The cap is generous because an OSC 52 clipboard reply is legitimately large.
279
+ return { events, pending: fragment.length > MAX_PENDING_LENGTH ? "" : fragment }
280
+ }
281
+
282
+ // An ESC whose next byte cannot continue a sequence: the Esc KEY.
283
+ events.push({ kind: "quit" })
284
+ index += 1
285
+ }
286
+
287
+ return { events, pending: "" }
288
+ }
@@ -53,3 +53,23 @@ const EARTH_CIRCUMFERENCE_M = 40_075_016.686
53
53
  export function metersPerPixel(lat: number, zoom: number): number {
54
54
  return (EARTH_CIRCUMFERENCE_M * Math.cos((lat * Math.PI) / 180)) / (TILE_SIZE * 2 ** zoom)
55
55
  }
56
+
57
+ /**
58
+ * Wraps a longitude into [-180, 180) so panning past the antimeridian continues rather than running off the pyramid.
59
+ */
60
+ export function wrapLongitude(lon: number): number {
61
+ const wrapped = (((lon + 180) % 360) + 360) % 360
62
+
63
+ return wrapped - 180
64
+ }
65
+
66
+ /**
67
+ * Subpixel dimensions of one braille cell — 2 columns wide, 4 rows tall. The unit every projection-to-cell conversion
68
+ * works in, shared by the renderer and the browser so both sides of the frame boundary agree on the grid.
69
+ */
70
+ export const SUBPIXEL_COLUMNS_PER_CELL = 2
71
+
72
+ /**
73
+ * See {@link SUBPIXEL_COLUMNS_PER_CELL} — the vertical half of the braille 2×4 subpixel grid.
74
+ */
75
+ export const SUBPIXEL_ROWS_PER_CELL = 4
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { VectorTile } from "@mapbox/vector-tile"
15
- import Pbf from "pbf"
15
+ import { PbfReader } from "pbf"
16
16
 
17
17
  export interface DecodedFeature {
18
18
  /**
@@ -34,7 +34,7 @@ export interface DecodedLayer {
34
34
  }
35
35
 
36
36
  export function decodeMVT(data: Uint8Array): DecodedLayer[] {
37
- const tile = new VectorTile(new Pbf(data))
37
+ const tile = new VectorTile(new PbfReader(data))
38
38
 
39
39
  return Object.entries(tile.layers).map(([name, layer]) => {
40
40
  const features: DecodedFeature[] = []
@@ -13,7 +13,7 @@
13
13
  * {@link fillPolygon} does not — its scanline edge math keeps ring vertices as given, see its own docstring.
14
14
  */
15
15
 
16
- import type { RGB } from "./style.ts"
16
+ import type { RGB } from "#style"
17
17
 
18
18
  /**
19
19
  * A row-major RGBA pixel buffer. Alpha starts at 0 (unlit/transparent) everywhere; drawing a pixel sets it to 255,
@@ -15,12 +15,20 @@
15
15
  * requested marker always wins the cell.
16
16
  */
17
17
 
18
- import { type MapFrame, overlayText, rasterizeToFrame, rgbToPacked } from "./frame.ts"
19
- import { lonLatToWorldPx, metersPerPixel, TILE_SIZE } from "./mercator.ts"
20
- import type { DecodedFeature } from "./mvt.ts"
21
- import { drawCircle, drawPolyline, fillPolygon, RGBAGrid } from "./raster.ts"
22
- import { type LayerStyle, type RGB, stylesFor } from "./style.ts"
23
- import type { DecodedTile, TileSource } from "./tile-source.ts"
18
+ import { clamp } from "@mailwoman/core/numeric"
19
+
20
+ import { type MapFrame, overlayText, rasterizeToFrame, rgbToPacked } from "#frame"
21
+ import {
22
+ lonLatToWorldPx,
23
+ metersPerPixel,
24
+ SUBPIXEL_COLUMNS_PER_CELL,
25
+ SUBPIXEL_ROWS_PER_CELL,
26
+ TILE_SIZE,
27
+ } from "#mercator"
28
+ import type { DecodedFeature } from "#mvt"
29
+ import { drawCircle, drawPolyline, fillPolygon, RGBAGrid } from "#raster"
30
+ import { type LayerStyle, type RGB, styleForFeatureKind, stylesFor } from "#style"
31
+ import type { DecodedTile, TileProvider } from "#tile-source"
24
32
 
25
33
  export interface Viewport {
26
34
  centerLon: number
@@ -46,12 +54,6 @@ export interface RingSpec {
46
54
  const DEFAULT_MARKER_CHAR = "●"
47
55
  const DEFAULT_MARKER_COLOR: RGB = [255, 80, 80]
48
56
 
49
- /**
50
- * Subpixel dimensions per braille cell: 2 columns wide, 4 rows tall.
51
- */
52
- const SUBPIXEL_COLUMNS_PER_CELL = 2
53
- const SUBPIXEL_ROWS_PER_CELL = 4
54
-
55
57
  /**
56
58
  * Minimum ring radius (in device pixels) worth drawing — smaller than this, the midpoint circle algorithm degenerates
57
59
  * to a single point or nothing useful.
@@ -84,22 +86,35 @@ interface GridOrigin {
84
86
  * — that's what keeps `rasterizeFeature` under `max-params` (8) once `style`, `renderZoom`, and `pendingLabels` join
85
87
  * it. Threaded through as a value rather than closed over so those rasterizers stay free functions (no nesting inside
86
88
  * `renderFrame` deep enough to trip `max-depth`).
89
+ *
90
+ * `worldX`/`worldY` are the tile's top-left corner in render-zoom world pixels — for a native tile that is `tileX *
91
+ * TILE_SIZE`, for an overzoomed parent it is scaled by the tile's span, so the projection needs no zoom arithmetic of
92
+ * its own.
87
93
  */
88
94
  interface TileProjection {
89
- tileX: number
90
- tileY: number
95
+ worldX: number
96
+ worldY: number
91
97
  scale: number
92
98
  origin: GridOrigin
93
99
  }
94
100
 
95
- function clamp(value: number, min: number, max: number): number {
96
- return Math.min(Math.max(value, min), max)
101
+ /**
102
+ * A tile chosen for a viewport slot: the native tile when the archive has one, otherwise the nearest ancestor that
103
+ * exists. `span` is how many render-zoom world pixels the tile covers (`TILE_SIZE << dz` for an ancestor `dz` levels
104
+ * up) — a spatially sparse archive (deep zooms only where people are) degrades to coarse geometry instead of blank
105
+ * cells.
106
+ */
107
+ interface ResolvedTile {
108
+ tile: DecodedTile
109
+ tileX: number
110
+ tileY: number
111
+ span: number
97
112
  }
98
113
 
99
114
  function projectPoint(projection: TileProjection, gx: number, gy: number): ProjectedPoint {
100
115
  return {
101
- x: projection.tileX * TILE_SIZE + gx * projection.scale - projection.origin.x,
102
- y: projection.tileY * TILE_SIZE + gy * projection.scale - projection.origin.y,
116
+ x: projection.worldX + gx * projection.scale - projection.origin.x,
117
+ y: projection.worldY + gy * projection.scale - projection.origin.y,
103
118
  }
104
119
  }
105
120
 
@@ -178,23 +193,28 @@ function rasterizeFeature(
178
193
  */
179
194
  function rasterizeTileForKind(
180
195
  grid: RGBAGrid,
181
- tile: DecodedTile,
182
- tileX: number,
183
- tileY: number,
196
+ resolved: ResolvedTile,
184
197
  kind: LayerStyle["kind"],
185
198
  renderZoom: number,
186
199
  origin: GridOrigin,
187
200
  pendingLabels: PendingLabel[]
188
201
  ): void {
189
- for (const layer of tile.layers) {
202
+ for (const layer of resolved.tile.layers) {
190
203
  const styles = stylesFor(layer.name, renderZoom).filter((style) => style.kind === kind)
191
204
 
192
205
  if (!styles.length) continue
193
206
 
194
- const projection: TileProjection = { tileX, tileY, scale: TILE_SIZE / layer.extent, origin }
207
+ const projection: TileProjection = {
208
+ worldX: resolved.tileX * resolved.span,
209
+ worldY: resolved.tileY * resolved.span,
210
+ scale: resolved.span / layer.extent,
211
+ origin,
212
+ }
213
+
214
+ for (const feature of layer.features) {
215
+ const style = styleForFeatureKind(styles, feature.properties.kind)
195
216
 
196
- for (const style of styles) {
197
- for (const feature of layer.features) {
217
+ if (style) {
198
218
  rasterizeFeature(grid, feature, style, renderZoom, projection, pendingLabels)
199
219
  }
200
220
  }
@@ -207,12 +227,48 @@ function rasterizeTileForKind(
207
227
  * `TileSource` — it holds no per-frame state itself.
208
228
  */
209
229
  export class MapRenderer {
210
- private readonly source: TileSource
230
+ private readonly source: TileProvider
211
231
 
212
- constructor(source: TileSource) {
232
+ constructor(source: TileProvider) {
213
233
  this.source = source
214
234
  }
215
235
 
236
+ /**
237
+ * Resolves each viewport slot to its native tile or, when the archive has none — a spatially sparse deep band, a
238
+ * zoom-capped extract — the nearest existing ancestor. Ancestors shared by several absent slots are deduplicated so
239
+ * their geometry rasterizes once, and coarse tiles sort first so native detail paints over the fallback wherever both
240
+ * cover a cell.
241
+ */
242
+ private async resolveTiles(
243
+ tileCoords: ReadonlyArray<{ x: number; y: number }>,
244
+ renderZoom: number
245
+ ): Promise<ResolvedTile[]> {
246
+ const resolved = new Map<string, ResolvedTile>()
247
+
248
+ await Promise.all(
249
+ tileCoords.map(async ({ x, y }) => {
250
+ for (let zoom = renderZoom; zoom >= this.source.minZoom; zoom--) {
251
+ const shift = renderZoom - zoom
252
+ const tileX = x >> shift
253
+ const tileY = y >> shift
254
+ const key = `${zoom}/${tileX}/${tileY}`
255
+
256
+ if (resolved.has(key)) return
257
+
258
+ const tile = await this.source.getTile(zoom, tileX, tileY)
259
+
260
+ if (tile) {
261
+ resolved.set(key, { tile, tileX, tileY, span: TILE_SIZE * 2 ** shift })
262
+
263
+ return
264
+ }
265
+ }
266
+ })
267
+ )
268
+
269
+ return [...resolved.values()].toSorted((a, b) => b.span - a.span)
270
+ }
271
+
216
272
  async renderFrame(viewport: Viewport, overlays?: { markers?: MarkerSpec[]; ring?: RingSpec }): Promise<MapFrame> {
217
273
  const { centerLon, centerLat, columns, rows } = viewport
218
274
 
@@ -237,20 +293,14 @@ export class MapRenderer {
237
293
  }
238
294
  }
239
295
 
240
- const tiles = await Promise.all(tileCoords.map((coord) => this.source.getTile(renderZoom, coord.x, coord.y)))
296
+ const resolvedTiles = await this.resolveTiles(tileCoords, renderZoom)
241
297
 
242
298
  const grid = new RGBAGrid(subpixelW, subpixelH)
243
299
  const pendingLabels: PendingLabel[] = []
244
300
 
245
301
  for (const kind of ["fill", "line", "label"] as const) {
246
- for (let i = 0; i < tileCoords.length; i++) {
247
- const tile = tiles[i]
248
-
249
- if (!tile) continue
250
-
251
- const { x: tileX, y: tileY } = tileCoords[i]!
252
-
253
- rasterizeTileForKind(grid, tile, tileX, tileY, kind, renderZoom, origin, pendingLabels)
302
+ for (const resolved of resolvedTiles) {
303
+ rasterizeTileForKind(grid, resolved, kind, renderZoom, origin, pendingLabels)
254
304
  }
255
305
  }
256
306
 
package/lib/style.ts ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @copyright Sister Software.
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ */
6
+
7
+ /**
8
+ * Protomaps-basemap style table for the map-tui debug view.
9
+ *
10
+ * Defines fill, line, and label styles for each of the nine protomaps-basemap layers, restricted by zoom level. Color
11
+ * palette calibrated for dark-terminal rendering: dim fills (read as stipple density via dithering), bright lines and
12
+ * labels.
13
+ */
14
+
15
+ export type RGB = readonly [red: number, green: number, blue: number]
16
+
17
+ /**
18
+ * Fields shared by every style entry. `featureKinds` scopes an entry to features whose `kind` tile attribute is in the
19
+ * list; an entry without it is the layer's catch-all. Entries are consulted in table order and the first match wins, so
20
+ * kind-scoped entries precede the catch-all.
21
+ */
22
+ interface StyleBase {
23
+ color: RGB
24
+ minZoom: number
25
+ featureKinds?: readonly string[]
26
+ }
27
+
28
+ export interface FillStyle extends StyleBase {
29
+ kind: "fill"
30
+ }
31
+
32
+ export interface LineStyle extends StyleBase {
33
+ kind: "line"
34
+ width: (zoom: number) => number
35
+ }
36
+
37
+ export interface LabelStyle extends StyleBase {
38
+ kind: "label"
39
+ property: string
40
+ }
41
+
42
+ export type LayerStyle = FillStyle | LineStyle | LabelStyle
43
+
44
+ // Zoom level at which roads render with 2px width instead of 1px.
45
+ const ROAD_WIDTH_THRESHOLD = 14
46
+
47
+ const roadWidth = (zoom: number) => (zoom >= ROAD_WIDTH_THRESHOLD ? 2 : 1)
48
+
49
+ // At braille scale every road is one dot wide and every fill is stipple, so CLASS has to ride on color instead of
50
+ // geometry: arteries brighten toward amber, paths dim toward the vegetation green, and urban landuse runs warmer and
51
+ // brighter than vegetation so a city reads as denser texture. Luminance is required — asciify's ordered dither
52
+ // turns it into stipple density.
53
+ const VEGETATION_KINDS = [
54
+ "allotments",
55
+ "cemetery",
56
+ "farmland",
57
+ "forest",
58
+ "garden",
59
+ "grass",
60
+ "meadow",
61
+ "nature_reserve",
62
+ "park",
63
+ "scrub",
64
+ "wetland",
65
+ "wood",
66
+ ] as const
67
+
68
+ const STYLE_TABLE: Record<string, LayerStyle[]> = {
69
+ earth: [{ kind: "fill", color: [40, 44, 36], minZoom: 0 }],
70
+ landcover: [{ kind: "fill", color: [36, 52, 32], minZoom: 4 }],
71
+ landuse: [
72
+ { kind: "fill", color: [66, 60, 52], minZoom: 10, featureKinds: ["residential"] },
73
+ { kind: "fill", color: [72, 62, 50], minZoom: 10, featureKinds: ["commercial"] },
74
+ { kind: "fill", color: [58, 60, 64], minZoom: 10, featureKinds: ["industrial"] },
75
+ { kind: "fill", color: [36, 52, 32], minZoom: 10, featureKinds: VEGETATION_KINDS },
76
+ { kind: "fill", color: [48, 48, 40], minZoom: 10 },
77
+ ],
78
+ water: [{ kind: "fill", color: [24, 48, 90], minZoom: 0 }],
79
+ buildings: [{ kind: "fill", color: [70, 66, 60], minZoom: 13 }],
80
+ boundaries: [{ kind: "line", color: [140, 110, 160], minZoom: 0, width: () => 1 }],
81
+ roads: [
82
+ { kind: "line", color: [255, 190, 90], minZoom: 6, featureKinds: ["highway"], width: roadWidth },
83
+ { kind: "line", color: [215, 210, 180], minZoom: 6, featureKinds: ["major_road"], width: roadWidth },
84
+ { kind: "line", color: [185, 182, 158], minZoom: 6, featureKinds: ["medium_road"], width: roadWidth },
85
+ { kind: "line", color: [150, 150, 132], minZoom: 6, featureKinds: ["minor_road"], width: roadWidth },
86
+ { kind: "line", color: [105, 115, 98], minZoom: 6, featureKinds: ["path"], width: roadWidth },
87
+ { kind: "line", color: [115, 125, 140], minZoom: 6, featureKinds: ["rail"], width: () => 1 },
88
+ { kind: "line", color: [170, 170, 150], minZoom: 6, width: roadWidth },
89
+ ],
90
+ places: [{ kind: "label", color: [235, 235, 220], minZoom: 2, property: "name" }],
91
+ pois: [{ kind: "label", color: [180, 200, 160], minZoom: 14, property: "name" }],
92
+ }
93
+
94
+ /**
95
+ * Styles applying to a protomaps-basemap layer at a zoom, draw-ordered (fills < lines < labels). Empty for unstyled or
96
+ * zoom-restricted layers.
97
+ */
98
+ export function stylesFor(layerName: string, zoom: number): LayerStyle[] {
99
+ return (STYLE_TABLE[layerName] ?? []).filter((style) => zoom >= style.minZoom)
100
+ }
101
+
102
+ /**
103
+ * The one style painting a feature: the first entry whose `featureKinds` contains the feature's `kind` attribute, or
104
+ * the first catch-all. A feature is painted at most once per draw pass — kind-scoped entries recolor, they never
105
+ * double-paint.
106
+ */
107
+ export function styleForFeatureKind(styles: readonly LayerStyle[], featureKind: unknown): LayerStyle | null {
108
+ for (const style of styles) {
109
+ if (style.featureKinds == null) return style
110
+
111
+ if (typeof featureKind === "string" && style.featureKinds.includes(featureKind)) return style
112
+ }
113
+
114
+ return null
115
+ }