dsh-live-trace 0.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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +920 -0
  3. package/README.zh.md +790 -0
  4. package/assets/rain.ogg +0 -0
  5. package/bin/dsh-glyph-probe.js +51 -0
  6. package/bin/dsh-live-trace.js +29 -0
  7. package/bin/dsh-live-working.js +14 -0
  8. package/cordis.patch.yml +31 -0
  9. package/icon.svg +12 -0
  10. package/index.js +328 -0
  11. package/lib/client.js +178 -0
  12. package/lib/instance.js +68 -0
  13. package/lib/normalize.js +850 -0
  14. package/lib/paths.js +66 -0
  15. package/lib/protocol.js +115 -0
  16. package/lib/registry.js +232 -0
  17. package/lib/tools.js +257 -0
  18. package/lib/tracker.js +648 -0
  19. package/lib/transport.js +231 -0
  20. package/locale/en.json +6 -0
  21. package/locale/zh.json +6 -0
  22. package/package.json +94 -0
  23. package/picture/call1.png +0 -0
  24. package/picture/call2.png +0 -0
  25. package/picture/sleep1.png +0 -0
  26. package/picture/sleep2.png +0 -0
  27. package/picture/tui1.png +0 -0
  28. package/picture/tui2.png +0 -0
  29. package/picture/type1.png +0 -0
  30. package/picture/type2.png +0 -0
  31. package/scripts/bench-render.mjs +69 -0
  32. package/scripts/demo-working.mjs +130 -0
  33. package/scripts/demo.mjs +284 -0
  34. package/scripts/install-profile.mjs +174 -0
  35. package/scripts/mock-provider.mjs +211 -0
  36. package/src/cli/cellsize.js +120 -0
  37. package/src/cli/format.js +73 -0
  38. package/src/cli/highlight.js +932 -0
  39. package/src/cli/i18n.js +457 -0
  40. package/src/cli/main.js +630 -0
  41. package/src/cli/markdown.js +753 -0
  42. package/src/cli/renderer.js +1044 -0
  43. package/src/cli/screen.js +270 -0
  44. package/src/cli/theme.js +221 -0
  45. package/src/cli/view-state.js +396 -0
  46. package/src/cli/views.js +406 -0
  47. package/src/cli/width.js +337 -0
  48. package/src/cli/working/art.js +413 -0
  49. package/src/cli/working/main.js +569 -0
  50. package/src/cli/working/packing.js +159 -0
  51. package/src/cli/working/picker.js +75 -0
  52. package/src/cli/working/props.js +385 -0
  53. package/src/cli/working/scene.js +837 -0
  54. package/src/cli/working/sky.js +641 -0
  55. package/src/cli/working/sound.js +400 -0
  56. package/src/cli/working/state.js +528 -0
