triiiceratops 0.16.2 → 0.16.4

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.
@@ -15,6 +15,9 @@
15
15
  let viewer: any | undefined = $state.raw();
16
16
  let OSD: any | undefined = $state();
17
17
 
18
+ const IIIF_LEVEL0_V2_HTTPS = 'https://iiif.io/api/image/2/level0.json';
19
+ const IIIF_LEVEL0_V2_HTTP = 'http://iiif.io/api/image/2/level0.json';
20
+
18
21
  // Track OSD state changes for reactivity
19
22
  let osdVersion = $state(0);
20
23
  // Track last opened tile source to prevent unnecessary resets
@@ -169,6 +172,7 @@
169
172
  if (!mounted) return;
170
173
 
171
174
  OSD = osdModule.default || osdModule;
175
+ patchIiifLevel0ProfileCompatibility(OSD);
172
176
 
173
177
  // Initialize OpenSeadragon viewer
174
178
  viewer = OSD({
@@ -184,6 +188,9 @@
184
188
  animationTime: 0.5,
185
189
  springStiffness: 7.0,
186
190
  zoomPerClick: 2.0,
191
+ // Lower threshold prevents blanking on sparse/level-0 pyramids
192
+ // when zooming far out. Consumers can override via config.
193
+ minPixelRatio: 0.1,
187
194
  // Consumer-provided OSD overrides
188
195
  ...(viewerState.config?.openSeadragonConfig ?? {}),
189
196
  // Enable double-click to zoom, but keep clickToZoom disabled for Annotorious
@@ -193,6 +200,20 @@
193
200
  },
194
201
  });
195
202
 
203
+ viewer.addHandler('open', () => {
204
+ const overrides = viewerState.config?.openSeadragonConfig ?? {};
205
+ if (overrides.minZoomLevel !== undefined) return;
206
+
207
+ // Prevent zooming into the "no tiles rendered" range seen on
208
+ // some level-0 services when zoomed far past home view.
209
+ const homeZoom = viewer.viewport.getHomeZoom();
210
+ viewer.viewport.minZoomLevel = homeZoom * 0.5;
211
+
212
+ if (overrides.minPixelRatio === undefined) {
213
+ viewer.minPixelRatio = 0.1;
214
+ }
215
+ });
216
+
196
217
  // Notify plugins that OSD is ready
197
218
  viewerState.notifyOSDReady(viewer);
198
219
  })();
@@ -239,6 +260,43 @@
239
260
  }
240
261
  });
241
262
 
263
+ function normalizeIiifLevel0Profile<T>(source: T): T {
264
+ if (!source || typeof source !== 'object') return source;
265
+
266
+ const obj = source as any;
267
+ const profile = obj.profile;
268
+
269
+ if (typeof profile === 'string' && profile === IIIF_LEVEL0_V2_HTTPS) {
270
+ obj.profile = IIIF_LEVEL0_V2_HTTP;
271
+ } else if (Array.isArray(profile) && profile.length > 0) {
272
+ const first = profile[0];
273
+ if (typeof first === 'string' && first === IIIF_LEVEL0_V2_HTTPS) {
274
+ obj.profile = [IIIF_LEVEL0_V2_HTTP, ...profile.slice(1)];
275
+ }
276
+ }
277
+
278
+ return source;
279
+ }
280
+
281
+ function patchIiifLevel0ProfileCompatibility(osd: any) {
282
+ if (
283
+ !osd?.IIIFTileSource?.prototype ||
284
+ osd.__triiiceratopsIiifLevel0Patched
285
+ )
286
+ return;
287
+
288
+ const proto = osd.IIIFTileSource.prototype;
289
+ const originalConfigure = proto.configure;
290
+ if (typeof originalConfigure !== 'function') return;
291
+
292
+ proto.configure = function (data: any, url: string, postData: any) {
293
+ const configured = originalConfigure.call(this, data, url, postData);
294
+ return normalizeIiifLevel0Profile(configured);
295
+ };
296
+
297
+ osd.__triiiceratopsIiifLevel0Patched = true;
298
+ }
299
+
242
300
  // Pre-fetch info.json URLs to detect 401 auth errors before passing to OSD
