@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.
- package/dist/balloon-layout.d.ts +111 -0
- package/dist/balloon-layout.js +286 -0
- package/dist/balloon.d.ts +80 -0
- package/dist/balloon.js +335 -0
- package/dist/cast-manifest.d.ts +60 -0
- package/dist/cast-manifest.js +10 -0
- package/dist/emotion.d.ts +29 -0
- package/dist/emotion.js +38 -0
- package/dist/figure.d.ts +65 -0
- package/dist/figure.js +93 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/layout.d.ts +71 -0
- package/dist/layout.js +71 -0
- package/dist/pose.d.ts +64 -0
- package/dist/pose.js +165 -0
- package/dist/render.d.ts +190 -0
- package/dist/render.js +511 -0
- package/package.json +30 -0
|
@@ -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;
|