@toonstrip/core 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.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The multi-balloon layout driver.
3
+ *
4
+ * Balloons in a panel are not stacked; each is placed over its own speaker
5
+ * and then squeezed out of the way of the balloons already placed, so every
6
+ * tail can still reach its head without crossing another balloon. That
7
+ * "without crossing" is the route region: a horizontal corridor each balloon
8
+ * reserves between itself and its speaker. A later balloon may not be placed
9
+ * inside a corridor an earlier one owns, and once placed it carves its own
10
+ * span out of theirs. When a balloon cannot be placed at all the *panel* is
11
+ * refused — the caller's signal to split the panel's lines across more than
12
+ * one panel.
13
+ *
14
+ * Coordinates are CSS px with y pointing down; `top` is the smaller y.
15
+ *
16
+ * **Determinism.** Two rolls (the width goal, the horizontal straddle) are
17
+ * drawn from a small deterministic PRNG seeded by the panel's own content
18
+ * ({@link panelSeed}), so a panel with the same speakers and text always
19
+ * lays out the same way and a caller can replay a strip exactly.
20
+ */
21
+ import type { BalloonBox } from "./balloon.js";
22
+ import type { Line } from "@toonstrip/schema";
23
+ /** A rectangle in panel space, y down: `top` is the smaller y. */
24
+ export interface Rect {
25
+ left: number;
26
+ top: number;
27
+ right: number;
28
+ bottom: number;
29
+ }
30
+ /** The corridor a balloon reserves between itself and its speaker. */
31
+ export interface RouteRgn {
32
+ left: number;
33
+ right: number;
34
+ bottom: number;
35
+ }
36
+ /** Measurement, injected so the driver itself never touches a canvas. */
37
+ export interface BalloonMetrics {
38
+ /** The whole text on one line. */
39
+ oneLineWidth(text: string): number;
40
+ /** The floor a wrap may not go below. */
41
+ widestWord(text: string): number;
42
+ /** Reflow `text` into a box of exactly `boxWidth`. */
43
+ measure(text: string, boxWidth: number): {
44
+ lines: string[];
45
+ width: number;
46
+ height: number;
47
+ };
48
+ /** What fits in `boxHeight`, and the remainder. */
49
+ split(text: string, boxWidth: number, boxHeight: number): {
50
+ text: string;
51
+ rest: string | null;
52
+ };
53
+ /** Chrome each side, so the driver can tell when a span is too narrow to build. */
54
+ chrome: number;
55
+ }
56
+ /** One balloon offered to the driver, in the panel's draw order. */
57
+ export interface BalloonInput {
58
+ /** Speaker id, so the caller can match a laid-out box back to its line. */
59
+ speaker: string;
60
+ /** A caption: pinned left, and it reserves no corridor. */
61
+ isBox: boolean;
62
+ /**
63
+ * The x a tail must reach, or `null` for a speaker with no body on stage
64
+ * (or a caption); such a balloon is placed like a caption and blocks
65
+ * nobody's route.
66
+ */
67
+ arrowX: number | null;
68
+ text: string;
69
+ metrics: BalloonMetrics;
70
+ }
71
+ /**
72
+ * The production {@link BalloonMetrics}: a real canvas, measuring in the
73
+ * balloon's own chrome. `ctx.font` must already be {@link "./balloon.js".BALLOON_FONT}
74
+ * — the caller sets it once for the whole panel.
75
+ */
76
+ export declare function canvasMetrics(ctx: CanvasRenderingContext2D, balloon: Pick<Line, "balloon" | "text">): BalloonMetrics;
77
+ /** A placed balloon: where it goes, how its text broke, what corridor it owns. */
78
+ export interface LaidBalloon {
79
+ input: BalloonInput;
80
+ box: BalloonBox;
81
+ lines: readonly string[];
82
+ routeRgn: RouteRgn;
83
+ }
84
+ export type LayoutResult = {
85
+ fits: true;
86
+ balloons: LaidBalloon[];
87
+ leftover: string | null;
88
+ } | {
89
+ fits: false;
90
+ };
91
+ /**
92
+ * A deterministic stand-in for `rand`: mulberry32, chosen because it is four
93
+ * lines and has no state to carry between panels. Every roll the driver
94
+ * makes comes from here, so a panel with the same content always lays out
95
+ * the same way.
96
+ */
97
+ export declare function makeRandom(seed: number): () => number;
98
+ /**
99
+ * The panel's seed, derived from its own content (FNV-1a over each speaker
100
+ * and line). Two panels that say the same thing lay out the same way, and a
101
+ * replayed panel lays out as it did the first time.
102
+ */
103
+ export declare function panelSeed(inputs: readonly BalloonInput[]): number;
104
+ /**
105
+ * Place every balloon in draw order, and give up on the whole panel the
106
+ * moment one will not go — except a panel holding exactly one balloon that
107
+ * will not fit, which is force-fitted into the free rect and split, since
108
+ * refusing it would refuse a panel a fresh, empty panel could not hold
109
+ * either.
110
+ */
111
+ export declare function layoutBalloons(inputs: readonly BalloonInput[], freeRect: Rect, seed?: number): LayoutResult;
@@ -0,0 +1,286 @@
1
+ /**
2
+ * The multi-balloon layout driver.
3
+ *
4
+ * Balloons in a panel are not stacked; each is placed over its own speaker
5
+ * and then squeezed out of the way of the balloons already placed, so every
6
+ * tail can still reach its head without crossing another balloon. That
7
+ * "without crossing" is the route region: a horizontal corridor each balloon
8
+ * reserves between itself and its speaker. A later balloon may not be placed
9
+ * inside a corridor an earlier one owns, and once placed it carves its own
10
+ * span out of theirs. When a balloon cannot be placed at all the *panel* is
11
+ * refused — the caller's signal to split the panel's lines across more than
12
+ * one panel.
13
+ *
14
+ * Coordinates are CSS px with y pointing down; `top` is the smaller y.
15
+ *
16
+ * **Determinism.** Two rolls (the width goal, the horizontal straddle) are
17
+ * drawn from a small deterministic PRNG seeded by the panel's own content
18
+ * ({@link panelSeed}), so a panel with the same speakers and text always
19
+ * lays out the same way and a caller can replay a strip exactly.
20
+ */
21
+ import { AREA_FUDGE, LINE_HEIGHT, ONE_LINE_THRESHOLD, chromeFor, measureAtWidth, splitHeight, widestWord, } from "./balloon.js";
22
+ /**
23
+ * The production {@link BalloonMetrics}: a real canvas, measuring in the
24
+ * balloon's own chrome. `ctx.font` must already be {@link "./balloon.js".BALLOON_FONT}
25
+ * — the caller sets it once for the whole panel.
26
+ */
27
+ export function canvasMetrics(ctx, balloon) {
28
+ const as = (text) => text === balloon.text ? balloon : { ...balloon, text };
29
+ return {
30
+ chrome: chromeFor(balloon),
31
+ oneLineWidth: (text) => ctx.measureText(text).width,
32
+ widestWord: (text) => widestWord(ctx, text),
33
+ measure: (text, boxWidth) => measureAtWidth(ctx, as(text), boxWidth),
34
+ split: (text, boxWidth, boxHeight) => splitHeight(ctx, as(text), boxWidth, boxHeight),
35
+ };
36
+ }
37
+ /** The drop a tail needs below the balloon. */
38
+ const MIN_HOOK_HEIGHT = 20;
39
+ /** The width a corridor may not shrink past. */
40
+ const MIN_ROUTE_WIDTH = 60;
41
+ /**
42
+ * The lift taken off a previous balloon's box bottom before it becomes the
43
+ * next balloon's ceiling. Zero here: `box` is already the text box alone
44
+ * (a tail is drawn from it, not measured into it), so no separate overhang
45
+ * correction is needed.
46
+ */
47
+ const DOCK_DELTA = 0;
48
+ /** How far below the free rect's top a balloon nothing pushed down docks. */
49
+ const TOP_BORDER = 4;
50
+ /** The fudge a balloon's width goal is padded by, twice over, before being capped. */
51
+ const WIDTH_FUDGE = 40;
52
+ const LARGE = Infinity;
53
+ /**
54
+ * A deterministic stand-in for `rand`: mulberry32, chosen because it is four
55
+ * lines and has no state to carry between panels. Every roll the driver
56
+ * makes comes from here, so a panel with the same content always lays out
57
+ * the same way.
58
+ */
59
+ export function makeRandom(seed) {
60
+ let a = seed >>> 0;
61
+ return () => {
62
+ a = (a + 0x6d2b79f5) >>> 0;
63
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
64
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
65
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
66
+ };
67
+ }
68
+ /**
69
+ * The panel's seed, derived from its own content (FNV-1a over each speaker
70
+ * and line). Two panels that say the same thing lay out the same way, and a
71
+ * replayed panel lays out as it did the first time.
72
+ */
73
+ export function panelSeed(inputs) {
74
+ let hash = 0x811c9dc5;
75
+ for (const input of inputs) {
76
+ for (const ch of `${input.speaker} ${input.text}`) {
77
+ hash ^= ch.codePointAt(0) ?? 0;
78
+ hash = Math.imul(hash, 0x01000193) >>> 0;
79
+ }
80
+ }
81
+ return hash >>> 0;
82
+ }
83
+ /**
84
+ * Place every balloon in draw order, and give up on the whole panel the
85
+ * moment one will not go — except a panel holding exactly one balloon that
86
+ * will not fit, which is force-fitted into the free rect and split, since
87
+ * refusing it would refuse a panel a fresh, empty panel could not hold
88
+ * either.
89
+ */
90
+ export function layoutBalloons(inputs, freeRect, seed = panelSeed(inputs)) {
91
+ const random = makeRandom(seed); // always lay out the same way for this seed
92
+ const laid = [];
93
+ for (let i = 0; i < inputs.length; i++) {
94
+ const balloon = layoutBalloon(inputs[i], laid, freeRect, random);
95
+ if (!balloon) {
96
+ if (i === 0 && inputs.length === 1)
97
+ return forceFitBalloon(inputs[0], freeRect);
98
+ return { fits: false };
99
+ }
100
+ laid.push(balloon);
101
+ }
102
+ return { fits: true, balloons: laid, leftover: null };
103
+ }
104
+ /** One balloon's placement: estimate a span, squeeze it into the free corridor, measure, validate. */
105
+ function layoutBalloon(input, laid, freeRect, random) {
106
+ const brect = getCloudEstimate(input, laid, freeRect, random); // best guess
107
+ // squeezed between the corridors, and pushed below anything in the way
108
+ const pushed = getInterveningBBox(input, laid, freeRect, brect);
109
+ const span = brect.right - brect.left;
110
+ // A span that cannot hold the widest single word is not a balloon.
111
+ if (span - input.metrics.chrome * 2 < input.metrics.widestWord(input.text))
112
+ return null;
113
+ const measured = input.metrics.measure(input.text, span);
114
+ const box = {
115
+ x: brect.left, y: brect.top, width: measured.width, height: measured.height,
116
+ };
117
+ // A balloon nothing pushed down docks flush to the free rect's top.
118
+ if (!pushed)
119
+ box.y = freeRect.top + TOP_BORDER;
120
+ // The estimate placed the *span* so it covers the arrow. The wrap is
121
+ // word-granular, so the true box can come back narrower than the span it
122
+ // was granted and that guarantee can slip; when it does, slide the
123
+ // shrunken box back over the arrow (inside the free rect), rather than
124
+ // leave a tail reaching out of the balloon it belongs to.
125
+ if (input.arrowX !== null && !input.isBox
126
+ && (input.arrowX < box.x || input.arrowX > box.x + box.width)) {
127
+ box.x = Math.max(freeRect.left, Math.min(input.arrowX - box.width / 2, freeRect.right - box.width));
128
+ }
129
+ const routeRgn = {
130
+ left: box.x, right: box.x + box.width, bottom: box.y + box.height,
131
+ };
132
+ // A balloon hanging that low leaves its own tail no room.
133
+ if (routeRgn.bottom > freeRect.bottom - MIN_HOOK_HEIGHT)
134
+ return null;
135
+ adjustRouteRgns(input, laid, routeRgn);
136
+ return { input, box, lines: measured.lines, routeRgn };
137
+ }
138
+ /** How wide this balloon wants to be, and where along x it wants to sit. */
139
+ function getCloudEstimate(input, laid, freeRect, random) {
140
+ const len = input.metrics.oneLineWidth(input.text);
141
+ const area = AREA_FUDGE * len * 2 * LINE_HEIGHT;
142
+ const maxWidth = freeRect.right - freeRect.left;
143
+ let goalWidth;
144
+ if (len <= ONE_LINE_THRESHOLD) {
145
+ goalWidth = len; // short enough to stay on one line
146
+ }
147
+ else {
148
+ // The height this balloon could use if it grew down to the hook line.
149
+ const potentialHeight = freeRect.bottom - lowestPreviousBottom(laid, freeRect.top)
150
+ + MIN_HOOK_HEIGHT;
151
+ const minWidth = Math.max(area / potentialHeight, input.metrics.widestWord(input.text));
152
+ goalWidth = minWidth + random() * (maxWidth - minWidth);
153
+ }
154
+ goalWidth = Math.min(goalWidth + WIDTH_FUDGE, maxWidth);
155
+ goalWidth = Math.min(goalWidth, len + WIDTH_FUDGE); // never wider than the text
156
+ let left;
157
+ if (input.isBox || input.arrowX === null) {
158
+ left = freeRect.left;
159
+ }
160
+ else {
161
+ // Straddle the speaker: the balloon's left is rolled inside
162
+ // [arrowX - goalWidth, arrowX], which guarantees the span covers arrowX
163
+ // so the tail drops straight.
164
+ const leftLimit = input.arrowX - goalWidth;
165
+ let startX = leftLimit + random() * (input.arrowX - leftLimit);
166
+ if (startX < freeRect.left)
167
+ startX = freeRect.left;
168
+ if (startX + goalWidth > freeRect.right)
169
+ startX = freeRect.right - goalWidth;
170
+ left = startX;
171
+ }
172
+ return { left, right: left + goalWidth, top: freeRect.top, bottom: freeRect.bottom };
173
+ }
174
+ /**
175
+ * Shrink or shift `brect` into the gap left by the corridors already
176
+ * claimed, then drop its top below whatever is already in the way. Mutates
177
+ * `brect`. Returns whether anything actually pushed the top down, which
178
+ * decides whether the balloon docks flush to the free rect's top.
179
+ */
180
+ function getInterveningBBox(input, laid, freeRect, brect) {
181
+ const toPtX = input.arrowX ?? freeRect.left;
182
+ let mostLeft = freeRect.left;
183
+ let mostRight = freeRect.right;
184
+ for (const prev of laid) {
185
+ const { leftAllowance, rightAllowance } = queryRouteRgn(prev, toPtX);
186
+ mostLeft = Math.max(leftAllowance, mostLeft);
187
+ mostRight = Math.min(rightAllowance, mostRight);
188
+ }
189
+ if (mostLeft > brect.left || mostRight < brect.right) { // can't be placed as is
190
+ const clearance = mostRight - mostLeft;
191
+ if (clearance >= brect.right - brect.left) { // shift it
192
+ const delta = mostLeft > brect.left ? mostLeft - brect.left : mostRight - brect.right;
193
+ brect.left += delta;
194
+ brect.right += delta;
195
+ }
196
+ else { // grab maximal clearance
197
+ brect.left = mostLeft;
198
+ brect.right = mostRight;
199
+ }
200
+ }
201
+ // Then the top: below the bottom of anything overlapping in x, and below
202
+ // the top of anything clear of it.
203
+ brect.top = freeRect.top;
204
+ for (const prev of laid) {
205
+ const cloud = prev.box;
206
+ if (cloud.x + cloud.width < brect.left) { // clear to one side — tuck alongside
207
+ brect.top = Math.max(brect.top, cloud.y);
208
+ }
209
+ else {
210
+ // overlapping balloons nest a little instead of stacking flush.
211
+ brect.top = Math.max(brect.top, cloud.y + cloud.height - DOCK_DELTA);
212
+ }
213
+ }
214
+ return brect.top > freeRect.top;
215
+ }
216
+ /** The balloon whose ink reaches furthest down the panel. */
217
+ function lowestPreviousBottom(laid, lowY) {
218
+ let lowest = lowY;
219
+ for (const prev of laid)
220
+ lowest = Math.max(lowest, prev.box.y + prev.box.height);
221
+ return lowest;
222
+ }
223
+ /**
224
+ * How far a *new* balloon aiming at `otherToX` may extend before it would
225
+ * block this one's tail. A balloon only defends the side its own speaker is
226
+ * on, and never lets its corridor be squeezed below {@link MIN_ROUTE_WIDTH}.
227
+ * A caption defends nothing, and so does a balloon whose speaker has no body
228
+ * on stage: neither drops a tail.
229
+ */
230
+ function queryRouteRgn(prev, otherToX) {
231
+ if (prev.input.isBox || prev.input.arrowX === null) {
232
+ return { leftAllowance: -LARGE, rightAllowance: LARGE };
233
+ }
234
+ const toX = prev.input.arrowX;
235
+ if (otherToX > toX) {
236
+ return {
237
+ leftAllowance: Math.max(toX, prev.routeRgn.left + MIN_ROUTE_WIDTH),
238
+ rightAllowance: LARGE,
239
+ };
240
+ }
241
+ return {
242
+ leftAllowance: -LARGE,
243
+ rightAllowance: Math.min(toX, prev.routeRgn.right - MIN_ROUTE_WIDTH),
244
+ };
245
+ }
246
+ /**
247
+ * The balloon just placed carves its own span out of every corridor claimed
248
+ * before it, so the balloons that follow are squeezed by the accumulated
249
+ * result rather than by the original rects.
250
+ */
251
+ function adjustRouteRgns(input, laid, routeRgn) {
252
+ const toX = input.arrowX;
253
+ if (toX === null)
254
+ return; // no tail, nothing to route around
255
+ for (const prev of laid) {
256
+ if (prev.input.arrowX === null)
257
+ continue;
258
+ if (toX > prev.input.arrowX)
259
+ prev.routeRgn.right = Math.min(prev.routeRgn.right, routeRgn.left);
260
+ else
261
+ prev.routeRgn.left = Math.max(prev.routeRgn.left, routeRgn.right);
262
+ }
263
+ }
264
+ /**
265
+ * Give the balloon the whole free rect, split off whatever hangs past the
266
+ * bottom, and dock it at the top. Only ever reached for a lone balloon in an
267
+ * otherwise empty panel — the case where refusing would achieve nothing.
268
+ */
269
+ function forceFitBalloon(input, freeRect) {
270
+ const width = freeRect.right - freeRect.left;
271
+ const split = input.metrics.split(input.text, width, freeRect.bottom - freeRect.top);
272
+ const measured = input.metrics.measure(split.text, width);
273
+ const box = {
274
+ x: freeRect.left, y: freeRect.top + TOP_BORDER, width: measured.width, height: measured.height,
275
+ };
276
+ return {
277
+ fits: true,
278
+ balloons: [{
279
+ input: { ...input, text: split.text },
280
+ box,
281
+ lines: measured.lines,
282
+ routeRgn: { left: box.x, right: box.x + box.width, bottom: box.y + box.height },
283
+ }],
284
+ leftover: split.rest,
285
+ };
286
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Balloons: speech, thought, and caption, drawn as a hand-drawn (wavy-edged)
3
+ * outline rather than a plain rounded rect.
4
+ *
5
+ * - `speech` — a balloon with a tail rooted on its speaker's head.
6
+ * - `thought` — a scalloped cloud with a bubble-trail tail.
7
+ * - `caption` — a plain box, spoken by nobody, no tail.
8
+ */
9
+ import type { Line } from "@toonstrip/schema";
10
+ export interface BalloonBox {
11
+ x: number;
12
+ y: number;
13
+ width: number;
14
+ height: number;
15
+ }
16
+ /** Where the tail should point: the figure's face anchor, in panel space. */
17
+ export interface TailTarget {
18
+ x: number;
19
+ y: number;
20
+ }
21
+ /**
22
+ * The one font every balloon is measured and drawn in. Exported because
23
+ * measurement happens in more than one place (the renderer, and any
24
+ * caller-side pre-check), and both must break lines identically.
25
+ */
26
+ export declare const BALLOON_FONT = "13px \"Comic Sans MS\", \"Comic Neue\", ui-rounded, cursive";
27
+ export declare const LINE_HEIGHT = 15;
28
+ /** Below this one-line width, a balloon stays a single line rather than being wrapped for shape. */
29
+ export declare const ONE_LINE_THRESHOLD = 100;
30
+ /** Ink-area fudge factor used when estimating a compact wrap width. */
31
+ export declare const AREA_FUDGE = 1.3;
32
+ /** Widest single word, the floor a balloon may not wrap below. */
33
+ export declare function widestWord(ctx: CanvasRenderingContext2D, text: string): number;
34
+ /**
35
+ * The width a balloon *wants* to wrap at. Short text stays one line; longer
36
+ * text is sized from its ink area so the balloon is compact rather than a
37
+ * wide flat strip, floored by the widest word and capped at the free width.
38
+ * Wrapping at this width is what makes a balloon read as a balloon shape
39
+ * rather than a text block.
40
+ */
41
+ export declare function goalWidth(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): number;
42
+ export declare function measureBalloon(ctx: CanvasRenderingContext2D, balloon: Pick<Line, "balloon" | "text">, maxWidth: number): {
43
+ lines: string[];
44
+ width: number;
45
+ height: number;
46
+ };
47
+ /** The chrome a balloon kind puts between its outline and its text, each side. */
48
+ export declare function chromeFor(balloon: Pick<Line, "balloon">): number;
49
+ /**
50
+ * Wrap and measure at a width the *caller* chose, rather than at the width
51
+ * {@link goalWidth} would pick. The entry point the multi-balloon layout
52
+ * driver needs, once the free rect and intervening routes have narrowed a
53
+ * balloon's span below its own preferred width.
54
+ */
55
+ export declare function measureAtWidth(ctx: CanvasRenderingContext2D, balloon: Pick<Line, "balloon" | "text">, boxWidth: number): {
56
+ lines: string[];
57
+ width: number;
58
+ height: number;
59
+ };
60
+ /** Appended to the part of a too-tall balloon that fit, prepended to the rest. */
61
+ export declare const CONTINUATION = "...";
62
+ /**
63
+ * Reflow the text at `boxWidth`, keep as many lines as `boxHeight` holds, and
64
+ * hand the rest back for the caller to re-add. Lets a balloon too tall for
65
+ * its free rect force-fit into it and split rather than being refused
66
+ * outright — the layout driver's ({@link "./balloon-layout.js".layoutBalloons})
67
+ * last resort for a lone balloon in an otherwise empty panel.
68
+ *
69
+ * At least one word is always kept: a word that fits nowhere is drawn
70
+ * clipped rather than handing back a leftover identical to the input.
71
+ */
72
+ export declare function splitHeight(ctx: CanvasRenderingContext2D, balloon: Pick<Line, "balloon" | "text">, boxWidth: number, boxHeight: number): {
73
+ text: string;
74
+ rest: string | null;
75
+ };
76
+ /**
77
+ * Draw a balloon in its kind's shape, with a tail toward `target` when the
78
+ * kind has one. Captions do not: a box is the narrator's, spoken by nobody.
79
+ */
80
+ export declare function drawBalloon(ctx: CanvasRenderingContext2D, balloon: Pick<Line, "balloon">, box: BalloonBox, lines: readonly string[], target: TailTarget | null): void;