canvas-globe 1.1.0 → 1.2.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,571 @@
1
+ /**
2
+ * Engine effects: the ones that needed real rendering work rather than a
3
+ * thin wrapper over existing options.
4
+ */
5
+ import {
6
+ clamp01, easeInOut, easeOut, label, lerp, panel, particles, pingPong,
7
+ releaseScratch, rng, scratch, TAU,
8
+ } from "../runtime.js";
9
+
10
+ const D2R = Math.PI / 180;
11
+
12
+ /**
13
+ * The sphere peels open into a flat world map, vertex by vertex.
14
+ * Ports catalogue effect C.
15
+ */
16
+ export const mapUnfold = ({
17
+ duration = 3200,
18
+ hold = 500,
19
+ fill = "#2dd4bf",
20
+ stroke = "#5eead4",
21
+ background = "#0b1220",
22
+ } = {}) => ({
23
+ name: "mapUnfold",
24
+ stage: "above",
25
+ z: 20,
26
+ duration,
27
+ hold,
28
+ frame(ctx, globe, t) {
29
+ const w = globe.canvas.clientWidth, h = globe.canvas.clientHeight;
30
+ const k = easeInOut(pingPong(t) * 2 > 1 ? 1 : pingPong(t) * 2);
31
+ const R = Math.min(w, h) * 0.42;
32
+ ctx.fillStyle = background;
33
+ ctx.fillRect(0, 0, w, h);
34
+ ctx.beginPath();
35
+ for (const shape of globe.world) {
36
+ const geom = shape.geometry;
37
+ const polys = geom.type === "Polygon" ? [geom.coordinates] : geom.coordinates;
38
+ for (const poly of polys) {
39
+ for (const ring of poly) {
40
+ let open = false;
41
+ for (const [lon, lat] of ring) {
42
+ const la = lat * D2R, lo = lon * D2R;
43
+ const ox = R * Math.cos(la) * Math.sin(lo);
44
+ const oy = -R * Math.sin(la);
45
+ const facing = Math.cos(la) * Math.cos(lo);
46
+ const fx = (lon / 180) * R * 1.35, fy = -(lat / 90) * R * 0.78;
47
+ // Back-facing vertices only exist once the sphere has opened out.
48
+ if (facing < 0 && k < 0.5) {
49
+ open = false;
50
+ continue;
51
+ }
52
+ const x = w / 2 + ox + (fx - ox) * k;
53
+ const y = h / 2 + oy + (fy - oy) * k;
54
+ open ? ctx.lineTo(x, y) : (ctx.moveTo(x, y), (open = true));
55
+ }
56
+ if (open) ctx.closePath();
57
+ }
58
+ }
59
+ }
60
+ ctx.fillStyle = fill;
61
+ ctx.globalAlpha = 0.85;
62
+ ctx.fill();
63
+ ctx.globalAlpha = 1;
64
+ ctx.strokeStyle = stroke;
65
+ ctx.lineWidth = 0.6;
66
+ ctx.stroke();
67
+ },
68
+ });
69
+
70
+ /**
71
+ * A shell launches from a location and bursts into falling sparks.
72
+ * Ports catalogue effect T.
73
+ */
74
+ export const firework = ({
75
+ at,
76
+ duration = 2800,
77
+ hold = 500,
78
+ sparks = 70,
79
+ rise = 90,
80
+ seed = 3,
81
+ colors = ["#fde68a", "#fb7185", "#a78bfa", "#67e8f9"],
82
+ } = {}) => ({
83
+ name: "firework",
84
+ stage: "above",
85
+ duration,
86
+ hold,
87
+ setup(globe) {
88
+ return {
89
+ origin: at || globe.markers[0] || { lon: globe.lon, lat: globe.lat },
90
+ parts: particles(sparks, seed, (r) => ({
91
+ a: r() * TAU,
92
+ v: 0.4 + r() * 0.8,
93
+ c: colors[Math.floor(r() * colors.length) % colors.length],
94
+ })),
95
+ };
96
+ },
97
+ frame(ctx, globe, t, state) {
98
+ const p = globe.project(state.origin.lon, state.origin.lat);
99
+ if (!p) return;
100
+ if (t < 0.34) {
101
+ ctx.fillStyle = "#fde68a";
102
+ ctx.beginPath();
103
+ ctx.arc(p.x, p.y - easeOut(t / 0.34) * rise, 2.4, 0, TAU);
104
+ ctx.fill();
105
+ return;
106
+ }
107
+ const k = (t - 0.34) / 0.66;
108
+ for (const s of state.parts) {
109
+ ctx.globalAlpha = Math.max(0, 1 - k);
110
+ ctx.fillStyle = s.c;
111
+ ctx.beginPath();
112
+ ctx.arc(
113
+ p.x + Math.cos(s.a) * s.v * k * rise,
114
+ p.y - rise + Math.sin(s.a) * s.v * k * rise + k * k * 120,
115
+ 1.9, 0, TAU
116
+ );
117
+ ctx.fill();
118
+ }
119
+ ctx.globalAlpha = 1;
120
+ },
121
+ });
122
+
123
+ /**
124
+ * RGB split plus sliced row offsets, firing in short bursts.
125
+ * Ports catalogue effect W.
126
+ */
127
+ export const glitch = ({ duration = 2600, slices = 9, spread = 14, seed = 11 } = {}) => ({
128
+ name: "glitch",
129
+ stage: "post",
130
+ z: 65,
131
+ duration,
132
+ setup() {
133
+ return { cuts: particles(slices, seed, (r) => ({ y: r(), h: 0.02 + r() * 0.06, d: r() - 0.5 })) };
134
+ },
135
+ frame(ctx, globe, t, state) {
136
+ // Bursts, not a constant wobble: quiet for most of the cycle.
137
+ const burst = t > 0.12 && t < 0.24 ? 1 : t > 0.62 && t < 0.7 ? 1 : 0;
138
+ if (!burst) return;
139
+ const buf = scratch(globe, "glitch");
140
+ if (!buf) return;
141
+ const c = globe.canvas;
142
+ if (buf.canvas.width !== c.width || buf.canvas.height !== c.height) {
143
+ buf.canvas.width = c.width;
144
+ buf.canvas.height = c.height;
145
+ }
146
+ buf.ctx.setTransform(1, 0, 0, 1, 0, 0);
147
+ buf.ctx.clearRect(0, 0, c.width, c.height);
148
+ buf.ctx.drawImage(c, 0, 0);
149
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
150
+ ctx.globalCompositeOperation = "lighter";
151
+ ctx.globalAlpha = 0.5;
152
+ ctx.drawImage(buf.canvas, spread, 0);
153
+ ctx.drawImage(buf.canvas, -spread, 0);
154
+ ctx.globalAlpha = 1;
155
+ ctx.globalCompositeOperation = "source-over";
156
+ for (const cut of state.cuts) {
157
+ const y = cut.y * c.height, hh = cut.h * c.height;
158
+ ctx.drawImage(buf.canvas, 0, y, c.width, hh, cut.d * spread * 4, y, c.width, hh);
159
+ }
160
+ },
161
+ dispose(_state, globe) {
162
+ releaseScratch(globe, "glitch");
163
+ },
164
+ });
165
+
166
+ /**
167
+ * A broadcast lower-third that slides in and names the place.
168
+ * Ports catalogue effect AB.
169
+ */
170
+ export const lowerThird = ({
171
+ title = "",
172
+ subtitle = "",
173
+ duration = 2800,
174
+ hold = 2000,
175
+ accent = "#34d399",
176
+ } = {}) => ({
177
+ name: "lowerThird",
178
+ stage: "above",
179
+ z: 45,
180
+ duration,
181
+ hold,
182
+ frame(ctx, globe, t) {
183
+ const w = globe.canvas.clientWidth, h = globe.canvas.clientHeight;
184
+ const slide = easeOut(clamp01(t * 3));
185
+ const out = 1 - easeOut(clamp01((t - 0.86) / 0.14));
186
+ const width = Math.min(320, w - 48);
187
+ const x = 24 - (1 - slide) * (width + 32);
188
+ const y = h - 96;
189
+ ctx.globalAlpha = out;
190
+ panel(ctx, x, y, width, 54, { stroke: "rgba(232,236,245,.14)" });
191
+ ctx.fillStyle = accent;
192
+ ctx.fillRect(x, y, 3, 54);
193
+ label(ctx, title || globe.markers[0]?.city || "", x + 16, y + 24, { size: 16, weight: 800 });
194
+ label(ctx, subtitle, x + 16, y + 42, { size: 11, weight: 600, color: "rgba(232,236,245,.66)" });
195
+ ctx.globalAlpha = 1;
196
+ },
197
+ });
198
+
199
+ /**
200
+ * A split-flap board that clatters to its final number.
201
+ * Ports catalogue effect AC.
202
+ */
203
+ export const splitFlap = ({
204
+ to = 0,
205
+ duration = 2600,
206
+ hold = 1600,
207
+ caption = "",
208
+ digits = 5,
209
+ accent = "#e8ecf5",
210
+ seed = 17,
211
+ } = {}) => ({
212
+ name: "splitFlap",
213
+ stage: "above",
214
+ z: 45,
215
+ duration,
216
+ hold,
217
+ setup() {
218
+ return { noise: rng(seed) };
219
+ },
220
+ frame(ctx, globe, t) {
221
+ const w = globe.canvas.clientWidth, h = globe.canvas.clientHeight;
222
+ const target = String(Math.round(to)).padStart(digits, "0");
223
+ const cell = 26, gap = 4;
224
+ const total = digits * cell + (digits - 1) * gap;
225
+ const x0 = (w - total) / 2, y = h - 84;
226
+ for (let i = 0; i < digits; i++) {
227
+ // Each wheel locks in turn, left to right.
228
+ const settled = clamp01(t * digits * 1.25 - i);
229
+ const shown = settled >= 1
230
+ ? target[i]
231
+ : String(Math.floor(Math.abs(Math.sin((t * 40 + i * 7))) * 10) % 10);
232
+ const x = x0 + i * (cell + gap);
233
+ panel(ctx, x, y, cell, 36, { fill: "#11131c", stroke: "rgba(232,236,245,.16)", radius: 4 });
234
+ ctx.strokeStyle = "rgba(0,0,0,.6)";
235
+ ctx.beginPath();
236
+ ctx.moveTo(x, y + 18);
237
+ ctx.lineTo(x + cell, y + 18);
238
+ ctx.stroke();
239
+ label(ctx, shown, x + cell / 2, y + 25, {
240
+ size: 20, weight: 800, align: "center",
241
+ color: settled >= 1 ? accent : "rgba(232,236,245,.55)",
242
+ });
243
+ }
244
+ if (caption) {
245
+ label(ctx, caption, w / 2, y + 52, {
246
+ size: 10, weight: 700, align: "center", color: "rgba(232,236,245,.5)",
247
+ });
248
+ }
249
+ },
250
+ });
251
+
252
+ /**
253
+ * Letters ride the curve of the sphere, each rotated to the tangent.
254
+ * Ports catalogue effect AD.
255
+ */
256
+ export const textOnCircle = ({
257
+ text = "• SHIPPING WORLDWIDE • ",
258
+ duration = 12000,
259
+ color = "#a7f3d0",
260
+ size = 13,
261
+ offset = 1.1,
262
+ } = {}) => ({
263
+ name: "textOnCircle",
264
+ stage: "above",
265
+ z: 35,
266
+ duration,
267
+ frame(ctx, globe, t) {
268
+ const w = globe.canvas.clientWidth, h = globe.canvas.clientHeight;
269
+ const cx = w / 2, cy = h / 2;
270
+ const radius = globe.o.mode === "globe" ? globe._radius(w, h) : Math.min(w, h) * 0.4;
271
+ const factor = offset > 3 ? 1 + offset / Math.max(1, radius) : offset;
272
+ const R = radius * Math.max(0.9, Math.min(1.45, factor));
273
+ const content = text.length < 18 ? `${text} • ${text}` : text;
274
+ ctx.font = `800 ${size}px Inter, system-ui, sans-serif`;
275
+ ctx.textAlign = "center";
276
+ ctx.textBaseline = "middle";
277
+ ctx.fillStyle = color;
278
+ ctx.strokeStyle = "rgba(4,8,18,.92)";
279
+ ctx.lineWidth = Math.max(2.5, size * 0.28);
280
+ ctx.shadowColor = "rgba(0,0,0,.8)";
281
+ ctx.shadowBlur = 6;
282
+ const step = TAU / content.length;
283
+ for (let i = 0; i < content.length; i++) {
284
+ const a = i * step + t * TAU - Math.PI / 2;
285
+ ctx.save();
286
+ ctx.translate(cx + Math.cos(a) * R, cy + Math.sin(a) * R);
287
+ ctx.rotate(a + Math.PI / 2);
288
+ ctx.strokeText(content[i], 0, 0);
289
+ ctx.fillText(content[i], 0, 0);
290
+ ctx.restore();
291
+ }
292
+ ctx.shadowBlur = 0;
293
+ ctx.textAlign = "left";
294
+ ctx.textBaseline = "alphabetic";
295
+ },
296
+ });
297
+
298
+ /**
299
+ * A country outline becomes a window onto a moving gradient.
300
+ * Ports catalogue effect AE.
301
+ */
302
+ export const countryMatte = ({
303
+ country,
304
+ duration = 4200,
305
+ stops = ["#f97316", "#fbbf24", "#ffffff", "#22c55e"],
306
+ outline = "#fff",
307
+ } = {}) => ({
308
+ name: "countryMatte",
309
+ stage: "above",
310
+ z: 25,
311
+ duration,
312
+ setup(globe) {
313
+ const want = country || globe.o.focus?.country;
314
+ const shape = globe.world.find(
315
+ (s) => s.code === want || s.name === want
316
+ ) || globe.world[0];
317
+ return { shape };
318
+ },
319
+ frame(ctx, globe, t, state) {
320
+ if (!state.shape) return;
321
+ const w = globe.canvas.clientWidth, h = globe.canvas.clientHeight;
322
+ ctx.save();
323
+ ctx.beginPath();
324
+ globe.tracePath(state.shape, ctx);
325
+ ctx.clip();
326
+ const shift = t * w * 2 - w * 0.5;
327
+ const grad = ctx.createLinearGradient(shift - w * 0.6, 0, shift + w * 0.6, h);
328
+ stops.forEach((c, i) => grad.addColorStop(i / (stops.length - 1), c));
329
+ ctx.fillStyle = grad;
330
+ ctx.fillRect(0, 0, w, h);
331
+ ctx.restore();
332
+ ctx.beginPath();
333
+ globe.tracePath(state.shape, ctx);
334
+ ctx.strokeStyle = outline;
335
+ ctx.lineWidth = 1.6;
336
+ ctx.stroke();
337
+ },
338
+ });
339
+
340
+ /**
341
+ * Earlier positions linger as fading ghosts behind the current one.
342
+ * Ports catalogue effect AO.
343
+ */
344
+ export const ghostTrail = ({ duration = 5000, hold = 800, ghosts = 5, color = "#38bdf8" } = {}) => ({
345
+ name: "ghostTrail",
346
+ stage: "above",
347
+ duration,
348
+ hold,
349
+ frame(ctx, globe, t) {
350
+ const markers = globe.markers;
351
+ if (markers.length < 2) return;
352
+ const hub = markers[0];
353
+ ctx.globalCompositeOperation = "lighter";
354
+ for (let i = 1; i < Math.min(markers.length, 7); i++) {
355
+ const target = markers[i];
356
+ const phase = clamp01(t * 1.45 - (i - 1) * 0.1);
357
+ for (let g = ghosts; g >= 0; g--) {
358
+ const k = clamp01(phase - g * 0.055);
359
+ if (k <= 0) continue;
360
+ const eased = easeInOut(k);
361
+ const lon = lerp(hub.lon, target.lon, eased);
362
+ const lat = lerp(hub.lat, target.lat, eased) + Math.sin(eased * Math.PI) * 13;
363
+ const p = globe.project(lon, lat);
364
+ if (!p) continue;
365
+ const head = g === 0;
366
+ ctx.globalAlpha = head ? 1 : 0.58 * (1 - g / (ghosts + 1));
367
+ ctx.fillStyle = target.color || color;
368
+ ctx.shadowColor = target.color || color;
369
+ ctx.shadowBlur = head ? 14 : 5;
370
+ ctx.beginPath();
371
+ ctx.arc(p.x, p.y, head ? 4.5 : Math.max(1.2, 3.4 - g * 0.25), 0, TAU);
372
+ ctx.fill();
373
+ }
374
+ }
375
+ ctx.shadowBlur = 0;
376
+ ctx.globalAlpha = 1;
377
+ ctx.globalCompositeOperation = "source-over";
378
+ },
379
+ });
380
+
381
+ /**
382
+ * Particles streaming along a vector field over the sphere.
383
+ *
384
+ * Particle positions advance from the previous frame, so this reproduces in
385
+ * sequential order rather than under random seeking.
386
+ * Ports catalogue effect AQ.
387
+ */
388
+ export const windField = ({ count = 500, seed = 23, speed = 0.42, life = 110, fade = 0.13 } = {}) => ({
389
+ name: "windField",
390
+ stage: "above",
391
+ z: 15,
392
+ duration: 1000,
393
+ setup() {
394
+ const random = rng(seed);
395
+ const spawn = () => ({
396
+ lon: random() * 360 - 180,
397
+ lat: random() * 150 - 75,
398
+ age: random() * 90,
399
+ });
400
+ return { pts: Array.from({ length: count }, spawn), spawn };
401
+ },
402
+ frame(ctx, globe, _t, state) {
403
+ const buf = scratch(globe, "windField");
404
+ if (!buf) return;
405
+ const c = globe.canvas;
406
+ const w = c.clientWidth, h = c.clientHeight;
407
+ const dpr = c.width / Math.max(1, w);
408
+ if (buf.canvas.width !== c.width || buf.canvas.height !== c.height) {
409
+ buf.canvas.width = c.width;
410
+ buf.canvas.height = c.height;
411
+ }
412
+ buf.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
413
+ buf.ctx.globalCompositeOperation = "source-over";
414
+ buf.ctx.fillStyle = `rgba(4,8,18,${fade})`;
415
+ buf.ctx.fillRect(0, 0, w, h);
416
+ buf.ctx.lineWidth = 1;
417
+ for (const p of state.pts) {
418
+ const a = globe.project(p.lon, p.lat);
419
+ const u = Math.cos(p.lat * D2R * 3) * 1.6 + Math.sin(p.lon * D2R * 2) * 0.9 + 1.4;
420
+ const v = Math.sin(p.lon * D2R * 3) * 0.7 - Math.cos(p.lat * D2R * 2) * 0.4;
421
+ p.lon += u * speed;
422
+ p.lat += v * speed;
423
+ p.age++;
424
+ if (p.lon > 180) p.lon -= 360;
425
+ if (p.lon < -180) p.lon += 360;
426
+ const b = globe.project(p.lon, p.lat);
427
+ if (a && b && Math.abs(a.x - b.x) < 40) {
428
+ const mag = Math.min(1, Math.hypot(u, v) / 2.6);
429
+ buf.ctx.strokeStyle = `hsla(${196 - mag * 60} 95% ${58 + mag * 22}% / .85)`;
430
+ buf.ctx.beginPath();
431
+ buf.ctx.moveTo(a.x, a.y);
432
+ buf.ctx.lineTo(b.x, b.y);
433
+ buf.ctx.stroke();
434
+ }
435
+ if (p.age > life || Math.abs(p.lat) > 84) Object.assign(p, state.spawn(), { age: 0 });
436
+ }
437
+ ctx.save();
438
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
439
+ ctx.globalCompositeOperation = "lighter";
440
+ ctx.drawImage(buf.canvas, 0, 0);
441
+ ctx.restore();
442
+ },
443
+ dispose(_state, globe) {
444
+ releaseScratch(globe, "windField");
445
+ },
446
+ });
447
+
448
+ /**
449
+ * Curtains of light over the poles.
450
+ * Ports catalogue effect AS.
451
+ */
452
+ export const aurora = ({
453
+ duration = 7000,
454
+ colors = ["#34d399", "#22d3ee", "#a78bfa"],
455
+ bands = 3,
456
+ lat = 68,
457
+ } = {}) => ({
458
+ name: "aurora",
459
+ stage: "above",
460
+ z: 18,
461
+ duration,
462
+ frame(ctx, globe, t) {
463
+ if (globe.o.mode !== "globe") return;
464
+ ctx.globalCompositeOperation = "lighter";
465
+ for (let b = 0; b < bands; b++) {
466
+ const phase = t * TAU + b * 1.3;
467
+ ctx.strokeStyle = colors[b % colors.length];
468
+ ctx.lineWidth = 8 - b * 1.6;
469
+ ctx.globalAlpha = 0.16 + 0.1 * Math.sin(phase);
470
+ for (const sign of [1, -1]) {
471
+ ctx.beginPath();
472
+ let open = false;
473
+ for (let lon = -180; lon <= 180; lon += 4) {
474
+ const wob = Math.sin(lon * D2R * 3 + phase) * 4 + Math.sin(lon * D2R * 5 - phase) * 2.5;
475
+ const p = globe.project(lon, sign * (lat + b * 2.5) + wob);
476
+ if (!p) {
477
+ open = false;
478
+ continue;
479
+ }
480
+ open ? ctx.lineTo(p.x, p.y) : (ctx.moveTo(p.x, p.y), (open = true));
481
+ }
482
+ ctx.stroke();
483
+ }
484
+ }
485
+ ctx.globalAlpha = 1;
486
+ ctx.globalCompositeOperation = "source-over";
487
+ },
488
+ });
489
+
490
+ /**
491
+ * The whole sphere squashes and stretches as if it had weight.
492
+ * Repaints the finished frame so the deformation affects the whole scene.
493
+ * Ports catalogue effect AX.
494
+ */
495
+ export const jellySquash = ({ duration = 2200, amount = 0.13 } = {}) => ({
496
+ name: "jellySquash",
497
+ stage: "post",
498
+ z: 85,
499
+ duration,
500
+ frame(ctx, globe, t) {
501
+ const c = globe.canvas;
502
+ const w = c.width, h = c.height;
503
+ const wobble = Math.sin(t * TAU * 2) * (0.35 + 0.65 * (1 - t)) * amount;
504
+ const buf = scratch(globe, "jellySquash");
505
+ if (!buf) return;
506
+ if (buf.canvas.width !== w || buf.canvas.height !== h) {
507
+ buf.canvas.width = w;
508
+ buf.canvas.height = h;
509
+ }
510
+ buf.ctx.setTransform(1, 0, 0, 1, 0, 0);
511
+ buf.ctx.clearRect(0, 0, w, h);
512
+ buf.ctx.drawImage(c, 0, 0);
513
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
514
+ ctx.clearRect(0, 0, w, h);
515
+ ctx.translate(w / 2, h / 2);
516
+ ctx.scale(1 + wobble, 1 - wobble);
517
+ ctx.translate(-w / 2, -h / 2);
518
+ ctx.drawImage(buf.canvas, 0, 0);
519
+ },
520
+ dispose(_state, globe) {
521
+ releaseScratch(globe, "jellySquash");
522
+ },
523
+ });
524
+
525
+ /**
526
+ * Newsroom furniture: headline, source line and a scale bar.
527
+ * Ports catalogue effect BF.
528
+ */
529
+ export const dataDesk = ({
530
+ title = "",
531
+ standfirst = "",
532
+ source = "",
533
+ accent = "#f59e0b",
534
+ } = {}) => ({
535
+ name: "dataDesk",
536
+ stage: "above",
537
+ z: 48,
538
+ duration: 1000,
539
+ frame(ctx, globe) {
540
+ const w = globe.canvas.clientWidth, h = globe.canvas.clientHeight;
541
+ const boxW = Math.min(330, Math.max(220, w * 0.43));
542
+ const boxH = standfirst ? 116 : 88;
543
+ panel(ctx, 14, 14, boxW, boxH, { fill: "rgba(5,10,20,.9)", stroke: "rgba(232,236,245,.18)", radius: 10 });
544
+ ctx.fillStyle = accent;
545
+ ctx.fillRect(14, 14, 4, boxH);
546
+ label(ctx, "LIVE DATA", 30, 36, { size: 9, weight: 800, color: accent });
547
+ label(ctx, title || "Global activity", 30, 62, { size: Math.min(19, Math.max(15, boxW / 17)), weight: 800 });
548
+ if (standfirst) {
549
+ const words = standfirst.split(/\s+/);
550
+ const lines = [];
551
+ let line = "";
552
+ ctx.font = "500 10px Inter, system-ui, sans-serif";
553
+ for (const word of words) {
554
+ const next = line ? `${line} ${word}` : word;
555
+ if (ctx.measureText(next).width > boxW - 48 && line) {
556
+ lines.push(line);
557
+ line = word;
558
+ } else line = next;
559
+ }
560
+ if (line) lines.push(line);
561
+ lines.slice(0, 2).forEach((copy, i) => label(ctx, copy, 30, 82 + i * 14, { size: 10, weight: 500, color: "rgba(232,236,245,.72)" }));
562
+ }
563
+ if (source) {
564
+ label(ctx, `SOURCE ${source}`, 18, h - 18, { size: 9, weight: 700, color: "rgba(232,236,245,.55)" });
565
+ }
566
+ panel(ctx, w - 112, h - 48, 98, 34, { fill: "rgba(5,10,20,.82)", stroke: "rgba(232,236,245,.16)", radius: 7 });
567
+ ctx.fillStyle = accent;
568
+ ctx.fillRect(w - 100, h - 29, 68, 2);
569
+ label(ctx, "1,000 KM", w - 100, h - 34, { size: 8, weight: 700, color: "rgba(232,236,245,.62)" });
570
+ },
571
+ });