@bendyline/squisq-react 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/index.d.ts +563 -0
  2. package/dist/index.js +3180 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/squisq-player.css +2 -0
  5. package/dist/squisq-player.css.map +1 -0
  6. package/dist/squisq-player.global.js +6 -0
  7. package/dist/squisq-player.global.js.map +1 -0
  8. package/dist/standalone-source.d.ts +2 -0
  9. package/dist/standalone-source.js +2 -0
  10. package/package.json +69 -0
  11. package/src/BlockRenderer.tsx +146 -0
  12. package/src/CaptionOverlay.tsx +86 -0
  13. package/src/DocControlsBottom.tsx +103 -0
  14. package/src/DocControlsOverlay.tsx +178 -0
  15. package/src/DocControlsSidebar.tsx +107 -0
  16. package/src/DocControlsSlideshow.tsx +132 -0
  17. package/src/DocPlayer.tsx +1005 -0
  18. package/src/DocPlayerWithSidebar.tsx +138 -0
  19. package/src/DocProgressBar.tsx +200 -0
  20. package/src/LinearDocView.tsx +313 -0
  21. package/src/MarkdownRenderer.tsx +360 -0
  22. package/src/__tests__/BlockRenderer.test.tsx +105 -0
  23. package/src/__tests__/DocControlsSlideshow.test.tsx +127 -0
  24. package/src/__tests__/LinearDocView.test.tsx +180 -0
  25. package/src/__tests__/MarkdownRenderer.test.tsx +234 -0
  26. package/src/__tests__/exports.test.ts +55 -0
  27. package/src/hooks/AudioProvider.ts +114 -0
  28. package/src/hooks/MediaContext.tsx +81 -0
  29. package/src/hooks/index.ts +6 -0
  30. package/src/hooks/useAudioSync.ts +390 -0
  31. package/src/hooks/useDocPlayback.ts +251 -0
  32. package/src/hooks/useViewportOrientation.ts +117 -0
  33. package/src/index.ts +46 -0
  34. package/src/layers/ImageLayer.tsx +182 -0
  35. package/src/layers/MapLayer.tsx +184 -0
  36. package/src/layers/ShapeLayer.tsx +107 -0
  37. package/src/layers/TextLayer.tsx +197 -0
  38. package/src/layers/VideoLayer.tsx +150 -0
  39. package/src/layers/index.ts +5 -0
  40. package/src/standalone-entry.tsx +228 -0
  41. package/src/styles/doc-animations.css +458 -0
  42. package/src/types.ts +152 -0
  43. package/src/utils/animationUtils.ts +13 -0
  44. package/src/utils/layerUtils.ts +42 -0
  45. package/src/utils/mapTileUtils.ts +375 -0
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Map Tile Utilities
3
+ *
4
+ * Functions for fetching and composing map tiles from free/open-source providers.
5
+ * Converts lat/lng to tile coordinates and composites multiple tiles into a
6
+ * single image for SVG embedding.
7
+ *
8
+ * Supported Providers (all free with attribution):
9
+ * - terrain: OpenTopoMap (CC-BY-SA)
10
+ * - road: OpenStreetMap (ODbL)
11
+ * - satellite: ESRI World Imagery (free with attribution)
12
+ * - toner: Stadia/Stamen Toner (free tier)
13
+ * - watercolor: Stadia/Stamen Watercolor (free tier)
14
+ *
15
+ * See docs/MAP_TILES.md for full provider details and terms.
16
+ */
17
+
18
+ import type { MapTileStyle, MapMarker } from '@bendyline/squisq/schemas';
19
+
20
+ /**
21
+ * Tile provider configuration.
22
+ */
23
+ export interface TileProvider {
24
+ /** URL template with {z}, {x}, {y} placeholders */
25
+ url: string;
26
+ /** Attribution text (required for display) */
27
+ attribution: string;
28
+ /** Maximum zoom level */
29
+ maxZoom: number;
30
+ /** Tile size in pixels (default: 256) */
31
+ tileSize?: number;
32
+ }
33
+
34
+ /**
35
+ * Free tile providers for each map style.
36
+ * All require attribution to be displayed.
37
+ */
38
+ export const TILE_PROVIDERS: Record<MapTileStyle, TileProvider> = {
39
+ terrain: {
40
+ url: 'https://tile.opentopomap.org/{z}/{x}/{y}.png',
41
+ attribution: 'Map: OpenTopoMap (CC-BY-SA)',
42
+ maxZoom: 17,
43
+ },
44
+ road: {
45
+ url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
46
+ attribution: '© OpenStreetMap contributors',
47
+ maxZoom: 19,
48
+ },
49
+ satellite: {
50
+ url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
51
+ attribution: 'Imagery: Esri, Maxar, Earthstar',
52
+ maxZoom: 18,
53
+ },
54
+ toner: {
55
+ url: 'https://tiles.stadiamaps.com/tiles/stamen_toner/{z}/{x}/{y}.png',
56
+ attribution: 'Map: Stadia Maps, Stamen Design',
57
+ maxZoom: 20,
58
+ },
59
+ watercolor: {
60
+ url: 'https://tiles.stadiamaps.com/tiles/stamen_watercolor/{z}/{x}/{y}.jpg',
61
+ attribution: 'Map: Stadia Maps, Stamen Design',
62
+ maxZoom: 16,
63
+ },
64
+ };
65
+
66
+ /**
67
+ * Convert latitude/longitude to tile coordinates at a given zoom level.
68
+ * Uses Web Mercator projection (EPSG:3857).
69
+ */
70
+ export function latLngToTile(lat: number, lng: number, zoom: number): { x: number; y: number } {
71
+ const n = Math.pow(2, zoom);
72
+ const x = Math.floor(((lng + 180) / 360) * n);
73
+ const latRad = (lat * Math.PI) / 180;
74
+ const y = Math.floor(((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n);
75
+ return { x, y };
76
+ }
77
+
78
+ /**
79
+ * Convert tile coordinates back to lat/lng (top-left corner of tile).
80
+ */
81
+ export function tileToLatLng(x: number, y: number, zoom: number): { lat: number; lng: number } {
82
+ const n = Math.pow(2, zoom);
83
+ const lng = (x / n) * 360 - 180;
84
+ const latRad = Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / n)));
85
+ const lat = (latRad * 180) / Math.PI;
86
+ return { lat, lng };
87
+ }
88
+
89
+ /**
90
+ * Get the pixel offset within a tile for a given lat/lng.
91
+ */
92
+ export function getPixelOffset(
93
+ lat: number,
94
+ lng: number,
95
+ zoom: number,
96
+ tileSize: number = 256,
97
+ ): { x: number; y: number } {
98
+ const n = Math.pow(2, zoom);
99
+ const xTile = ((lng + 180) / 360) * n;
100
+ const latRad = (lat * Math.PI) / 180;
101
+ const yTile = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n;
102
+
103
+ return {
104
+ x: (xTile - Math.floor(xTile)) * tileSize,
105
+ y: (yTile - Math.floor(yTile)) * tileSize,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Calculate which tiles are needed to cover a viewport centered on lat/lng.
111
+ */
112
+ export function getTilesForViewport(
113
+ centerLat: number,
114
+ centerLng: number,
115
+ zoom: number,
116
+ viewportWidth: number,
117
+ viewportHeight: number,
118
+ tileSize: number = 256,
119
+ ): Array<{ x: number; y: number; screenX: number; screenY: number }> {
120
+ const centerTile = latLngToTile(centerLat, centerLng, zoom);
121
+ const pixelOffset = getPixelOffset(centerLat, centerLng, zoom, tileSize);
122
+
123
+ // How many tiles we need in each direction
124
+ const tilesX = Math.ceil(viewportWidth / tileSize) + 1;
125
+ const tilesY = Math.ceil(viewportHeight / tileSize) + 1;
126
+
127
+ // Start tile offset
128
+ const startX = centerTile.x - Math.floor(tilesX / 2);
129
+ const startY = centerTile.y - Math.floor(tilesY / 2);
130
+
131
+ // Screen position of the center tile
132
+ const centerScreenX = viewportWidth / 2 - pixelOffset.x;
133
+ const centerScreenY = viewportHeight / 2 - pixelOffset.y;
134
+
135
+ const tiles: Array<{ x: number; y: number; screenX: number; screenY: number }> = [];
136
+
137
+ for (let dy = 0; dy < tilesY; dy++) {
138
+ for (let dx = 0; dx < tilesX; dx++) {
139
+ const tileX = startX + dx;
140
+ const tileY = startY + dy;
141
+ const screenX = centerScreenX + (tileX - centerTile.x) * tileSize;
142
+ const screenY = centerScreenY + (tileY - centerTile.y) * tileSize;
143
+
144
+ tiles.push({ x: tileX, y: tileY, screenX, screenY });
145
+ }
146
+ }
147
+
148
+ return tiles;
149
+ }
150
+
151
+ /**
152
+ * Build a tile URL from the provider template.
153
+ */
154
+ export function buildTileUrl(provider: TileProvider, x: number, y: number, z: number): string {
155
+ return provider.url.replace('{z}', String(z)).replace('{x}', String(x)).replace('{y}', String(y));
156
+ }
157
+
158
+ /**
159
+ * Fetch a single tile image as an HTMLImageElement.
160
+ */
161
+ async function fetchTileImage(url: string): Promise<HTMLImageElement> {
162
+ return new Promise((resolve, reject) => {
163
+ const img = new Image();
164
+ img.crossOrigin = 'anonymous';
165
+ img.onload = () => resolve(img);
166
+ img.onerror = () => reject(new Error(`Failed to load tile: ${url}`));
167
+ img.src = url;
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Options for composing a map image.
173
+ */
174
+ export interface ComposeMapOptions {
175
+ /** Center coordinates */
176
+ center: { lat: number; lng: number };
177
+ /** Zoom level */
178
+ zoom: number;
179
+ /** Map tile style */
180
+ style: MapTileStyle;
181
+ /** Output width in pixels */
182
+ width: number;
183
+ /** Output height in pixels */
184
+ height: number;
185
+ /** Optional markers to render */
186
+ markers?: MapMarker[];
187
+ /** Show attribution (default: true) */
188
+ showAttribution?: boolean;
189
+ }
190
+
191
+ /**
192
+ * Compose map tiles into a single data URL image.
193
+ *
194
+ * This is the main function for generating map images for blocks.
195
+ * It fetches all needed tiles, composites them on a canvas, and
196
+ * returns a data URL that can be used in an SVG <image> element.
197
+ */
198
+ export async function composeMapImage(options: ComposeMapOptions): Promise<string> {
199
+ const { center, zoom, style, width, height, markers = [], showAttribution = true } = options;
200
+
201
+ const provider = TILE_PROVIDERS[style];
202
+ const tileSize = provider.tileSize || 256;
203
+ const clampedZoom = Math.min(zoom, provider.maxZoom);
204
+
205
+ // Create canvas
206
+ const canvas = document.createElement('canvas');
207
+ canvas.width = width;
208
+ canvas.height = height;
209
+ const ctx = canvas.getContext('2d');
210
+ if (!ctx) throw new Error('Failed to get canvas context');
211
+
212
+ // Fill with a neutral background in case tiles fail to load
213
+ ctx.fillStyle = style === 'toner' ? '#ffffff' : '#e5e7eb';
214
+ ctx.fillRect(0, 0, width, height);
215
+
216
+ // Get tiles needed
217
+ const tiles = getTilesForViewport(center.lat, center.lng, clampedZoom, width, height, tileSize);
218
+
219
+ // Fetch and draw tiles
220
+ const tilePromises = tiles.map(async (tile) => {
221
+ const url = buildTileUrl(provider, tile.x, tile.y, clampedZoom);
222
+ try {
223
+ const img = await fetchTileImage(url);
224
+ ctx.drawImage(img, tile.screenX, tile.screenY, tileSize, tileSize);
225
+ } catch (err: unknown) {
226
+ // Tile failed to load - leave background color
227
+ console.warn(`Tile load failed: ${url}`, err);
228
+ }
229
+ });
230
+
231
+ await Promise.all(tilePromises);
232
+
233
+ // Draw markers
234
+ for (const marker of markers) {
235
+ drawMarker(ctx, marker, center, clampedZoom, width, height, tileSize);
236
+ }
237
+
238
+ // Draw attribution
239
+ if (showAttribution) {
240
+ drawAttribution(ctx, provider.attribution, width, height);
241
+ }
242
+
243
+ return canvas.toDataURL('image/png');
244
+ }
245
+
246
+ /**
247
+ * Draw a marker on the canvas.
248
+ */
249
+ function drawMarker(
250
+ ctx: CanvasRenderingContext2D,
251
+ marker: MapMarker,
252
+ center: { lat: number; lng: number },
253
+ zoom: number,
254
+ width: number,
255
+ height: number,
256
+ tileSize: number,
257
+ ): void {
258
+ // Calculate screen position of marker relative to center
259
+ const centerTile = latLngToTile(center.lat, center.lng, zoom);
260
+ const markerTile = latLngToTile(marker.lat, marker.lng, zoom);
261
+
262
+ const centerOffset = getPixelOffset(center.lat, center.lng, zoom, tileSize);
263
+ const markerOffset = getPixelOffset(marker.lat, marker.lng, zoom, tileSize);
264
+
265
+ const dx = (markerTile.x - centerTile.x) * tileSize + (markerOffset.x - centerOffset.x);
266
+ const dy = (markerTile.y - centerTile.y) * tileSize + (markerOffset.y - centerOffset.y);
267
+
268
+ const screenX = width / 2 + dx;
269
+ const screenY = height / 2 + dy;
270
+
271
+ // Draw marker
272
+ const color = marker.color || '#ef4444';
273
+ const icon = marker.icon || 'pin';
274
+
275
+ ctx.save();
276
+
277
+ if (icon === 'pin') {
278
+ // Draw pin shape
279
+ ctx.fillStyle = color;
280
+ ctx.beginPath();
281
+ ctx.arc(screenX, screenY - 12, 8, Math.PI, 0, false);
282
+ ctx.lineTo(screenX, screenY);
283
+ ctx.closePath();
284
+ ctx.fill();
285
+
286
+ // White dot in center
287
+ ctx.fillStyle = '#ffffff';
288
+ ctx.beginPath();
289
+ ctx.arc(screenX, screenY - 12, 3, 0, Math.PI * 2);
290
+ ctx.fill();
291
+ } else if (icon === 'circle') {
292
+ ctx.fillStyle = color;
293
+ ctx.beginPath();
294
+ ctx.arc(screenX, screenY, 8, 0, Math.PI * 2);
295
+ ctx.fill();
296
+ ctx.strokeStyle = '#ffffff';
297
+ ctx.lineWidth = 2;
298
+ ctx.stroke();
299
+ } else if (icon === 'star') {
300
+ ctx.fillStyle = color;
301
+ drawStar(ctx, screenX, screenY, 5, 10, 5);
302
+ ctx.fill();
303
+ }
304
+
305
+ // Draw label if present
306
+ if (marker.label) {
307
+ ctx.fillStyle = '#1f2937';
308
+ ctx.font = 'bold 12px system-ui, sans-serif';
309
+ ctx.textAlign = 'center';
310
+ ctx.fillText(marker.label, screenX, screenY + 20);
311
+ }
312
+
313
+ ctx.restore();
314
+ }
315
+
316
+ /**
317
+ * Draw a star shape.
318
+ */
319
+ function drawStar(
320
+ ctx: CanvasRenderingContext2D,
321
+ cx: number,
322
+ cy: number,
323
+ spikes: number,
324
+ outerRadius: number,
325
+ innerRadius: number,
326
+ ): void {
327
+ let rot = (Math.PI / 2) * 3;
328
+ const step = Math.PI / spikes;
329
+
330
+ ctx.beginPath();
331
+ ctx.moveTo(cx, cy - outerRadius);
332
+
333
+ for (let i = 0; i < spikes; i++) {
334
+ ctx.lineTo(cx + Math.cos(rot) * outerRadius, cy + Math.sin(rot) * outerRadius);
335
+ rot += step;
336
+ ctx.lineTo(cx + Math.cos(rot) * innerRadius, cy + Math.sin(rot) * innerRadius);
337
+ rot += step;
338
+ }
339
+
340
+ ctx.lineTo(cx, cy - outerRadius);
341
+ ctx.closePath();
342
+ }
343
+
344
+ /**
345
+ * Draw attribution text on the canvas.
346
+ */
347
+ function drawAttribution(
348
+ ctx: CanvasRenderingContext2D,
349
+ text: string,
350
+ width: number,
351
+ height: number,
352
+ ): void {
353
+ ctx.save();
354
+
355
+ // Semi-transparent background
356
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
357
+ const padding = 4;
358
+ ctx.font = '10px system-ui, sans-serif';
359
+ const textWidth = ctx.measureText(text).width;
360
+ ctx.fillRect(width - textWidth - padding * 2 - 4, height - 16, textWidth + padding * 2, 14);
361
+
362
+ // Text
363
+ ctx.fillStyle = '#374151';
364
+ ctx.textAlign = 'right';
365
+ ctx.fillText(text, width - padding - 4, height - 5);
366
+
367
+ ctx.restore();
368
+ }
369
+
370
+ /**
371
+ * Get attribution text for a map style.
372
+ */
373
+ export function getAttribution(style: MapTileStyle): string {
374
+ return TILE_PROVIDERS[style].attribution;
375
+ }