243
301
  async function resolveTileSources(
244
302
  sources: any[],
@@ -248,18 +306,25 @@
248
306
  > {
249
307
  const resolved = await Promise.all(
250
308
  sources.map(async (source) => {
251
- // Only fetch string sources (info.json URLs)
252
- if (typeof source !== 'string') return source;
309
+ // Only probe string sources (info.json URLs); preserve string tile
310
+ // sources so OSD follows its normal source-loading path.
311
+ if (typeof source !== 'string')
312
+ return normalizeIiifLevel0Profile(source);
253
313
  try {
254
314
  const response = await fetch(source);
255
315
  if (response.status === 401) {
256
316
  return { __authError: true };
257
317
  }
258
- if (!response.ok) {
259
- // For other errors, pass through the URL and let OSD handle it
260
- return source;
318
+ if (response.ok) {
319
+ // Keep probing for auth, but preserve source URL so OSD
320
+ // continues using IIIF tile source behavior.
321
+ try {
322
+ normalizeIiifLevel0Profile(await response.json());
323
+ } catch {
324
+ // Not JSON or malformed response; let OSD handle source URL.
325
+ }
261
326
  }
262
- return await response.json();
327
+ return source;
263
328
  } catch {
264
329
  // Network errors: pass through and let OSD handle it
265
330
  return source;
@@ -309,107 +374,178 @@
309
374
 
310
375
  // Capture stateKey for staleness guard
311
376
  const capturedKey = stateKey;
377
+ const overrides = viewerState.config?.openSeadragonConfig ?? {};
312
378
 
313
379
  if (mode === 'continuous') {
314
- viewerState.tileSourceError = null;
380
+ // Continuous mode can include mixed-size canvases; keep tile culling
381
+ // thresholds low so smaller images don't disappear before full-strip view.
382
+ if (overrides.minPixelRatio === undefined) {
383
+ viewer.minPixelRatio = 0.01;
384
+ }
385
+ if (overrides.minZoomImageRatio === undefined) {
386
+ viewer.minZoomImageRatio = 0.1;
387
+ }
315
388
 
316
- const gap = 0.025;
389
+ resolveTileSources(sources).then((result) => {
390
+ // Staleness guard: if tile sources changed while we were fetching, discard
391
+ if (capturedKey !== lastTileSourceStr) return;
317
392
 
318
- // Build position info for all sources
319
- const allPositions = sources.map((source, index) => {
320
- let x = 0;
321
- let y = 0;
322
- if (isVertical) {
323
- const yPos = index * (1 + gap);
324
- y = isBTT ? -yPos : yPos;
325
- } else {
326
- const xPos = index * (1 + gap);
327
- x = isRTL ? -xPos : xPos;
393
+ if (!result.ok) {
394
+ viewerState.tileSourceError = result.error;
395
+ viewer.close();
396
+ return;
328
397
  }
329
- return { tileSource: source, x, y, width: 1.0 };
330
- });
331
398
 
332
- // Only open a window of canvases around the active one for fast initial load.
333
- // The rest are added progressively after the viewer opens.
334
- const INITIAL_WINDOW = 3; // canvases on each side of current
335
- const currentIndex = viewerState.currentCanvasIndex;
336
- const startIdx = Math.max(0, currentIndex - INITIAL_WINDOW);
337
- const endIdx = Math.min(
338
- allPositions.length,
339
- currentIndex + INITIAL_WINDOW + 1,
340
- );
399
+ viewerState.tileSourceError = null;
400
+ const resolvedSources = result.resolved;
341
401
 
342
- const initialSpread = allPositions.slice(startIdx, endIdx);
343
- viewer.open(initialSpread);
402
+ const gap = 0.025;
344
403
 
345
- viewer.addOnceHandler('open', () => {
346
- // Zoom to the active canvas
347
- const itemIdx = currentIndex - startIdx;
348
- const item = viewer.world.getItemAt(itemIdx);
349
- if (item) {
350
- viewer.viewport.fitBounds(item.getBounds(), true);
351
- }
404
+ // Build position info for all sources
405
+ const allPositions = resolvedSources.map((source, index) => {
406
+ let x = 0;
407
+ let y = 0;
408
+ if (isVertical) {
409
+ const yPos = index * (1 + gap);
410
+ y = isBTT ? -yPos : yPos;
411
+ } else {
412
+ const xPos = index * (1 + gap);
413
+ x = isRTL ? -xPos : xPos;
414
+ }
415
+ return { tileSource: source, x, y, width: 1.0 };
416
+ });
352
417
 
353
- // Progressively add remaining canvases in batches
354
- const remaining = [
355
- ...allPositions
356
- .slice(0, startIdx)
357
- .map((pos, i) => ({ ...pos, originalIndex: i })),
358
- ...allPositions
359
- .slice(endIdx)
360
- .map((pos, i) => ({
361
- ...pos,
362
- originalIndex: endIdx + i,
363
- })),
364
- ];
365
-
366
- const BATCH_SIZE = 5;
367
- let batchIdx = 0;
368
-
369
- function addNextBatch() {
370
- // Staleness guard
371
- if (capturedKey !== lastTileSourceStr) return;
372
-
373
- const batch = remaining.slice(
374
- batchIdx * BATCH_SIZE,
375
- (batchIdx + 1) * BATCH_SIZE,
376
- );
377
- if (batch.length === 0) {
378
- // All loaded — now set zoom constraints based on full world bounds
379
- const worldBounds = viewer.world.getHomeBounds();
380
- const viewportBounds = viewer.viewport.getBounds();
381
- const minZoom =
382
- (Math.min(
383
- viewportBounds.width / worldBounds.width,
384
- viewportBounds.height / worldBounds.height,
385
- ) *
386
- viewer.viewport.getZoom()) *
387
- 0.8;
388
- viewer.viewport.minZoomLevel = minZoom;
389
- viewer.viewport.visibilityRatio = 1;
390
- return;
418
+ // Only open a window of canvases around the active one for fast initial load.
419
+ // The rest are added progressively after the viewer opens.
420
+ const INITIAL_WINDOW = 3; // canvases on each side of current
421
+ const currentIndex = viewerState.currentCanvasIndex;
422
+ const startIdx = Math.max(0, currentIndex - INITIAL_WINDOW);
423
+ const endIdx = Math.min(
424
+ allPositions.length,
425
+ currentIndex + INITIAL_WINDOW + 1,
426
+ );
427
+
428
+ const initialSpread = allPositions.slice(startIdx, endIdx);
429
+ viewer.open(initialSpread);
430
+
431
+ viewer.addOnceHandler('open', () => {
432
+ // Zoom to the active canvas
433
+ const itemIdx = currentIndex - startIdx;
434
+ const item = viewer.world.getItemAt(itemIdx);
435
+ if (item) {
436
+ viewer.viewport.fitBounds(item.getBounds(), true);
391
437
  }
392
438
 
393
- for (const pos of batch) {
394
- viewer.addTiledImage({
395
- tileSource: pos.tileSource,
396
- x: pos.x,
397
- y: pos.y,
398
- width: pos.width,
399
- });
439
+ // Progressively add remaining canvases in batches
440
+ const remaining = [
441
+ ...allPositions
442
+ .slice(0, startIdx)
443
+ .map((pos, i) => ({ ...pos, originalIndex: i })),
444
+ ...allPositions
445
+ .slice(endIdx)
446
+ .map((pos, i) => ({
447
+ ...pos,
448
+ originalIndex: endIdx + i,
449
+ })),
450
+ ];
451
+
452
+ const BATCH_SIZE = 5;
453
+ let batchIdx = 0;
454
+
455
+ function addNextBatch() {
456
+ // Staleness guard
457
+ if (capturedKey !== lastTileSourceStr) return;
458
+
459
+ const batch = remaining.slice(
460
+ batchIdx * BATCH_SIZE,
461
+ (batchIdx + 1) * BATCH_SIZE,
462
+ );
463
+ if (batch.length === 0) {
464
+ // In continuous mode, allow a bit beyond home zoom so
465
+ // strips centered near one end can still include all canvases.
466
+ if (overrides.minZoomLevel === undefined) {
467
+ viewer.viewport.minZoomLevel =
468
+ viewer.viewport.getHomeZoom() + 0.1;
469
+ }
470
+ return;
471
+ }
472
+
473
+ for (const pos of batch) {
474
+ viewer.addTiledImage({
475
+ tileSource: pos.tileSource,
476
+ x: pos.x,
477
+ y: pos.y,
478
+ width: pos.width,
479
+ });
480
+ }
481
+ batchIdx++;
482
+ setTimeout(addNextBatch, 100);
400
483
  }
401
- batchIdx++;
402
- setTimeout(addNextBatch, 100);
403
- }
404
484
 
405
- // Start adding remaining canvases after a short delay
406
- setTimeout(addNextBatch, 200);
485
+ // Start adding remaining canvases after a short delay
486
+ setTimeout(addNextBatch, 200);
487
+ });
407
488
  });
408
489
 
409
490
  return;
410
491
  }
411
492
 
412
- // Pre-fetch info.json URLs to detect auth errors (for non-continuous modes)
493
+ // In paged/individual modes, clear the current image immediately so
494
+ // users don't see stale content while tile sources are being prepared.
495
+ viewer.close();
496
+ viewerState.tileSourceError = null;
497
+
498
+ // Restore less aggressive defaults outside continuous mode unless user-overridden.
499
+ if (overrides.minPixelRatio === undefined) {
500
+ viewer.minPixelRatio = 0.1;
501
+ }
502
+ if (overrides.minZoomImageRatio === undefined) {
503
+ viewer.minZoomImageRatio = 0.9;
504
+ }
505
+
506
+ const immediateSources = sources.map((source) =>
507
+ typeof source === 'string'
508
+ ? source
509
+ : normalizeIiifLevel0Profile(source),
510
+ );
511
+
512
+ // Open immediately for perceived responsiveness.
513
+ if (mode === 'paged' && immediateSources.length === 2) {
514
+ const gap = 0.025;
515
+ const offset = 1 + gap;
516
+
517
+ // Two pages.
518
+ // If LTR: [0] at 0, [1] at 1.025
519
+ // If RTL: [0] at 1.025, [1] at 0
520
+ const firstX = isPagedRTL ? offset : 0;
521
+ const secondX = isPagedRTL ? 0 : offset;
522
+
523
+ const spread = [
524
+ {
525
+ tileSource: immediateSources[0],
526
+ x: firstX,
527
+ y: 0,
528
+ width: 1.0,
529
+ },
530
+ {
531
+ tileSource: immediateSources[1],
532
+ x: secondX,
533
+ y: 0,
534
+ width: 1.0,
535
+ },
536
+ ];
537
+ viewer.open(spread);
538
+ } else {
539
+ // Individuals or single paged or fallback
540
+ viewer.open(
541
+ immediateSources.length === 1
542
+ ? immediateSources[0]
543
+ : immediateSources,
544
+ );
545
+ }
546
+
547
+ // Pre-fetch info.json URLs in the background to detect auth errors
548
+ // without delaying image display.
413
549
  resolveTileSources(sources).then((result) => {
414
550
  // Staleness guard: if tile sources changed while we were fetching, discard
415
551
  if (capturedKey !== lastTileSourceStr) return;
@@ -423,42 +559,6 @@
423
559
 
424
560
  // Clear any previous error
425
561
  viewerState.tileSourceError = null;
426
-
427
- const resolvedSources = result.resolved;
428
-
429
- if (mode === 'paged' && resolvedSources.length === 2) {
430
- const gap = 0.025;
431
- const offset = 1 + gap;
432
-
433
- // Two pages.
434
- // If LTR: [0] at 0, [1] at 1.025
435
- // If RTL: [0] at 1.025, [1] at 0
436
- const firstX = isPagedRTL ? offset : 0;
437
- const secondX = isPagedRTL ? 0 : offset;
438
-
439
- const spread = [
440
- {
441
- tileSource: resolvedSources[0],
442
- x: firstX,
443
- y: 0,
444
- width: 1.0,
445
- },
446
- {
447
- tileSource: resolvedSources[1],
448
- x: secondX,
449
- y: 0,
450
- width: 1.0,
451
- },
452
- ];
453
- viewer.open(spread);
454
- } else {
455
- // Individuals or single paged or fallback
456
- viewer.open(
457
- resolvedSources.length === 1
458
- ? resolvedSources[0]
459
- : resolvedSources,
460
- );
461
- }
462
562
  });
463
563
  });
464
564
 
@@ -610,10 +610,9 @@
610
610
  {manifestData.error}
611
611
  </div>
612
612
  {:else if tileSources}
613
- <OSDViewer {tileSources} viewerState={internalViewerState} />
614
613
  {#if internalViewerState.tileSourceError}
615
614
  <div
616
- class="absolute inset-0 z-[5] flex items-center justify-center pointer-events-none overflow-hidden"
615
+ class="w-full h-full absolute inset-0 z-[5] flex items-center justify-center pointer-events-none overflow-hidden"
617
616
  role="alert"
618
617
  >
619
618
  {#if currentCanvasThumbnail}
@@ -643,6 +642,8 @@
643
642
  </p>
644
643
  </div>
645
644
  </div>
645
+ {:else}
646
+ <OSDViewer {tileSources} viewerState={internalViewerState} />
646
647
  {/if}
647
648
  {:else if manifestData && !manifestData.isFetching && !tileSources}
648
649
  <div