@@ -0,0 +1,159 @@
1
+ /**
2
+ * How many pixels a terminal cell can carry, and with what glyphs.
3
+ *
4
+ * A cell is roughly twice as tall as it is wide, so the mapping from pixels to
5
+ * glyphs decides both the resolution and the shape of the pixels:
6
+ *
7
+ * | packing | pixels/cell | pixel shape | glyphs |
8
+ * |------------|-------------|-------------|---------------------------------|
9
+ * | `half` | 1 x 2 | square | `▀ ▄ █` |
10
+ * | `quadrant` | 2 x 2 | 1:2 tall | `▘ ▝ ▖ ▗ ▚ ▞ ▛ ▜ ▙ ▟` |
11
+ * | `braille` | 2 x 4 | square | `U+2800..U+28FF` |
12
+ *
13
+ * `half` is what the dashboard draws with: it is the only packing whose pixels
14
+ * are square *and* whose glyphs are solid, which is what pixel art of a whale
15
+ * needs. `braille` carries four times as many pixels with square pixels too,
16
+ * but every pixel is a dot with gaps around it, so solid areas come out
17
+ * stippled — good for a plot, poor for a picture. `quadrant` doubles the
18
+ * horizontal count at the cost of pixels that are twice as tall as they are
19
+ * wide, which stretches anything drawn for square pixels.
20
+ *
21
+ * None of this is a free win, which is why the dashboard stays on `half` and
22
+ * this module exists to let you *look* at the alternatives on your own font.
23
+ *
24
+ * @module dsh-live-working/packing
25
+ */
26
+
27
+ /** Half blocks: one wide, two tall, and every glyph is solid. */
28
+ const HALF = {
29
+ name: 'half',
30
+ cols: 1,
31
+ rows: 2,
32
+ square: true,
33
+ solid: true,
34
+ glyphs: [' ', '▀', '▄', '█'],
35
+ label: 'half blocks — 1x2, square pixels, solid glyphs (what the dashboard uses)'
36
+ }
37
+
38
+ /**
39
+ * Quadrant blocks, in row-major bit order: top-left 1, top-right 2,
40
+ * bottom-left 4, bottom-right 8. Combinations that coincide with a half or
41
+ * full block use that glyph instead, which is both cleaner and better
42
+ * supported.
43
+ */
44
+ const QUADRANT = {
45
+ name: 'quadrant',
46
+ cols: 2,
47
+ rows: 2,
48
+ square: false,
49
+ solid: true,
50
+ glyphs: [
51
+ ' ', '▘', '▝', '▀',
52
+ '▖', '▌', '▞', '▛',
53
+ '▗', '▚', '▐', '▜',
54
+ '▄', '▙', '▟', '█'
55
+ ],
56
+ label: 'quadrant blocks — 2x2, pixels 1:2 tall, solid glyphs'
57
+ }
58
+
59
+ /**
60
+ * Braille patterns. The eight dots are numbered down each column, so the bit
61
+ * for a dot is not its position in reading order:
62
+ *
63
+ * ```
64
+ * 1 4
65
+ * 2 5
66
+ * 3 6
67
+ * 7 8
68
+ * ```
69
+ */
70
+ const BRAILLE_BITS = [0x01, 0x08, 0x02, 0x10, 0x04, 0x20, 0x40, 0x80]
71
+ const BRAILLE = {
72
+ name: 'braille',
73
+ cols: 2,
74
+ rows: 4,
75
+ square: true,
76
+ solid: false,
77
+ glyphs: Array.from({ length: 256 }, (_, mask) => String.fromCodePoint(0x2800 + mask)),
78
+ bits: BRAILLE_BITS,
79
+ label: 'braille — 2x4, square pixels, but every pixel is a dot with gaps'
80
+ }
81
+
82
+ /** Every packing, in increasing order of resolution. */
83
+ export const PACKINGS = { half: HALF, quadrant: QUADRANT, braille: BRAILLE }
84
+
85
+ /** Packing names. */
86
+ export const PACKING_NAMES = Object.keys(PACKINGS)
87
+
88
+ /**
89
+ * @param {string} [name]
90
+ * @returns {typeof HALF}
91
+ */
92
+ export function packingFor(name) {
93
+ return PACKINGS[name] ?? HALF
94
+ }
95
+
96
+ /**
97
+ * The glyph for a set of filled pixels within one cell.
98
+ *
99
+ * @param {object} packing
100
+ * @param {boolean[]} filled row-major, `packing.cols * packing.rows` entries
101
+ * @returns {string}
102
+ */
103
+ export function glyphFor(packing, filled) {
104
+ if (packing.bits === undefined) {
105
+ let mask = 0
106
+ for (let index = 0; index < filled.length; index += 1) {
107
+ if (filled[index]) mask |= 1 << index
108
+ }
109
+ return packing.glyphs[mask] ?? ' '
110
+ }
111
+ let mask = 0
112
+ for (let index = 0; index < filled.length; index += 1) {
113
+ if (filled[index]) mask |= packing.bits[index]
114
+ }
115
+ return packing.glyphs[mask] ?? ' '
116
+ }
117
+
118
+ /**
119
+ * A sample sheet for one packing, so you can see what your font does with it.
120
+ *
121
+ * A font that lacks these glyphs does not fail loudly: it substitutes a box, a
122
+ * blank, or another character, and the picture quietly falls apart. Printing
123
+ * this is the only reliable way to find out.
124
+ *
125
+ * @param {object} packing
126
+ * @returns {{ label: string, rows: string[] }}
127
+ */
128
+ export function probe(packing) {
129
+ const rows = []
130
+ // Every glyph the packing uses, in a grid.
131
+ for (let start = 0; start < packing.glyphs.length; start += 16) {
132
+ rows.push(packing.glyphs.slice(start, start + 16).map((glyph) => `${glyph}${glyph}`).join(''))
133
+ }
134
+ // A disc rasterised at the packing's own pixel resolution, so its edge falls
135
+ // on partial cells and every glyph gets used. Filling whole cells instead
136
+ // would draw an identical blocky disc for all three packings and tell you
137
+ // nothing.
138
+ const cells = 17
139
+ const across = cells * packing.cols
140
+ const down = cells * packing.rows
141
+ const radius = (Math.min(across, down) - 1) / 2
142
+ const cx = (across - 1) / 2
143
+ const cy = (down - 1) / 2
144
+ const filledAt = (px, py) => (px - cx) ** 2 + (py - cy) ** 2 <= radius * radius
145
+ for (let cellY = 0; cellY < cells; cellY += 1) {
146
+ const line = []
147
+ for (let cellX = 0; cellX < cells; cellX += 1) {
148
+ const filled = []
149
+ for (let row = 0; row < packing.rows; row += 1) {
150
+ for (let col = 0; col < packing.cols; col += 1) {
151
+ filled.push(filledAt(cellX * packing.cols + col, cellY * packing.rows + row))
152
+ }
153
+ }
154
+ line.push(glyphFor(packing, filled))
155
+ }
156
+ rows.push(line.join(''))
157
+ }
158
+ return { label: packing.label, rows }
159
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The session chooser, as a pure reducer.
3
+ *
4
+ * With several sessions running, guessing which one the user meant is a guess
5
+ * they cannot see the basis of, so `dsh-live-working` asks. The key handling
6
+ * lives here rather than inline in the render loop so it can be tested without
7
+ * a terminal: the parts that have broken in this command have all been in the
8
+ * wiring between a key press and a state change.
9
+ *
10
+ * @module dsh-live-working/picker
11
+ */
12
+
13
+ /**
14
+ * @typedef {{ index: number, count: number, boundId: string | null }} PickerState
15
+ * @typedef {{ index: number, intent: 'none' | 'bind' | 'cancel', sessionId: string | null }} PickerStep
16
+ */
17
+
18
+ /** Positive modulo. */
19
+ function mod(value, base) {
20
+ if (base <= 0) return 0
21
+ return ((value % base) + base) % base
22
+ }
23
+
24
+ /**
25
+ * A picker positioned on the currently bound session.
26
+ *
27
+ * @param {Array<{ id: string }>} sessions
28
+ * @param {string | null} [boundId]
29
+ * @returns {PickerState}
30
+ */
31
+ export function createPickerState(sessions, boundId = null) {
32
+ const list = Array.isArray(sessions) ? sessions : []
33
+ const found = list.findIndex((session) => session?.id === boundId)
34
+ return { index: found >= 0 ? found : 0, count: list.length, boundId: boundId ?? null }
35
+ }
36
+
37
+ /**
38
+ * Apply one key.
39
+ *
40
+ * @param {PickerState} state
41
+ * @param {string} key
42
+ * @param {Array<{ id: string }>} [sessions]
43
+ * @returns {PickerStep}
44
+ */
45
+ export function applyPickerKey(state, key, sessions = []) {
46
+ const count = state.count > 0 ? state.count : (Array.isArray(sessions) ? sessions.length : 0)
47
+ const index = mod(state.index, Math.max(1, count))
48
+ const stay = (next = index) => ({ index: next, intent: 'none', sessionId: null })
49
+
50
+ switch (key) {
51
+ case 'up':
52
+ case 'k':
53
+ return stay(mod(index - 1, Math.max(1, count)))
54
+ case 'down':
55
+ case 'j':
56
+ return stay(mod(index + 1, Math.max(1, count)))
57
+ case 'home':
58
+ return stay(0)
59
+ case 'end':
60
+ return stay(Math.max(0, count - 1))
61
+ case 'enter':
62
+ case '\r':
63
+ case '\n': {
64
+ const chosen = Array.isArray(sessions) ? sessions[index] : undefined
65
+ const sessionId = chosen?.id ?? null
66
+ return { index, intent: sessionId === null ? 'cancel' : 'bind', sessionId }
67
+ }
68
+ case 'escape':
69
+ case 's':
70
+ case 'q':
71
+ return { index, intent: 'cancel', sessionId: null }
72
+ default:
73
+ return stay()
74
+ }
75
+ }
@@ -0,0 +1,385 @@
1
+ /**
2
+ * The props on the desk: a keyboard, a stack of books and a red telephone.
3
+ *
4
+ * Everything here is composed from a few primitives rather than stored as pixel
5
+ * grids, so each prop can move, open or light up between frames. All
6
+ * coordinates are pixels in the scene grid; the renderer packs two pixel rows
7
+ * into each terminal cell. Sizes are passed in, so the same prop works at any
8
+ * scale.
9
+ *
10
+ * Classes: `.` transparent, `d` desk wood, `D` desk shadow, `k` keyboard deck,
11
+ * `K` keycap, `a` lit key, `r` telephone red, `R` telephone shadow, `p` page,
12
+ * `q` page in shadow, `Q` deep shadow, `g`/`y`/`c` book covers, `o` dark line,
13
+ * `n` bubble border, `N` bubble fill, `e` window edge, `E` window fill,
14
+ * `t` text.
15
+ *
16
+ * @module dsh-live-working/props
17
+ */
18
+
19
+ /** A blank pixel canvas. */
20
+ export function canvas(width, height) {
21
+ return Array.from({ length: Math.max(1, height) }, () => new Array(Math.max(1, width)).fill('.'))
22
+ }
23
+
24
+ /** Fill an inclusive rectangle. */
25
+ export function rect(grid, x0, y0, x1, y1, cls) {
26
+ const height = grid.length
27
+ const width = grid[0].length
28
+ for (let y = Math.max(0, y0); y <= Math.min(height - 1, y1); y += 1) {
29
+ for (let x = Math.max(0, x0); x <= Math.min(width - 1, x1); x += 1) grid[y][x] = cls
30
+ }
31
+ }
32
+
33
+ /** Draw a rectangle outline. */
34
+ export function frame(grid, x0, y0, x1, y1, cls) {
35
+ rect(grid, x0, y0, x1, y0, cls)
36
+ rect(grid, x0, y1, x1, y1, cls)
37
+ rect(grid, x0, y0, x0, y1, cls)
38
+ rect(grid, x1, y0, x1, y1, cls)
39
+ }
40
+
41
+ /** Draw a ring of whole cells, for a dial. */
42
+ export function ring(grid, cx, cy, radius, cls) {
43
+ const r = Math.max(1, Math.round(radius))
44
+ const inner = (r - 1) * (r - 1)
45
+ for (let dy = -r; dy <= r; dy += 1) {
46
+ for (let dx = -r; dx <= r; dx += 1) {
47
+ const d = dx * dx + dy * dy
48
+ if (d > r * r || d < inner) continue
49
+ const px = cx + dx
50
+ const py = cy + dy
51
+ if (py < 0 || py >= grid.length || px < 0 || px >= grid[0].length) continue
52
+ grid[py][px] = cls
53
+ }
54
+ }
55
+ }
56
+
57
+ /** Copy a grid onto a canvas at an offset, optionally only onto empty pixels. */
58
+ export function blit(target, source, dx, dy, only) {
59
+ const height = target.length
60
+ const width = target[0].length
61
+ for (let y = 0; y < source.length; y += 1) {
62
+ for (let x = 0; x < source[y].length; x += 1) {
63
+ const ch = source[y][x]
64
+ if (ch === '.') continue
65
+ const tx = x + dx
66
+ const ty = y + dy
67
+ if (tx < 0 || tx >= width || ty < 0 || ty >= height) continue
68
+ if (only !== undefined && !only(target[ty][tx])) continue
69
+ target[ty][tx] = ch
70
+ }
71
+ }
72
+ }
73
+
74
+ /** The desk: a top surface, a darker front edge, and two legs. */
75
+ export function drawDesk(grid, top, left, right, thickness) {
76
+ const height = grid.length
77
+ const slab = Math.max(2, thickness)
78
+ rect(grid, left, top, right, top + slab - 1, 'd')
79
+ rect(grid, left, top + slab, right, top + slab + 2, 'D')
80
+ const legTop = top + slab + 3
81
+ if (legTop < height) {
82
+ const legWidth = Math.max(2, Math.round((right - left) * 0.04))
83
+ rect(grid, left + 3, legTop, left + 3 + legWidth, height - 1, 'D')
84
+ rect(grid, right - 3 - legWidth, legTop, right - 3, height - 1, 'D')
85
+ }
86
+ }
87
+
88
+ /**
89
+ * A keyboard lying flat on the desk, seen from the front and slightly above.
90
+ *
91
+ * Drawn as a **trapezoid**: the far edge is narrower than the near edge, and
92
+ * the rows of keycaps shrink towards the back. A plain rectangle reads as a
93
+ * picture of a keyboard pasted onto the desk rather than an object on it.
94
+ *
95
+ * `phase` sweeps which keys are lit; a negative phase leaves the deck alone,
96
+ * for the states where the orca has its hands off it.
97
+ */
98
+ export function drawKeyboard(grid, x, y, width, height, phase) {
99
+ // A small dark bar. An earlier version drew a perspective deck with rows of
100
+ // keycaps, which at this size was a grey mass that read as neither a
101
+ // keyboard nor anything else; a plain slab on the desk is clearer.
102
+ const h = Math.max(2, height)
103
+ rect(grid, x, y, x + width - 1, y + h - 1, 'k')
104
+ rect(grid, x, y, x + width - 1, y, 'K')
105
+ rect(grid, x, y + h - 1, x + width - 1, y + h - 1, 'D')
106
+ // A single travelling highlight, so a keyboard that is being used still
107
+ // looks alive without drawing keys.
108
+ void phase
109
+ }
110
+
111
+ /**
112
+ * A pectoral flipper, drawn over the orca's shoulder.
113
+ *
114
+ * The sampled sprite is a side view with no separate limb, so a flipper that
115
+ * moves has to be drawn: the reference artwork cannot animate something it does
116
+ * not depict.
117
+ *
118
+ * @param {number} size scale in pixels
119
+ * @param {boolean} down whether the tip is on the keys
120
+ */
121
+ export function drawFlipper(grid, x, y, size, down, cls = 'f') {
122
+ const s = Math.max(1, size)
123
+ // Deliberately stubby. A longer reach reads as a spindly arm rather than a
124
+ // pectoral fin.
125
+ drawLimb(grid, x, y, s, 2 * s, (down ? 2 : 1) * s, cls)
126
+ }
127
+
128
+ /**
129
+ * A thick limb drawn along a direction, from `(x, y)` towards `(x + dx, y + dy)`.
130
+ *
131
+ * @param {number} size thickness scale in pixels
132
+ */
133
+ export function drawLimb(grid, x, y, size, dx, dy, cls = 'f') {
134
+ const s = Math.max(1, size)
135
+ const steps = Math.max(1, Math.round(Math.hypot(dx, dy)))
136
+ const thickness = Math.max(2, Math.round(s * 1.3))
137
+ for (let step = 0; step <= steps; step += 1) {
138
+ const cx = x + Math.round((dx * step) / steps)
139
+ const cy = y + Math.round((dy * step) / steps)
140
+ rect(grid, cx, cy, cx + thickness - 1, cy + thickness - 1, cls)
141
+ }
142
+ }
143
+
144
+ /**
145
+ * The little window that shows what the orca is typing.
146
+ *
147
+ * A frame and a dark interior; the text is written by the caller, because
148
+ * glyphs are not pixels.
149
+ *
150
+ * @returns {{ x: number, y: number, width: number, height: number }} the inner
151
+ * area in pixels where text may be placed
152
+ */
153
+ export function drawWindow(grid, x, y, width, height) {
154
+ rect(grid, x, y, x + width - 1, y + height - 1, 'e')
155
+ rect(grid, x + 1, y + 1, x + width - 2, y + height - 2, 'E')
156
+ // Title bar, so it reads as a window rather than a hole.
157
+ rect(grid, x + 1, y + 1, x + width - 2, y + 1, 'e')
158
+ return { x: x + 2, y: y + 2, width: Math.max(1, width - 4), height: Math.max(1, height - 3) }
159
+ }
160
+
161
+ /**
162
+ * An open book: two pages either side of a spine, with shading, and a page
163
+ * part-way through turning.
164
+ *
165
+ * The shading is the point. Real pages are all the same white, but a book is
166
+ * not lit evenly: the gutter between the pages falls into shadow, the leaf
167
+ * away from the light is darker than the one facing it, and a page caught
168
+ * mid-turn is edge-on and darker still. Without that the spread reads as a
169
+ * blank rectangle.
170
+ */
171
+ export function drawOpenBook(grid, x, y, width, height, phase) {
172
+ const bottom = y + height - 1
173
+ const top = y + 1
174
+ const pageBottom = bottom - 1
175
+ const spine = x + Math.floor(width / 2)
176
+
177
+ rect(grid, x, y, x + width - 1, bottom, 'o')
178
+ // Left page catches the light; the right page is turned away from it.
179
+ rect(grid, x + 1, top, spine - 1, pageBottom, 'p')
180
+ rect(grid, spine + 1, top, x + width - 2, pageBottom, 'q')
181
+ // Gutter shadow either side of the spine.
182
+ rect(grid, spine - 2, top, spine - 1, pageBottom, 'q')
183
+ rect(grid, spine + 1, top, spine + 2, pageBottom, 'Q')
184
+ rect(grid, spine, top, spine, pageBottom, 'o')
185
+ // The block of pages under the spread.
186
+ rect(grid, x + 1, bottom, x + width - 2, bottom, 'Q')
187
+
188
+ const half = Math.max(2, Math.floor((width - 3) / 2))
189
+ const steps = 6
190
+ const step = ((Math.trunc(phase) % steps) + steps) % steps
191
+ const turning = step < steps / 2 ? 'q' : 'p'
192
+ if (step < steps / 2) {
193
+ // Lifting off the right-hand side and folding towards the spine: the leaf
194
+ // is edge-on, so it is the darkest thing on the spread.
195
+ const fold = Math.round((half * step * 2) / steps)
196
+ const edge = spine + half - fold
197
+ rect(grid, edge, top, spine + half, pageBottom, 'q')
198
+ rect(grid, edge, top, spine + half, top, 'o')
199
+ rect(grid, edge, top, edge, pageBottom, 'Q')
200
+ } else {
201
+ // Landing on the left, widening away from the spine, picking the light up
202
+ // as it flattens.
203
+ const grow = Math.round((half * (step - steps / 2) * 2) / steps)
204
+ rect(grid, spine - grow, top, spine, pageBottom, turning)
205
+ rect(grid, spine - grow, top, spine, top, 'o')
206
+ rect(grid, spine - grow, top, spine - grow, pageBottom, 'Q')
207
+ }
208
+ }
209
+
210
+ /** A stack of closed books. `open` puts an open, page-turning book on top. */
211
+ export function drawBooks(grid, x, y, width, height, open, phase) {
212
+ const covers = ['g', 'y', 'c']
213
+ const thickness = Math.max(2, Math.floor((height - 1) / 3))
214
+ let cursor = y + height - 1
215
+ for (let index = 0; index < 3; index += 1) {
216
+ const bottom = cursor
217
+ const top = Math.max(y, cursor - thickness + 1)
218
+ rect(grid, x, top, x + width - 1, bottom, covers[index])
219
+ rect(grid, x, top, x + width - 1, top, 'o')
220
+ // Page block on the fore edge, with the bottom book in shadow.
221
+ rect(grid, x + width - 2, top + 1, x + width - 1, bottom, index === 0 ? 'Q' : 'p')
222
+ cursor = top - 1
223
+ }
224
+ if (open) drawOpenBook(grid, x, y - Math.max(5, height - 4), width, Math.max(4, thickness + 2), phase)
225
+ }
226
+
227
+ /**
228
+ * A handset: a bar with a bulb at each end, drawn at an angle.
229
+ *
230
+ * The angle is what makes the pickup readable. Snapping the handset from lying
231
+ * flat to standing at the ear is a teleport; rotating it there is an action.
232
+ *
233
+ * @param {number} angle radians, 0 being horizontal with the earpiece right
234
+ */
235
+ export function drawHandset(grid, cx, cy, length, thickness, angle) {
236
+ const t = Math.max(1, Math.round(thickness))
237
+ const steps = Math.max(2, Math.round(length))
238
+ const dx = Math.cos(angle)
239
+ const dy = Math.sin(angle)
240
+ for (let index = 0; index <= steps; index += 1) {
241
+ const along = (index / steps - 0.5) * length
242
+ const px = Math.round(cx + dx * along - (t - 1) / 2)
243
+ const py = Math.round(cy + dy * along - (t - 1) / 2)
244
+ rect(grid, px, py, px + t - 1, py + t - 1, 'r')
245
+ }
246
+ // Earpiece and mouthpiece: bulbs at either end.
247
+ const bulb = Math.max(t, Math.round(t * 1.6))
248
+ for (const direction of [-1, 1]) {
249
+ const ex = Math.round(cx + dx * direction * (length / 2))
250
+ const ey = Math.round(cy + dy * direction * (length / 2))
251
+ rect(grid, ex - Math.floor(bulb / 2), ey - Math.floor(bulb / 2), ex + Math.floor(bulb / 2), ey + Math.floor(bulb / 2), 'r')
252
+ }
253
+ }
254
+
255
+ /**
256
+ * A red telephone on its cradle, with the handset anywhere between resting and
257
+ * held to the ear.
258
+ *
259
+ * `at.lift` runs 0 (lying on the cradle) to 1 (at the ear); everything between
260
+ * is the handset on its way up or down. The cord follows whichever end is the
261
+ * mouthpiece at that moment.
262
+ *
263
+ * @param {{ x: number, y: number, scale: number, lift?: number, shake?: number } | null} at
264
+ */
265
+ export function drawPhone(grid, x, y, width, height, at) {
266
+ const s = Math.max(1, Math.round(at?.scale ?? 1))
267
+ const raw = Number.isFinite(at?.lift) ? at.lift : (at === null || at === undefined ? 0 : 1)
268
+ const lift = Math.max(0, Math.min(1, raw))
269
+ // Smoothstep: the handset eases off the cradle and eases onto the ear.
270
+ const eased = lift * lift * (3 - 2 * lift)
271
+ const shake = Number.isFinite(at?.shake) ? Math.round(at.shake) : 0
272
+
273
+ // The base: a body with a lit top surface, so it has a front and a top rather
274
+ // than being a red rectangle.
275
+ const baseTop = y + Math.round(height * 0.45)
276
+ const baseH = Math.max(3, y + height - baseTop)
277
+ const capH = Math.max(1, Math.round(baseH * 0.28))
278
+ rect(grid, x, baseTop, x + width - 1, y + height - 1, 'R')
279
+ rect(grid, x, baseTop, x + width - 1, baseTop + capH - 1, 'r')
280
+ rect(grid, x + 1, baseTop + capH, x + width - 2, y + height - 2, 'r')
281
+ rect(grid, x, y + height - 1, x + width - 1, y + height - 1, 'R')
282
+
283
+ // The dial, on the front face.
284
+ const dialR = Math.max(1, Math.round(Math.min(width, baseH) * 0.24))
285
+ ring(grid, x + Math.max(2, Math.round(width * 0.3)), baseTop + capH + Math.max(1, Math.round((baseH - capH) * 0.45)), dialR, 'o')
286
+
287
+ // The cradle: two nubs the handset rests between.
288
+ const cradleY = Math.max(0, baseTop - Math.max(2, Math.round(baseH * 0.45)))
289
+ rect(grid, x + 1, cradleY, x + 1, baseTop - 1, 'o')
290
+ rect(grid, x + width - 2, cradleY, x + width - 2, baseTop - 1, 'o')
291
+
292
+ // Where the handset rests, and where it ends up.
293
+ const restCx = x + (width - 1) / 2
294
+ const restCy = cradleY - 1.5 * s
295
+ const restLength = width - 2
296
+ const run = 6 * s
297
+ const drop = 13 * s
298
+ const earX = at?.x ?? restCx
299
+ const earY = at?.y ?? restCy
300
+ const heldCx = earX - run / 2 + shake
301
+ const heldCy = earY + drop / 2
302
+ const heldLength = Math.hypot(run, drop)
303
+
304
+ const cx = restCx + (heldCx - restCx) * eased
305
+ const cy = restCy + (heldCy - restCy) * eased
306
+ const angle = (-Math.atan2(drop, run)) * eased
307
+ const length = restLength + (heldLength - restLength) * eased
308
+
309
+ drawHandset(grid, cx, cy, length, Math.max(1, Math.round(2.4 * s)), angle)
310
+
311
+ // Cord, from the mouthpiece end down to the base. It slackens as the handset
312
+ // rises, which is most of what sells the movement.
313
+ const dirX = Math.cos(angle)
314
+ const dirY = Math.sin(angle)
315
+ const fromX = Math.round(cx - dirX * (length / 2))
316
+ const fromY = Math.round(cy - dirY * (length / 2))
317
+ const span = Math.max(1, 10 * s)
318
+ for (let step = 0; step <= span; step += 1) {
319
+ const ratio = step / span
320
+ const sag = Math.round(Math.sin(Math.PI * ratio) * (3 * s + Math.round(eased * 3 * s)))
321
+ const px = Math.round(fromX + (x + width - 2 - fromX) * ratio)
322
+ const py = fromY + Math.round((baseTop - fromY) * ratio) + sag
323
+ if (py >= 0 && py < grid.length && px >= 0 && px < grid[0].length) grid[py][px] = 'R'
324
+ }
325
+
326
+ return { cx, cy, angle, length }
327
+ }
328
+
329
+ /**
330
+ * A speech bubble with a tail, sized to the text it will hold.
331
+ *
332
+ * @returns {{ x: number, y: number, width: number, height: number }} the inner
333
+ * area, in pixels, where text may be placed
334
+ */
335
+ export function drawBubble(grid, x, y, innerWidth, innerHeight) {
336
+ const x1 = Math.min(grid[0].length - 1, x + innerWidth + 3)
337
+ const y1 = Math.min(grid.length - 1, y + innerHeight + 1)
338
+ rect(grid, x + 1, y, x1 - 1, y, 'n')
339
+ rect(grid, x + 1, y1, x1 - 1, y1, 'n')
340
+ rect(grid, x, y + 1, x, y1 - 1, 'n')
341
+ rect(grid, x1, y + 1, x1, y1 - 1, 'n')
342
+ rect(grid, x + 1, y + 1, x1 - 1, y1 - 1, 'N')
343
+ const tailX = x + 4
344
+ for (let step = 0; step < 3; step += 1) {
345
+ const row = y1 + 1 + step
346
+ if (row >= grid.length) break
347
+ grid[row][Math.max(0, tailX - step)] = 'n'
348
+ grid[row][Math.max(0, tailX - step + 1)] = 'N'
349
+ }
350
+ // Text must stop one cell short of the right border, or the last glyph
351
+ // overwrites it.
352
+ return { x: x + 2, y: y + 1, width: Math.max(1, x1 - x - 4), height: innerHeight }
353
+ }
354
+
355
+ /** A cartoon `z`, rising, for the sleeping pose. */
356
+ export function drawSleepZ(grid, x, y, size) {
357
+ rect(grid, x, y, x + size, y, 'z')
358
+ rect(grid, x, y + size, x + size, y + size, 'z')
359
+ for (let step = 0; step <= size; step += 1) {
360
+ const cx = x + size - step
361
+ const cy = y + step
362
+ if (cy < grid.length && cx >= 0 && cx < grid[0].length) grid[cy][cx] = 'z'
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Ringing arcs: two short dashes either side of the telephone, growing and
368
+ * fading, which is what makes a ringing telephone read as ringing rather than
369
+ * as a telephone that happens to be shaking.
370
+ *
371
+ * @param {{ x: number, y: number, size: number, frame: number }} at
372
+ */
373
+ export function drawRingArcs(grid, x, y, size, frame) {
374
+ const s = Math.max(1, size)
375
+ const phase = ((Math.trunc(frame) % 8) + 8) % 8
376
+ const reach = 2 + Math.floor(phase / 2)
377
+ for (const direction of [-1, 1]) {
378
+ for (let index = 0; index < 3; index += 1) {
379
+ const cx = x + direction * (reach + index) * 2 * s
380
+ const cy = y - index * s
381
+ if (cy < 0 || cy >= grid.length || cx < 0 || cx >= grid[0].length) continue
382
+ rect(grid, cx, cy, cx, cy + s, 'a')
383
+ }
384
+ }
385
+ }