triiiceratops 0.16.2 → 0.16.3

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,19 @@
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
+ const IIIF_LEVEL0_V3_HTTPS = 'https://iiif.io/api/image/3/level0.json';
21
+
22
+ const IIIF_LEVEL0_PROFILES = new Set([
23
+ 'level0',
24
+ IIIF_LEVEL0_V2_HTTP,
25
+ IIIF_LEVEL0_V2_HTTPS,
26
+ IIIF_LEVEL0_V3_HTTPS,
27
+ 'http://library.stanford.edu/iiif/image-api/compliance.html#level0',
28
+ 'http://library.stanford.edu/iiif/image-api/1.1/compliance.html#level0',
29
+ ]);
30
+
18
31
  // Track OSD state changes for reactivity
19
32
  let osdVersion = $state(0);
20
33
  // Track last opened tile source to prevent unnecessary resets
@@ -169,6 +182,7 @@
169
182
  if (!mounted) return;
170
183
 
171
184
  OSD = osdModule.default || osdModule;
185
+ patchIiifLevel0ProfileCompatibility(OSD);
172
186
 
173
187
  // Initialize OpenSeadragon viewer
174
188
  viewer = OSD({
@@ -193,6 +207,16 @@
193
207
  },
194
208
  });
195
209
 
210
+ viewer.addHandler('open', () => {
211
+ const overrides = viewerState.config?.openSeadragonConfig ?? {};
212
+ if (overrides.minZoomLevel !== undefined) return;
213
+
214
+ // Prevent zooming into the "no tiles rendered" range seen on
215
+ // some level-0 services when zoomed far past home view.
216
+ const homeZoom = viewer.viewport.getHomeZoom();
217
+ viewer.viewport.minZoomLevel = homeZoom * 0.5;
218
+ });
219
+
196
220
  // Notify plugins that OSD is ready
197
221
  viewerState.notifyOSDReady(viewer);
198
222
  })();
@@ -239,6 +263,78 @@
239
263
  }
240
264
  });
241
265
 
266
+ function normalizeIiifLevel0Profile<T>(source: T): T {
267
+ if (!source || typeof source !== 'object') return source;
268
+
269
+ const obj = source as any;
270
+ const profile = obj.profile;
271
+
272
+ if (typeof profile === 'string' && profile === IIIF_LEVEL0_V2_HTTPS) {
273
+ obj.profile = IIIF_LEVEL0_V2_HTTP;
274
+ } else if (Array.isArray(profile) && profile.length > 0) {
275
+ const first = profile[0];
276
+ if (typeof first === 'string' && first === IIIF_LEVEL0_V2_HTTPS) {
277
+ obj.profile = [IIIF_LEVEL0_V2_HTTP, ...profile.slice(1)];
278
+ }
279
+ }
280
+
281
+ return source;
282
+ }
283
+
284
+ function isLevel0Profile(profile: unknown): boolean {
285
+ if (typeof profile === 'string') {
286
+ return IIIF_LEVEL0_PROFILES.has(profile);
287
+ }
288
+ if (Array.isArray(profile)) {
289
+ return profile.some(
290
+ (item) =>
291
+ typeof item === 'string' && IIIF_LEVEL0_PROFILES.has(item),
292
+ );
293
+ }
294
+ return false;
295
+ }
296
+
297
+ function buildLevel0ImageSource(info: any): { type: 'image'; url: string } | null {
298
+ const id = info?.id || info?.['@id'];
299
+ if (!id || typeof id !== 'string') return null;
300
+
301
+ const serviceId = id.endsWith('/info.json')
302
+ ? id.slice(0, -'/info.json'.length)
303
+ : id;
304
+
305
+ const context = info?.['@context'];
306
+ const contextValues = Array.isArray(context)
307
+ ? context.filter((v: unknown) => typeof v === 'string')
308
+ : [context].filter((v: unknown) => typeof v === 'string');
309
+ const isV3 = contextValues.some((v: string) =>
310
+ v.includes('/api/image/3/context.json'),
311
+ );
312
+
313
+ // IIIF level-0 is fundamentally a non-tiled target. Using a plain image
314
+ // source avoids zoom-out blanking caused by sparse level math.
315
+ const size = isV3 ? 'max' : 'full';
316
+ return { type: 'image', url: `${serviceId}/full/${size}/0/default.jpg` };
317
+ }
318
+
319
+ function patchIiifLevel0ProfileCompatibility(osd: any) {
320
+ if (
321
+ !osd?.IIIFTileSource?.prototype ||
322
+ osd.__triiiceratopsIiifLevel0Patched
323
+ )
324
+ return;
325
+
326
+ const proto = osd.IIIFTileSource.prototype;
327
+ const originalConfigure = proto.configure;
328
+ if (typeof originalConfigure !== 'function') return;
329
+
330
+ proto.configure = function (data: any, url: string, postData: any) {
331
+ const configured = originalConfigure.call(this, data, url, postData);
332
+ return normalizeIiifLevel0Profile(configured);
333
+ };
334
+
335
+ osd.__triiiceratopsIiifLevel0Patched = true;
336
+ }
337
+
242
338
  // Pre-fetch info.json URLs to detect 401 auth errors before passing to OSD
