@bendyline/squisq 0.1.1
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/Types-sh2VRxfo.d.ts +15 -0
- package/dist/chunk-7UDSRZKG.js +103 -0
- package/dist/chunk-7UDSRZKG.js.map +1 -0
- package/dist/chunk-O5HCIUAD.js +577 -0
- package/dist/chunk-O5HCIUAD.js.map +1 -0
- package/dist/chunk-PJ7AODIM.js +3040 -0
- package/dist/chunk-PJ7AODIM.js.map +1 -0
- package/dist/chunk-QWVRN6I4.js +81 -0
- package/dist/chunk-QWVRN6I4.js.map +1 -0
- package/dist/chunk-S4F2RY37.js +92 -0
- package/dist/chunk-S4F2RY37.js.map +1 -0
- package/dist/chunk-URU6QMLY.js +888 -0
- package/dist/chunk-URU6QMLY.js.map +1 -0
- package/dist/chunk-VJN7UB2Z.js +145 -0
- package/dist/chunk-VJN7UB2Z.js.map +1 -0
- package/dist/doc/index.d.ts +3 -0
- package/dist/doc/index.js +126 -0
- package/dist/doc/index.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +217 -0
- package/dist/index.js.map +1 -0
- package/dist/markdown/index.d.ts +237 -0
- package/dist/markdown/index.js +33 -0
- package/dist/markdown/index.js.map +1 -0
- package/dist/schemas/index.d.ts +70 -0
- package/dist/schemas/index.js +61 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/spatial/index.d.ts +111 -0
- package/dist/spatial/index.js +25 -0
- package/dist/spatial/index.js.map +1 -0
- package/dist/storage/index.d.ts +91 -0
- package/dist/storage/index.js +11 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/story/index.d.ts +691 -0
- package/dist/story/index.js +126 -0
- package/dist/story/index.js.map +1 -0
- package/dist/themeLibrary-DySHPcgj.d.ts +1352 -0
- package/dist/types-DHiv_Pnm.d.ts +496 -0
- package/package.json +91 -0
- package/src/__tests__/animationUtils.test.ts +77 -0
- package/src/__tests__/geohash.test.ts +109 -0
- package/src/__tests__/getLayers.test.ts +200 -0
- package/src/__tests__/haversine.test.ts +63 -0
- package/src/__tests__/localForageAdapter.test.ts +155 -0
- package/src/__tests__/markdown.test.ts +806 -0
- package/src/__tests__/markdownToDoc.test.ts +374 -0
- package/src/__tests__/storage.test.ts +60 -0
- package/src/__tests__/templates.test.ts +247 -0
- package/src/doc/docToMarkdown.ts +98 -0
- package/src/doc/getLayers.ts +152 -0
- package/src/doc/index.ts +8 -0
- package/src/doc/markdownToDoc.ts +277 -0
- package/src/doc/templates/accentImage.ts +338 -0
- package/src/doc/templates/captionUtils.ts +33 -0
- package/src/doc/templates/comparisonBar.ts +140 -0
- package/src/doc/templates/coverBlock.ts +156 -0
- package/src/doc/templates/dateEvent.ts +122 -0
- package/src/doc/templates/definitionCard.ts +129 -0
- package/src/doc/templates/factCard.ts +114 -0
- package/src/doc/templates/fullBleedQuote.ts +59 -0
- package/src/doc/templates/imageWithCaption.ts +167 -0
- package/src/doc/templates/index.ts +571 -0
- package/src/doc/templates/listBlock.ts +101 -0
- package/src/doc/templates/mapBlock.ts +132 -0
- package/src/doc/templates/persistentLayers.ts +522 -0
- package/src/doc/templates/photoGrid.ts +199 -0
- package/src/doc/templates/pullQuote.ts +119 -0
- package/src/doc/templates/quoteBlock.ts +112 -0
- package/src/doc/templates/sectionHeader.ts +105 -0
- package/src/doc/templates/statHighlight.ts +113 -0
- package/src/doc/templates/titleBlock.ts +100 -0
- package/src/doc/templates/twoColumn.ts +239 -0
- package/src/doc/templates/videoPullQuote.ts +128 -0
- package/src/doc/templates/videoWithCaption.ts +126 -0
- package/src/doc/utils/animationUtils.ts +135 -0
- package/src/doc/utils/themeUtils.ts +139 -0
- package/src/index.ts +5 -0
- package/src/markdown/convert.ts +897 -0
- package/src/markdown/htmlParse.ts +212 -0
- package/src/markdown/index.ts +110 -0
- package/src/markdown/parse.ts +103 -0
- package/src/markdown/stringify.ts +122 -0
- package/src/markdown/types.ts +605 -0
- package/src/markdown/utils.ts +165 -0
- package/src/ngeohash.d.ts +11 -0
- package/src/schemas/BlockTemplates.ts +706 -0
- package/src/schemas/Doc.ts +565 -0
- package/src/schemas/LayoutStrategy.ts +178 -0
- package/src/schemas/MediaProvider.ts +70 -0
- package/src/schemas/Theme.ts +235 -0
- package/src/schemas/Types.ts +15 -0
- package/src/schemas/Viewport.ts +91 -0
- package/src/schemas/index.ts +14 -0
- package/src/schemas/themeLibrary.ts +492 -0
- package/src/spatial/Geohash.ts +168 -0
- package/src/spatial/Haversine.ts +59 -0
- package/src/spatial/index.ts +2 -0
- package/src/storage/LocalForageAdapter.ts +103 -0
- package/src/storage/LocalStorageAdapter.ts +57 -0
- package/src/storage/MemoryStorageAdapter.ts +37 -0
- package/src/storage/Storage.ts +28 -0
- package/src/storage/index.ts +5 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accent Image Utility
|
|
3
|
+
*
|
|
4
|
+
* Creates image layers and layout adjustments for accent images on text-based slides.
|
|
5
|
+
* Accent images are tasteful additions that complement text without overwhelming it.
|
|
6
|
+
*
|
|
7
|
+
* Layout patterns:
|
|
8
|
+
* - left-strip: 35% width vertical strip on left, text area shifted to right 65%
|
|
9
|
+
* - right-strip: 35% width vertical strip on right, text area shifted to left 65%
|
|
10
|
+
* - bottom-strip: 35% height horizontal strip at bottom, text area in upper 65%
|
|
11
|
+
* - corner-inset: Small 25% corner image with gradient vignette
|
|
12
|
+
*
|
|
13
|
+
* This is shared code used by both site and efb-app doc renderers.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Layer, ImageLayer, ShapeLayer, Animation } from '../../schemas/Doc.js';
|
|
17
|
+
import type { AccentImage, AccentPosition } from '../../schemas/BlockTemplates.js';
|
|
18
|
+
|
|
19
|
+
const PERCENTAGE_RE = /^(\d+(?:\.\d+)?)\s*%?$/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Layout adjustments when an accent image is present.
|
|
23
|
+
* Templates use these values to reposition their text content.
|
|
24
|
+
*/
|
|
25
|
+
export interface AccentLayout {
|
|
26
|
+
/** X offset for text center (e.g., '64%' for left-strip) */
|
|
27
|
+
textCenterX: string;
|
|
28
|
+
/** Width available for text content */
|
|
29
|
+
textWidth: string;
|
|
30
|
+
/** Y offset adjustment for vertical positioning */
|
|
31
|
+
textYAdjust: number;
|
|
32
|
+
/** Whether to adjust Y positions (for bottom-strip) */
|
|
33
|
+
adjustY: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Default layout when no accent image is present.
|
|
38
|
+
*/
|
|
39
|
+
export const DEFAULT_LAYOUT: AccentLayout = {
|
|
40
|
+
textCenterX: '50%',
|
|
41
|
+
textWidth: '85%',
|
|
42
|
+
textYAdjust: 0,
|
|
43
|
+
adjustY: false,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Map ambientMotion values to proper Ken Burns animations.
|
|
48
|
+
* Uses slowZoom variants which provide smooth pan/zoom without opacity changes.
|
|
49
|
+
* Supports both standard values ('zoomIn', 'panLeft', etc.) and legacy
|
|
50
|
+
* aliases ('in', 'out', 'left', 'right') for backward compatibility.
|
|
51
|
+
*
|
|
52
|
+
* @param motion - The ambient motion value (e.g., 'zoomIn', 'panLeft')
|
|
53
|
+
* @param duration - Optional animation duration in seconds
|
|
54
|
+
*/
|
|
55
|
+
export function mapAmbientMotion(
|
|
56
|
+
motion: string | undefined,
|
|
57
|
+
duration?: number,
|
|
58
|
+
): Animation | undefined {
|
|
59
|
+
if (!motion) return undefined;
|
|
60
|
+
const base = duration !== undefined ? { duration } : {};
|
|
61
|
+
switch (motion) {
|
|
62
|
+
case 'zoomIn':
|
|
63
|
+
case 'in':
|
|
64
|
+
return { type: 'slowZoom', direction: 'in', ...base };
|
|
65
|
+
case 'zoomOut':
|
|
66
|
+
case 'out':
|
|
67
|
+
return { type: 'slowZoom', direction: 'out', ...base };
|
|
68
|
+
case 'panLeft':
|
|
69
|
+
case 'left':
|
|
70
|
+
return { type: 'slowZoom', panDirection: 'left', ...base };
|
|
71
|
+
case 'panRight':
|
|
72
|
+
case 'right':
|
|
73
|
+
return { type: 'slowZoom', panDirection: 'right', ...base };
|
|
74
|
+
default:
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Accent strip width/height as percentage.
|
|
81
|
+
* 35% gives the image enough presence to complement the text without overwhelming it.
|
|
82
|
+
*/
|
|
83
|
+
const STRIP_SIZE = 35;
|
|
84
|
+
const STRIP_SIZE_PCT = `${STRIP_SIZE}%`;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get layout adjustments based on accent position.
|
|
88
|
+
*/
|
|
89
|
+
export function getAccentLayout(position: AccentPosition): AccentLayout {
|
|
90
|
+
switch (position) {
|
|
91
|
+
case 'left-strip':
|
|
92
|
+
return {
|
|
93
|
+
textCenterX: `${50 + STRIP_SIZE / 2}%`, // Shift center right
|
|
94
|
+
textWidth: `${100 - STRIP_SIZE - 8}%`, // Leave margin
|
|
95
|
+
textYAdjust: 0,
|
|
96
|
+
adjustY: false,
|
|
97
|
+
};
|
|
98
|
+
case 'right-strip':
|
|
99
|
+
return {
|
|
100
|
+
textCenterX: `${50 - STRIP_SIZE / 2}%`, // Shift center left
|
|
101
|
+
textWidth: `${100 - STRIP_SIZE - 8}%`,
|
|
102
|
+
textYAdjust: 0,
|
|
103
|
+
adjustY: false,
|
|
104
|
+
};
|
|
105
|
+
case 'bottom-strip':
|
|
106
|
+
return {
|
|
107
|
+
textCenterX: '50%',
|
|
108
|
+
textWidth: '85%',
|
|
109
|
+
textYAdjust: -10, // Shift content up by 10%
|
|
110
|
+
adjustY: true,
|
|
111
|
+
};
|
|
112
|
+
case 'corner-inset':
|
|
113
|
+
return {
|
|
114
|
+
textCenterX: '45%', // Slight shift to avoid corner
|
|
115
|
+
textWidth: '70%',
|
|
116
|
+
textYAdjust: 0,
|
|
117
|
+
adjustY: false,
|
|
118
|
+
};
|
|
119
|
+
default:
|
|
120
|
+
return DEFAULT_LAYOUT;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Create layers for an accent image.
|
|
126
|
+
* Returns the image layer and any overlay/gradient layers needed.
|
|
127
|
+
*/
|
|
128
|
+
export function createAccentLayers(accent: AccentImage, slideId: string): Layer[] {
|
|
129
|
+
const layers: Layer[] = [];
|
|
130
|
+
const { src, alt, position, ambientMotion, credit, license } = accent;
|
|
131
|
+
|
|
132
|
+
switch (position) {
|
|
133
|
+
case 'left-strip':
|
|
134
|
+
layers.push(createStripImage(src, alt, 'left', slideId, ambientMotion, credit, license));
|
|
135
|
+
layers.push(createStripGradient('left', slideId));
|
|
136
|
+
break;
|
|
137
|
+
|
|
138
|
+
case 'right-strip':
|
|
139
|
+
layers.push(createStripImage(src, alt, 'right', slideId, ambientMotion, credit, license));
|
|
140
|
+
layers.push(createStripGradient('right', slideId));
|
|
141
|
+
break;
|
|
142
|
+
|
|
143
|
+
case 'bottom-strip':
|
|
144
|
+
layers.push(createBottomStripImage(src, alt, slideId, ambientMotion, credit, license));
|
|
145
|
+
layers.push(createBottomStripGradient(slideId));
|
|
146
|
+
break;
|
|
147
|
+
|
|
148
|
+
case 'corner-inset':
|
|
149
|
+
layers.push(createCornerInsetImage(src, alt, slideId, ambientMotion, credit, license));
|
|
150
|
+
layers.push(createCornerVignette(slideId));
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return layers;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Create a vertical strip image layer.
|
|
159
|
+
*/
|
|
160
|
+
function createStripImage(
|
|
161
|
+
src: string,
|
|
162
|
+
alt: string,
|
|
163
|
+
side: 'left' | 'right',
|
|
164
|
+
slideId: string,
|
|
165
|
+
ambientMotion?: AccentImage['ambientMotion'],
|
|
166
|
+
credit?: string,
|
|
167
|
+
license?: string,
|
|
168
|
+
): ImageLayer {
|
|
169
|
+
return {
|
|
170
|
+
type: 'image',
|
|
171
|
+
id: `${slideId}-accent-img`,
|
|
172
|
+
content: {
|
|
173
|
+
src,
|
|
174
|
+
alt,
|
|
175
|
+
fit: 'cover',
|
|
176
|
+
credit,
|
|
177
|
+
license,
|
|
178
|
+
},
|
|
179
|
+
position: {
|
|
180
|
+
x: side === 'left' ? 0 : `${100 - STRIP_SIZE}%`,
|
|
181
|
+
y: 0,
|
|
182
|
+
width: STRIP_SIZE_PCT,
|
|
183
|
+
height: '100%',
|
|
184
|
+
},
|
|
185
|
+
animation: mapAmbientMotion(ambientMotion, 15),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Create a gradient overlay for strip images (fades to background).
|
|
191
|
+
*/
|
|
192
|
+
function createStripGradient(side: 'left' | 'right', slideId: string): ShapeLayer {
|
|
193
|
+
// Gradient from transparent to background color at the edge
|
|
194
|
+
const gradientId = side === 'left' ? 'accent-gradient-left' : 'accent-gradient-right';
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
type: 'shape',
|
|
198
|
+
id: `${slideId}-accent-fade`,
|
|
199
|
+
content: {
|
|
200
|
+
shape: 'rect',
|
|
201
|
+
// SVG gradient will be handled by the renderer
|
|
202
|
+
// Using a semi-transparent overlay as fallback
|
|
203
|
+
fill: `url(#${gradientId})`,
|
|
204
|
+
},
|
|
205
|
+
position: {
|
|
206
|
+
x: side === 'left' ? `${STRIP_SIZE - 8}%` : `${100 - STRIP_SIZE}%`,
|
|
207
|
+
y: 0,
|
|
208
|
+
width: '10%',
|
|
209
|
+
height: '100%',
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Create a horizontal bottom strip image layer.
|
|
216
|
+
*/
|
|
217
|
+
function createBottomStripImage(
|
|
218
|
+
src: string,
|
|
219
|
+
alt: string,
|
|
220
|
+
slideId: string,
|
|
221
|
+
ambientMotion?: AccentImage['ambientMotion'],
|
|
222
|
+
credit?: string,
|
|
223
|
+
license?: string,
|
|
224
|
+
): ImageLayer {
|
|
225
|
+
return {
|
|
226
|
+
type: 'image',
|
|
227
|
+
id: `${slideId}-accent-img`,
|
|
228
|
+
content: {
|
|
229
|
+
src,
|
|
230
|
+
alt,
|
|
231
|
+
fit: 'cover',
|
|
232
|
+
credit,
|
|
233
|
+
license,
|
|
234
|
+
},
|
|
235
|
+
position: {
|
|
236
|
+
x: 0,
|
|
237
|
+
y: `${100 - STRIP_SIZE}%`,
|
|
238
|
+
width: '100%',
|
|
239
|
+
height: STRIP_SIZE_PCT,
|
|
240
|
+
},
|
|
241
|
+
animation: mapAmbientMotion(ambientMotion, 15),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Create a gradient overlay for bottom strip (fades up to background).
|
|
247
|
+
*/
|
|
248
|
+
function createBottomStripGradient(slideId: string): ShapeLayer {
|
|
249
|
+
return {
|
|
250
|
+
type: 'shape',
|
|
251
|
+
id: `${slideId}-accent-fade`,
|
|
252
|
+
content: {
|
|
253
|
+
shape: 'rect',
|
|
254
|
+
fill: 'url(#accent-gradient-bottom)',
|
|
255
|
+
},
|
|
256
|
+
position: {
|
|
257
|
+
x: 0,
|
|
258
|
+
y: `${100 - STRIP_SIZE - 8}%`,
|
|
259
|
+
width: '100%',
|
|
260
|
+
height: '12%',
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Create a corner inset image layer.
|
|
267
|
+
*/
|
|
268
|
+
function createCornerInsetImage(
|
|
269
|
+
src: string,
|
|
270
|
+
alt: string,
|
|
271
|
+
slideId: string,
|
|
272
|
+
ambientMotion?: AccentImage['ambientMotion'],
|
|
273
|
+
credit?: string,
|
|
274
|
+
license?: string,
|
|
275
|
+
): ImageLayer {
|
|
276
|
+
return {
|
|
277
|
+
type: 'image',
|
|
278
|
+
id: `${slideId}-accent-img`,
|
|
279
|
+
content: {
|
|
280
|
+
src,
|
|
281
|
+
alt,
|
|
282
|
+
fit: 'cover',
|
|
283
|
+
credit,
|
|
284
|
+
license,
|
|
285
|
+
},
|
|
286
|
+
position: {
|
|
287
|
+
x: '70%',
|
|
288
|
+
y: '60%',
|
|
289
|
+
width: '28%',
|
|
290
|
+
height: '38%',
|
|
291
|
+
anchor: 'top-left',
|
|
292
|
+
},
|
|
293
|
+
animation: mapAmbientMotion(ambientMotion, 15) ?? { type: 'fadeIn', duration: 2, delay: 0.5 },
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Create a vignette overlay around corner inset.
|
|
299
|
+
*/
|
|
300
|
+
function createCornerVignette(slideId: string): ShapeLayer {
|
|
301
|
+
return {
|
|
302
|
+
type: 'shape',
|
|
303
|
+
id: `${slideId}-accent-vignette`,
|
|
304
|
+
content: {
|
|
305
|
+
shape: 'rect',
|
|
306
|
+
fill: 'url(#accent-vignette-corner)',
|
|
307
|
+
borderRadius: 8,
|
|
308
|
+
},
|
|
309
|
+
position: {
|
|
310
|
+
x: '69%',
|
|
311
|
+
y: '59%',
|
|
312
|
+
width: '30%',
|
|
313
|
+
height: '40%',
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Adjust a Y position value based on accent layout.
|
|
320
|
+
* Used by templates to shift content when bottom-strip is present.
|
|
321
|
+
*/
|
|
322
|
+
export function adjustY(originalY: string | number, layout: AccentLayout): string {
|
|
323
|
+
if (!layout.adjustY || layout.textYAdjust === 0) {
|
|
324
|
+
return typeof originalY === 'number' ? `${originalY}%` : originalY;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Parse percentage value
|
|
328
|
+
const yStr = typeof originalY === 'number' ? `${originalY}` : originalY;
|
|
329
|
+
const match = yStr.match(PERCENTAGE_RE);
|
|
330
|
+
|
|
331
|
+
if (match) {
|
|
332
|
+
const yValue = parseFloat(match[1]);
|
|
333
|
+
const adjusted = Math.max(5, yValue + layout.textYAdjust);
|
|
334
|
+
return `${adjusted}%`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return yStr;
|
|
338
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template Utilities
|
|
3
|
+
*
|
|
4
|
+
* Shared helpers for doc block templates:
|
|
5
|
+
* - Caption text cleaning
|
|
6
|
+
* - Common layer construction patterns
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Layer } from '../../schemas/Doc.js';
|
|
10
|
+
|
|
11
|
+
const TRAILING_PUNCT_RE = /[.;:!?]+$/;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Strip trailing punctuation from a caption string.
|
|
15
|
+
* Image captions on blocks look cleaner without terminal periods,
|
|
16
|
+
* semicolons, or other sentence-ending punctuation.
|
|
17
|
+
*/
|
|
18
|
+
export function cleanCaption(text: string): string {
|
|
19
|
+
return text.replace(TRAILING_PUNCT_RE, '').trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a full-screen background shape layer.
|
|
24
|
+
* Most text-based templates start with this as their first layer.
|
|
25
|
+
*/
|
|
26
|
+
export function createBackgroundLayer(id: string, fill: string): Layer {
|
|
27
|
+
return {
|
|
28
|
+
type: 'shape',
|
|
29
|
+
id,
|
|
30
|
+
content: { shape: 'rect', fill },
|
|
31
|
+
position: { x: 0, y: 0, width: '100%', height: '100%' },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comparison Bar Template
|
|
3
|
+
*
|
|
4
|
+
* Two horizontal bars showing relative numeric values side by side.
|
|
5
|
+
* Bar widths are proportional to the values for immediate visual comparison.
|
|
6
|
+
* Good for population, distance, or measurement comparisons.
|
|
7
|
+
*
|
|
8
|
+
* This is shared code used by both site and efb-app doc renderers.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Layer } from '../../schemas/Doc.js';
|
|
12
|
+
import type { ComparisonBarInput, TemplateContext } from '../../schemas/BlockTemplates.js';
|
|
13
|
+
import { scaledFontSize } from '../../schemas/BlockTemplates.js';
|
|
14
|
+
import { resolveColorScheme } from '../utils/themeUtils.js';
|
|
15
|
+
|
|
16
|
+
export function comparisonBar(input: ComparisonBarInput, context: TemplateContext): Layer[] {
|
|
17
|
+
const { leftLabel, leftValue, rightLabel, rightValue, unit, colorScheme = 'blue' } = input;
|
|
18
|
+
const { theme } = context;
|
|
19
|
+
const colors = resolveColorScheme(context, colorScheme);
|
|
20
|
+
|
|
21
|
+
const labelFontSize = scaledFontSize(28, context, false);
|
|
22
|
+
const valueFontSize = scaledFontSize(48, context, true);
|
|
23
|
+
|
|
24
|
+
// Calculate proportional bar widths (max bar = 65% of viewport width)
|
|
25
|
+
const maxValue = Math.max(leftValue, rightValue, 1);
|
|
26
|
+
const maxBarWidth = 65;
|
|
27
|
+
const leftBarWidth = (leftValue / maxValue) * maxBarWidth;
|
|
28
|
+
const rightBarWidth = (rightValue / maxValue) * maxBarWidth;
|
|
29
|
+
|
|
30
|
+
// Bar positioning
|
|
31
|
+
const barStartX = 15;
|
|
32
|
+
const barHeight = 6; // % of viewport height
|
|
33
|
+
const topBarY = 36;
|
|
34
|
+
const bottomBarY = 58;
|
|
35
|
+
|
|
36
|
+
// Format values for display
|
|
37
|
+
const formatValue = (v: number): string => {
|
|
38
|
+
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
|
39
|
+
if (v >= 1_000) return `${(v / 1_000).toFixed(v >= 10_000 ? 0 : 1)}K`;
|
|
40
|
+
return v.toLocaleString();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const leftDisplay = unit ? `${formatValue(leftValue)} ${unit}` : formatValue(leftValue);
|
|
44
|
+
const rightDisplay = unit ? `${formatValue(rightValue)} ${unit}` : formatValue(rightValue);
|
|
45
|
+
|
|
46
|
+
return [
|
|
47
|
+
// Background
|
|
48
|
+
{
|
|
49
|
+
type: 'shape',
|
|
50
|
+
id: 'bg',
|
|
51
|
+
content: {
|
|
52
|
+
shape: 'rect',
|
|
53
|
+
fill: `linear-gradient(180deg, ${theme.colors.background} 0%, #0f1520 100%)`,
|
|
54
|
+
},
|
|
55
|
+
position: { x: 0, y: 0, width: '100%', height: '100%' },
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
// Top bar label
|
|
59
|
+
{
|
|
60
|
+
type: 'text',
|
|
61
|
+
id: 'left-label',
|
|
62
|
+
content: {
|
|
63
|
+
text: leftLabel,
|
|
64
|
+
style: { fontSize: labelFontSize, color: theme.colors.textMuted },
|
|
65
|
+
},
|
|
66
|
+
position: { x: `${barStartX}%`, y: `${topBarY - 8}%` },
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
// Top bar value
|
|
70
|
+
{
|
|
71
|
+
type: 'text',
|
|
72
|
+
id: 'left-value',
|
|
73
|
+
content: {
|
|
74
|
+
text: leftDisplay,
|
|
75
|
+
style: { fontSize: valueFontSize, fontWeight: 'bold', color: colors.text },
|
|
76
|
+
},
|
|
77
|
+
position: { x: `${barStartX + leftBarWidth + 2}%`, y: `${topBarY - 1}%` },
|
|
78
|
+
animation: { type: 'fadeIn', duration: 0.8, delay: 0.3 },
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
// Top bar
|
|
82
|
+
{
|
|
83
|
+
type: 'shape',
|
|
84
|
+
id: 'left-bar',
|
|
85
|
+
content: {
|
|
86
|
+
shape: 'rect',
|
|
87
|
+
fill: colors.text,
|
|
88
|
+
borderRadius: 4,
|
|
89
|
+
},
|
|
90
|
+
position: {
|
|
91
|
+
x: `${barStartX}%`,
|
|
92
|
+
y: `${topBarY}%`,
|
|
93
|
+
width: `${leftBarWidth}%`,
|
|
94
|
+
height: `${barHeight}%`,
|
|
95
|
+
},
|
|
96
|
+
animation: { type: 'fadeIn', duration: 1 },
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
// Bottom bar label
|
|
100
|
+
{
|
|
101
|
+
type: 'text',
|
|
102
|
+
id: 'right-label',
|
|
103
|
+
content: {
|
|
104
|
+
text: rightLabel,
|
|
105
|
+
style: { fontSize: labelFontSize, color: theme.colors.textMuted },
|
|
106
|
+
},
|
|
107
|
+
position: { x: `${barStartX}%`, y: `${bottomBarY - 8}%` },
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
// Bottom bar value
|
|
111
|
+
{
|
|
112
|
+
type: 'text',
|
|
113
|
+
id: 'right-value',
|
|
114
|
+
content: {
|
|
115
|
+
text: rightDisplay,
|
|
116
|
+
style: { fontSize: valueFontSize, fontWeight: 'bold', color: colors.accent },
|
|
117
|
+
},
|
|
118
|
+
position: { x: `${barStartX + rightBarWidth + 2}%`, y: `${bottomBarY - 1}%` },
|
|
119
|
+
animation: { type: 'fadeIn', duration: 0.8, delay: 0.6 },
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
// Bottom bar
|
|
123
|
+
{
|
|
124
|
+
type: 'shape',
|
|
125
|
+
id: 'right-bar',
|
|
126
|
+
content: {
|
|
127
|
+
shape: 'rect',
|
|
128
|
+
fill: colors.accent,
|
|
129
|
+
borderRadius: 4,
|
|
130
|
+
},
|
|
131
|
+
position: {
|
|
132
|
+
x: `${barStartX}%`,
|
|
133
|
+
y: `${bottomBarY}%`,
|
|
134
|
+
width: `${rightBarWidth}%`,
|
|
135
|
+
height: `${barHeight}%`,
|
|
136
|
+
},
|
|
137
|
+
animation: { type: 'fadeIn', duration: 1, delay: 0.3 },
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cover Block Template
|
|
3
|
+
*
|
|
4
|
+
* Full-screen hero image with title overlay, shown before playback starts.
|
|
5
|
+
* This is the "poster for the doc, displaying the
|
|
6
|
+
* article's hero image with elegant title treatment.
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Full-screen hero image with Ken Burns ambient motion
|
|
10
|
+
* - Gradient overlay for text readability
|
|
11
|
+
* - Large centered title with optional subtitle
|
|
12
|
+
* - No animation delays (shown at rest, not during playback)
|
|
13
|
+
*
|
|
14
|
+
* This is shared code used by both site and efb-app doc renderers.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Layer } from '../../schemas/Doc.js';
|
|
18
|
+
import type { TemplateContext } from '../../schemas/BlockTemplates.js';
|
|
19
|
+
import { scaledFontSize } from '../../schemas/BlockTemplates.js';
|
|
20
|
+
import type { StartBlockConfig } from '../../schemas/Doc.js';
|
|
21
|
+
import { mapAmbientMotion } from './accentImage.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Input for coverBlock template - matches StartBlockConfig
|
|
25
|
+
*/
|
|
26
|
+
export interface CoverBlockInput {
|
|
27
|
+
/** Path to hero image */
|
|
28
|
+
heroSrc: string;
|
|
29
|
+
/** Alt text for the hero image */
|
|
30
|
+
heroAlt?: string;
|
|
31
|
+
/** Title to display over the hero */
|
|
32
|
+
title: string;
|
|
33
|
+
/** Optional subtitle */
|
|
34
|
+
subtitle?: string;
|
|
35
|
+
/** Ambient motion for the hero image */
|
|
36
|
+
ambientMotion?: 'zoomIn' | 'zoomOut' | 'panLeft' | 'panRight';
|
|
37
|
+
/** Photo credit / artist name */
|
|
38
|
+
heroCredit?: string;
|
|
39
|
+
/** License identifier */
|
|
40
|
+
heroLicense?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generate cover block layers from StartBlockConfig.
|
|
45
|
+
*/
|
|
46
|
+
export function coverBlock(input: CoverBlockInput, context: TemplateContext): Layer[] {
|
|
47
|
+
const { heroSrc, heroAlt, title, subtitle, ambientMotion, heroCredit, heroLicense } = input;
|
|
48
|
+
const { theme, layout } = context;
|
|
49
|
+
|
|
50
|
+
// Scale font sizes for viewport - use larger sizes for the cover
|
|
51
|
+
const titleFontSize = scaledFontSize(96, context, true);
|
|
52
|
+
const subtitleFontSize = scaledFontSize(36, context, false);
|
|
53
|
+
|
|
54
|
+
// Determine Ken Burns animation
|
|
55
|
+
const imageAnimation = mapAmbientMotion(ambientMotion);
|
|
56
|
+
|
|
57
|
+
const layers: Layer[] = [
|
|
58
|
+
// Full-screen hero image
|
|
59
|
+
{
|
|
60
|
+
type: 'image',
|
|
61
|
+
id: 'cover-hero',
|
|
62
|
+
content: {
|
|
63
|
+
src: heroSrc,
|
|
64
|
+
alt: heroAlt || title,
|
|
65
|
+
fit: 'cover',
|
|
66
|
+
credit: heroCredit,
|
|
67
|
+
license: heroLicense,
|
|
68
|
+
},
|
|
69
|
+
position: { x: 0, y: 0, width: '100%', height: '100%' },
|
|
70
|
+
animation: imageAnimation,
|
|
71
|
+
},
|
|
72
|
+
// Gradient overlay for text readability - from bottom
|
|
73
|
+
{
|
|
74
|
+
type: 'shape',
|
|
75
|
+
id: 'cover-gradient',
|
|
76
|
+
content: {
|
|
77
|
+
shape: 'rect',
|
|
78
|
+
fill: 'linear-gradient(0deg, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.4) 40%, rgba(0,0,0,0.1) 70%, transparent 100%)',
|
|
79
|
+
},
|
|
80
|
+
position: { x: 0, y: 0, width: '100%', height: '100%' },
|
|
81
|
+
},
|
|
82
|
+
// Title - positioned lower in the frame to work with hero composition
|
|
83
|
+
{
|
|
84
|
+
type: 'text',
|
|
85
|
+
id: 'cover-title',
|
|
86
|
+
content: {
|
|
87
|
+
text: title,
|
|
88
|
+
style: {
|
|
89
|
+
fontSize: titleFontSize,
|
|
90
|
+
fontWeight: 'bold',
|
|
91
|
+
color: theme.colors.text,
|
|
92
|
+
textAlign: 'center',
|
|
93
|
+
shadow: true,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
position: {
|
|
97
|
+
x: '50%',
|
|
98
|
+
y: subtitle ? '70%' : '75%',
|
|
99
|
+
anchor: 'center',
|
|
100
|
+
width: layout.maxTextWidth,
|
|
101
|
+
},
|
|
102
|
+
// No animation delay - shown immediately at rest
|
|
103
|
+
animation: { type: 'fadeIn', duration: 0.8 },
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
// Add subtitle if provided
|
|
108
|
+
if (subtitle) {
|
|
109
|
+
layers.push({
|
|
110
|
+
type: 'text',
|
|
111
|
+
id: 'cover-subtitle',
|
|
112
|
+
content: {
|
|
113
|
+
text: subtitle,
|
|
114
|
+
style: {
|
|
115
|
+
fontSize: subtitleFontSize,
|
|
116
|
+
color: theme.colors.textMuted,
|
|
117
|
+
textAlign: 'center',
|
|
118
|
+
lineHeight: 1.5,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
position: {
|
|
122
|
+
x: '50%',
|
|
123
|
+
y: '82%',
|
|
124
|
+
anchor: 'center',
|
|
125
|
+
width: layout.maxTextWidth,
|
|
126
|
+
},
|
|
127
|
+
animation: { type: 'fadeIn', duration: 0.8, delay: 0.2 },
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return layers;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Convert StartBlockConfig to CoverBlockInput.
|
|
136
|
+
*/
|
|
137
|
+
export function startBlockToCoverInput(config: StartBlockConfig): CoverBlockInput {
|
|
138
|
+
return {
|
|
139
|
+
heroSrc: config.heroSrc,
|
|
140
|
+
heroAlt: config.heroAlt,
|
|
141
|
+
title: config.title,
|
|
142
|
+
subtitle: config.subtitle,
|
|
143
|
+
ambientMotion: config.ambientMotion,
|
|
144
|
+
heroCredit: config.heroCredit,
|
|
145
|
+
heroLicense: config.heroLicense,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Expand a StartBlockConfig into a renderable Block.
|
|
151
|
+
* This is used by the player to render the cover block at rest.
|
|
152
|
+
*/
|
|
153
|
+
export function expandCoverBlock(config: StartBlockConfig, context: TemplateContext): Layer[] {
|
|
154
|
+
const input = startBlockToCoverInput(config);
|
|
155
|
+
return coverBlock(input, context);
|
|
156
|
+
}
|