@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.
- package/README.md +59 -0
- package/lib/browser.ts +525 -0
- package/lib/cli-args.ts +233 -0
- package/lib/cli.ts +141 -0
- package/{frame.ts → lib/frame.ts} +33 -6
- package/lib/index.ts +13 -0
- package/lib/input.ts +288 -0
- package/{mercator.ts → lib/mercator.ts} +20 -0
- package/{mvt.ts → lib/mvt.ts} +2 -2
- package/{raster.ts → lib/raster.ts} +1 -1
- package/{renderer.ts → lib/renderer.ts} +86 -36
- package/lib/style.ts +115 -0
- package/{tile-source.ts → lib/tile-source.ts} +76 -20
- package/out/browser.d.ts +126 -0
- package/out/browser.d.ts.map +1 -0
- package/out/browser.js +373 -0
- package/out/browser.js.map +1 -0
- package/out/cli-args.d.ts +47 -0
- package/out/cli-args.d.ts.map +1 -0
- package/out/cli-args.js +171 -0
- package/out/cli-args.js.map +1 -0
- package/out/cli.d.ts +8 -0
- package/out/cli.d.ts.map +1 -0
- package/out/cli.js +119 -0
- package/out/cli.js.map +1 -0
- package/out/frame.d.ts +20 -2
- package/out/frame.d.ts.map +1 -1
- package/out/frame.js +28 -3
- package/out/frame.js.map +1 -1
- package/out/index.d.ts +7 -7
- package/out/index.d.ts.map +1 -1
- package/out/index.js +7 -7
- package/out/index.js.map +1 -1
- package/out/input.d.ts +93 -0
- package/out/input.d.ts.map +1 -0
- package/out/input.js +214 -0
- package/out/input.js.map +1 -0
- package/out/mercator.d.ts +13 -0
- package/out/mercator.d.ts.map +1 -1
- package/out/mercator.js +16 -0
- package/out/mercator.js.map +1 -1
- package/out/mvt.d.ts.map +1 -1
- package/out/mvt.js +2 -2
- package/out/mvt.js.map +1 -1
- package/out/raster.d.ts +1 -1
- package/out/raster.d.ts.map +1 -1
- package/out/raster.js.map +1 -1
- package/out/renderer.d.ts +11 -14
- package/out/renderer.d.ts.map +1 -1
- package/out/renderer.js +46 -26
- package/out/renderer.js.map +1 -1
- package/out/style.d.ts +22 -11
- package/out/style.d.ts.map +1 -1
- package/out/style.js +49 -4
- package/out/style.js.map +1 -1
- package/out/tile-source.d.ts +25 -4
- package/out/tile-source.d.ts.map +1 -1
- package/out/tile-source.js +46 -14
- package/out/tile-source.js.map +1 -1
- package/out/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +105 -6
- package/index.ts +0 -13
- package/out/tsconfig.tsbuildinfo +0 -1
- 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
|
package/{mvt.ts → lib/mvt.ts}
RENAMED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { VectorTile } from "@mapbox/vector-tile"
|
|
15
|
-
import
|
|
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
|
|
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 "
|
|
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 {
|
|
19
|
-
|
|
20
|
-
import type
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
90
|
-
|
|
95
|
+
worldX: number
|
|
96
|
+
worldY: number
|
|
91
97
|
scale: number
|
|
92
98
|
origin: GridOrigin
|
|
93
99
|
}
|
|
94
100
|
|
|
95
|
-
|
|
96
|
-
|
|
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.
|
|
102
|
-
y: projection.
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
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:
|
|
230
|
+
private readonly source: TileProvider
|
|
211
231
|
|
|
212
|
-
constructor(source:
|
|
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
|
|
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 (
|
|
247
|
-
|
|
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
|
+
}
|