243
339
  async function resolveTileSources(
244
340
  sources: any[],
@@ -248,18 +344,31 @@
248
344
  > {
249
345
  const resolved = await Promise.all(
250
346
  sources.map(async (source) => {
251
- // Only fetch string sources (info.json URLs)
252
- if (typeof source !== 'string') return source;
347
+ // Only probe string sources (info.json URLs); preserve string tile
348
+ // sources so OSD follows its normal source-loading path.
349
+ if (typeof source !== 'string')
350
+ return normalizeIiifLevel0Profile(source);
253
351
  try {
254
352
  const response = await fetch(source);
255
353
  if (response.status === 401) {
256
354
  return { __authError: true };
257
355
  }
258
- if (!response.ok) {
259
- // For other errors, pass through the URL and let OSD handle it
260
- return source;
356
+ if (response.ok) {
357
+ try {
358
+ const info = normalizeIiifLevel0Profile(
359
+ await response.json(),
360
+ ) as any;
361
+ if (isLevel0Profile(info?.profile)) {
362
+ const level0Source = buildLevel0ImageSource(info);
363
+ if (level0Source) return level0Source;
364
+ }
365
+ } catch {
366
+ // Not JSON or malformed response; let OSD handle source URL.
367
+ }
261
368
  }
262
- return await response.json();
369
+ // For non-401 responses, keep passing through the original source
370
+ // unless we positively detect and convert level-0.
371
+ return source;
263
372
  } catch {
264
373
  // Network errors: pass through and let OSD handle it
265
374
  return source;
@@ -311,99 +420,108 @@
311
420
  const capturedKey = stateKey;
312
421
 
313
422
  if (mode === 'continuous') {
314
- viewerState.tileSourceError = null;
315
-
316
- const gap = 0.025;
317
-
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;
423
+ resolveTileSources(sources).then((result) => {
424
+ // Staleness guard: if tile sources changed while we were fetching, discard
425
+ if (capturedKey !== lastTileSourceStr) return;
426
+
427
+ if (!result.ok) {
428
+ viewerState.tileSourceError = result.error;
429
+ viewer.close();
430
+ return;
328
431
  }
329
- return { tileSource: source, x, y, width: 1.0 };
330
- });
331
432
 
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
- );
433
+ viewerState.tileSourceError = null;
434
+ const resolvedSources = result.resolved;
341
435
 
342
- const initialSpread = allPositions.slice(startIdx, endIdx);
343
- viewer.open(initialSpread);
344
-
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
- }
436
+ const gap = 0.025;
352
437
 
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
- ];
438
+ // Build position info for all sources
439
+ const allPositions = resolvedSources.map((source, index) => {
440
+ let x = 0;
441
+ let y = 0;
442
+ if (isVertical) {
443
+ const yPos = index * (1 + gap);
444
+ y = isBTT ? -yPos : yPos;
445
+ } else {
446
+ const xPos = index * (1 + gap);
447
+ x = isRTL ? -xPos : xPos;
448
+ }
449
+ return { tileSource: source, x, y, width: 1.0 };
450
+ });
365
451
 
366
- const BATCH_SIZE = 5;
367
- let batchIdx = 0;
452
+ // Only open a window of canvases around the active one for fast initial load.
453
+ // The rest are added progressively after the viewer opens.
454
+ const INITIAL_WINDOW = 3; // canvases on each side of current
455
+ const currentIndex = viewerState.currentCanvasIndex;
456
+ const startIdx = Math.max(0, currentIndex - INITIAL_WINDOW);
457
+ const endIdx = Math.min(
458
+ allPositions.length,
459
+ currentIndex + INITIAL_WINDOW + 1,
460
+ );
368
461
 
369
- function addNextBatch() {
370
- // Staleness guard
371
- if (capturedKey !== lastTileSourceStr) return;
462
+ const initialSpread = allPositions.slice(startIdx, endIdx);
463
+ viewer.open(initialSpread);
372
464
 
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;
465
+ viewer.addOnceHandler('open', () => {
466
+ // Zoom to the active canvas
467
+ const itemIdx = currentIndex - startIdx;
468
+ const item = viewer.world.getItemAt(itemIdx);
469
+ if (item) {
470
+ viewer.viewport.fitBounds(item.getBounds(), true);
391
471
  }
392
472
 
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
- });
473
+ // Progressively add remaining canvases in batches
474
+ const remaining = [
475
+ ...allPositions
476
+ .slice(0, startIdx)
477
+ .map((pos, i) => ({ ...pos, originalIndex: i })),
478
+ ...allPositions
479
+ .slice(endIdx)
480
+ .map((pos, i) => ({
481
+ ...pos,
482
+ originalIndex: endIdx + i,
483
+ })),
484
+ ];
485
+
486
+ const BATCH_SIZE = 5;
487
+ let batchIdx = 0;
488
+
489
+ function addNextBatch() {
490
+ // Staleness guard
491
+ if (capturedKey !== lastTileSourceStr) return;
492
+
493
+ const batch = remaining.slice(
494
+ batchIdx * BATCH_SIZE,
495
+ (batchIdx + 1) * BATCH_SIZE,
496
+ );
497
+ if (batch.length === 0) {
498
+ // All loaded — set a stable min zoom floor from world home zoom.
499
+ // Avoid forcing visibilityRatio=1; it can over-constrain level-0
500
+ // services and cause abrupt blanking at zoom-out extremes.
501
+ const overrides =
502
+ viewerState.config?.openSeadragonConfig ?? {};
503
+ if (overrides.minZoomLevel === undefined) {
504
+ const homeZoom = viewer.viewport.getHomeZoom();
505
+ viewer.viewport.minZoomLevel = homeZoom * 0.8;
506
+ }
507
+ return;
508
+ }
509
+
510
+ for (const pos of batch) {
511
+ viewer.addTiledImage({
512
+ tileSource: pos.tileSource,
513
+ x: pos.x,
514
+ y: pos.y,
515
+ width: pos.width,
516
+ });
517
+ }
518
+ batchIdx++;
519
+ setTimeout(addNextBatch, 100);
400
520
  }
401
- batchIdx++;
402
- setTimeout(addNextBatch, 100);
403
- }
404
521
 
405
- // Start adding remaining canvases after a short delay
406
- setTimeout(addNextBatch, 200);
522
+ // Start adding remaining canvases after a short delay
523
+ setTimeout(addNextBatch, 200);
524
+ });
407
525
  });
408
526
 
409
527
  return;