diff --git a/frontend/src/__tests__/geoblacklight/leaflet_viewer_controller.test.js b/frontend/src/__tests__/geoblacklight/leaflet_viewer_controller.test.js new file mode 100644 index 00000000..9795e6f7 --- /dev/null +++ b/frontend/src/__tests__/geoblacklight/leaflet_viewer_controller.test.js @@ -0,0 +1,114 @@ +import Leaflet from 'leaflet'; +import { describe, expect, it } from 'vitest'; +import { + EMPTY_IIIF_TILE_URL, + getIiifImageBounds, + getIiifLeafletMapOptions, + getIiifMaxNativeZoom, + getIiifTileUrl, + normalizeIiifImageServiceId, +} from '../../geoblacklight/iiif_image_layer'; + +const baseTileOptions = { + imageApiVersion: 2, + imageHeight: 6270, + imageWidth: 7392, + maxNativeZoom: 3, + serviceId: + 'https://s3.amazonaws.com/ogm-metadata-studio/uploads/unr-74479f22-0e6b-4c13-b376-0195a7461525/iiif', + tileFormat: 'jpg', + tileQuality: 'default', + tileSize: 1024, +}; + +describe('Leaflet IIIF helpers', () => { + it('computes the native zoom from IIIF image dimensions and tile size', () => { + expect(getIiifMaxNativeZoom(7392, 6270, 1024)).toBe(3); + }); + + it('builds CRS.Simple image bounds from pixel dimensions at native zoom', () => { + const bounds = getIiifImageBounds(Leaflet, 7392, 6270, 3); + + expect(bounds.getSouthWest().lat).toBeCloseTo(-783.75); + expect(bounds.getSouthWest().lng).toBeCloseTo(0); + expect(bounds.getNorthEast().lat).toBeCloseTo(0); + expect(bounds.getNorthEast().lng).toBeCloseTo(924); + }); + + it('pins IIIF maps to CRS.Simple and image bounds even when generic map settings exist', () => { + const bounds = getIiifImageBounds(Leaflet, 7392, 6270, 3); + const options = getIiifLeafletMapOptions( + Leaflet, + bounds, + 3, + { SLEEP: false }, + { + crs: Leaflet.CRS.EPSG3857, + gestureHandling: true, + maxZoom: 18, + scrollWheelZoom: true, + zoom: 7, + } + ); + + expect(options.crs).toBe(Leaflet.CRS.Simple); + expect(options.center.equals(bounds.getCenter())).toBe(true); + expect(options.gestureHandling).toBe(true); + expect(options.maxBounds.contains(bounds)).toBe(true); + expect(options.maxZoom).toBe(3); + expect(options.minZoom).toBe(0); + expect(options.scrollWheelZoom).toBe(true); + expect(options.zoom).toBe(0); + }); + + it('normalizes IIIF service ids from info.json metadata', () => { + expect( + normalizeIiifImageServiceId('https://example.com/fallback/info.json', { + '@id': 'https://example.com/canonical/info.json', + }) + ).toBe('https://example.com/canonical'); + }); + + it('generates IIIF v2 region tile URLs for the initial zoom level', () => { + expect( + getIiifTileUrl({ + ...baseTileOptions, + coords: { x: 0, y: 0, z: 0 }, + }) + ).toBe( + 'https://s3.amazonaws.com/ogm-metadata-studio/uploads/unr-74479f22-0e6b-4c13-b376-0195a7461525/iiif/0,0,7392,6270/924,/0/default.jpg' + ); + }); + + it('clips IIIF edge tiles to the image dimensions', () => { + expect( + getIiifTileUrl({ + ...baseTileOptions, + coords: { x: 7, y: 6, z: 3 }, + }) + ).toBe( + 'https://s3.amazonaws.com/ogm-metadata-studio/uploads/unr-74479f22-0e6b-4c13-b376-0195a7461525/iiif/7168,6144,224,126/224,/0/default.jpg' + ); + }); + + it('uses IIIF v3 width,height size syntax', () => { + expect( + getIiifTileUrl({ + ...baseTileOptions, + imageApiVersion: 3, + coords: { x: 0, y: 0, z: 3 }, + }) + ).toBe( + 'https://s3.amazonaws.com/ogm-metadata-studio/uploads/unr-74479f22-0e6b-4c13-b376-0195a7461525/iiif/0,0,1024,1024/1024,1024/0/default.jpg' + ); + }); + + it('does not request remote URLs for tiles outside the image', () => { + expect( + getIiifTileUrl({ + ...baseTileOptions, + coords: { x: 8, y: 0, z: 3 }, + }) + ).toBe(EMPTY_IIIF_TILE_URL); + }); +}); diff --git a/frontend/src/geoblacklight/iiif_image_layer.js b/frontend/src/geoblacklight/iiif_image_layer.js new file mode 100644 index 00000000..f02d0062 --- /dev/null +++ b/frontend/src/geoblacklight/iiif_image_layer.js @@ -0,0 +1,150 @@ +export const EMPTY_IIIF_TILE_URL = + 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; + +function getInfoId(info) { + const id = info.id ?? info['@id']; + return typeof id === 'string' ? id : null; +} + +export function getIiifImageApiVersion(info) { + const context = info['@context']; + const contextValues = Array.isArray(context) ? context : [context]; + + if ( + contextValues.some( + (value) => typeof value === 'string' && value.includes('/image/2') + ) + ) { + return 2; + } + + return info['@id'] && !info.id ? 2 : 3; +} + +export function getIiifTileSize(info) { + const firstTile = Array.isArray(info.tiles) ? info.tiles[0] : undefined; + return firstTile?.width && firstTile.width > 0 ? firstTile.width : 256; +} + +export function getIiifTileFormat(info) { + const preferredFormats = info.preferredFormats; + if (Array.isArray(preferredFormats)) { + const firstFormat = preferredFormats.find( + (format) => typeof format === 'string' + ); + if (firstFormat) return firstFormat.replace(/^\./, ''); + } + + return 'jpg'; +} + +export function normalizeIiifImageServiceId(imageServiceOrInfoUrl, info) { + const canonical = getInfoId(info); + const serviceId = canonical || imageServiceOrInfoUrl; + return serviceId.replace(/\/info\.json$/, '').replace(/\/$/, ''); +} + +export function getIiifMaxNativeZoom(width, height, tileSize) { + return Math.max( + Math.ceil(Math.log(width / tileSize) / Math.LN2), + Math.ceil(Math.log(height / tileSize) / Math.LN2), + 0 + ); +} + +export function getIiifImageBounds(leaflet, width, height, maxNativeZoom) { + const southWest = leaflet.CRS.Simple.pointToLatLng( + leaflet.point(0, height), + maxNativeZoom + ); + const northEast = leaflet.CRS.Simple.pointToLatLng( + leaflet.point(width, 0), + maxNativeZoom + ); + return leaflet.latLngBounds(southWest, northEast); +} + +export function getIiifTileUrl({ + coords, + imageApiVersion, + imageHeight, + imageWidth, + maxNativeZoom, + serviceId, + tileFormat, + tileQuality, + tileSize, +}) { + const zoom = Math.max(0, Math.min(maxNativeZoom, coords.z)); + const scale = Math.pow(2, maxNativeZoom - zoom); + const sourceTileSize = tileSize * scale; + const minX = coords.x * sourceTileSize; + const minY = coords.y * sourceTileSize; + + if ( + coords.x < 0 || + coords.y < 0 || + minX >= imageWidth || + minY >= imageHeight + ) { + return EMPTY_IIIF_TILE_URL; + } + + const maxX = Math.min(minX + sourceTileSize, imageWidth); + const maxY = Math.min(minY + sourceTileSize, imageHeight); + const regionWidth = maxX - minX; + const regionHeight = maxY - minY; + + if (regionWidth <= 0 || regionHeight <= 0) { + return EMPTY_IIIF_TILE_URL; + } + + const outputWidth = Math.ceil(regionWidth / scale); + const outputHeight = Math.ceil(regionHeight / scale); + const size = + imageApiVersion === 2 + ? `${outputWidth},` + : `${outputWidth},${outputHeight}`; + const region = [minX, minY, regionWidth, regionHeight].join(','); + const baseUrl = serviceId.replace(/\/$/, ''); + + return `${baseUrl}/${region}/${size}/0/${tileQuality}.${tileFormat}`; +} + +export async function fetchIiifImageInfo(imageServiceOrInfoUrl) { + const base = imageServiceOrInfoUrl.replace(/\/$/, ''); + const infoUrl = base.endsWith('/info.json') ? base : `${base}/info.json`; + const response = await fetch(infoUrl, { + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch IIIF info.json: ${response.status}`); + } + + return response.json(); +} + +export function getIiifLeafletMapOptions( + leaflet, + imageBounds, + maxNativeZoom, + sleepSettings, + mapSettings +) { + return { + ...sleepSettings, + ...mapSettings, + attributionControl: false, + center: imageBounds.getCenter(), + crs: leaflet.CRS.Simple, + maxBounds: imageBounds.pad(0.5), + maxBoundsViscosity: 0.5, + maxZoom: maxNativeZoom, + minZoom: 0, + preferCanvas: false, + zoom: 0, + }; +} diff --git a/frontend/src/geoblacklight/leaflet_viewer_controller.js b/frontend/src/geoblacklight/leaflet_viewer_controller.js index 97b13859..37e2cb82 100644 --- a/frontend/src/geoblacklight/leaflet_viewer_controller.js +++ b/frontend/src/geoblacklight/leaflet_viewer_controller.js @@ -1,22 +1,44 @@ import Leaflet, { map } from 'leaflet'; +import 'leaflet-fullscreen'; import BaseLeafletViewerController from '@geoblacklight/frontend/app/javascript/geoblacklight/controllers/leaflet_viewer_controller'; import Sleep from 'geoblacklight/leaflet/controls/sleep'; import { registerLeafletGestureHandling } from '../config/leafletGestureHandling'; - -let leafletIiifPromise; - -async function ensureLeafletIiif() { - if (Leaflet.tileLayer.iiif) return; - - globalThis.L = Leaflet; - if (typeof window !== 'undefined') window.L = Leaflet; - - leafletIiifPromise ||= import('leaflet-iiif'); - await leafletIiifPromise; - - if (!Leaflet.tileLayer.iiif) { - throw new Error('leaflet-iiif did not register L.tileLayer.iiif'); - } +import { + fetchIiifImageInfo, + getIiifImageApiVersion, + getIiifImageBounds, + getIiifLeafletMapOptions, + getIiifMaxNativeZoom, + getIiifTileFormat, + getIiifTileSize, + getIiifTileUrl, + normalizeIiifImageServiceId, +} from './iiif_image_layer'; + +function buildIiifTileLayer(options) { + const IiifTileLayer = Leaflet.TileLayer.extend({ + getTileUrl(coords) { + return getIiifTileUrl({ + coords, + imageApiVersion: this.options.imageApiVersion, + imageHeight: this.options.imageHeight, + imageWidth: this.options.imageWidth, + maxNativeZoom: this.maxNativeZoom, + serviceId: this.options.serviceId, + tileFormat: this.options.tileFormat, + tileQuality: this.options.tileQuality, + tileSize: this.options.tileSize, + }); + }, + }); + + const layer = new IiifTileLayer('', { + ...options, + noWrap: true, + updateWhenIdle: true, + }); + layer.maxNativeZoom = options.maxNativeZoom || 0; + return layer; } export default class LeafletViewerController extends BaseLeafletViewerController { @@ -24,31 +46,56 @@ export default class LeafletViewerController extends BaseLeafletViewerController return this.protocolValue === 'Iiif'; } + connect() { + if (!this.isIiifImage) { + super.connect(); + return; + } + + Leaflet.Icon.Default.imagePath = + 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/'; + this.overlay = Leaflet.layerGroup(); + this.bounds = null; + void this.loadMap().catch((error) => { + console.error('Failed to load IIIF image viewer:', error); + }); + } + + disconnect() { + if (!this.isIiifImage) return; + + if (this.iiifCleanup) { + this.iiifCleanup(); + this.iiifCleanup = null; + } + if (this.map) { + this.map.remove(); + this.map = null; + } + } + // Keep the GeoBlacklight controller behavior, but let local MAP options reach L.map. async loadMap() { if (this.map) return; registerLeafletGestureHandling(Leaflet); + if (this.isIiifImage) { + await this.loadIiifImageMap(); + return; + } + const sleepSettings = this.optionsValue.SLEEP || { SLEEP: false }; const mapSettings = this.optionsValue.MAP || {}; - const iiifSettings = this.isIiifImage - ? { - center: [0, 0], - crs: Leaflet.CRS.Simple, - zoom: 0, - } - : {}; this.map = map(this.element, { ...sleepSettings, ...mapSettings, - ...iiifSettings, }); if (sleepSettings.SLEEP) this.map.addHandler('SLEEP', Sleep); - if (!this.isIiifImage) this.map.addLayer(this.basemap); + this.map.addLayer(this.basemap); this.map.addLayer(this.overlay); - if (!this.isIiifImage) this.fitBounds(this.bounds); + this.fitBounds(this.bounds); this.map.options.selected_color = this.optionsValue.SELECTED_COLOR || 'blue'; @@ -70,16 +117,109 @@ export default class LeafletViewerController extends BaseLeafletViewerController this.dispatch('loaded'); } - async getPreviewOverlay(protocol, url, options) { - if (protocol === 'Iiif') { - await ensureLeafletIiif(); - return Leaflet.tileLayer.iiif(url, { - ...options, - fitBounds: true, - setMaxBounds: true, + async loadIiifImageMap() { + if (!this.availableValue || !this.urlValue) return; + + const info = await fetchIiifImageInfo(this.urlValue); + const width = info.width; + const height = info.height; + if (!width || !height) { + throw new Error('IIIF info.json is missing image dimensions.'); + } + + const tileSize = getIiifTileSize(info); + const maxNativeZoom = getIiifMaxNativeZoom(width, height, tileSize); + const imageBounds = getIiifImageBounds( + Leaflet, + width, + height, + maxNativeZoom + ); + const sleepSettings = this.optionsValue.SLEEP || { SLEEP: false }; + const mapSettings = this.optionsValue.MAP || {}; + + this.map = map( + this.element, + getIiifLeafletMapOptions( + Leaflet, + imageBounds, + maxNativeZoom, + sleepSettings, + mapSettings + ) + ); + if (sleepSettings.SLEEP) this.map.addHandler('SLEEP', Sleep); + + this.previewOverlay = buildIiifTileLayer({ + bounds: imageBounds, + imageApiVersion: getIiifImageApiVersion(info), + imageHeight: height, + imageWidth: width, + maxNativeZoom, + maxZoom: maxNativeZoom, + minZoom: 0, + serviceId: normalizeIiifImageServiceId(this.urlValue, info), + tileFormat: getIiifTileFormat(info), + tileQuality: 'default', + tileSize, + }); + this.overlay.addTo(this.map); + this.overlay.addLayer(this.previewOverlay); + this.map.options.selected_color = + this.optionsValue.SELECTED_COLOR || 'blue'; + this.scheduleIiifRefits(imageBounds); + this.addIiifControls(); + this.dispatch('loaded'); + } + + scheduleIiifRefits(imageBounds) { + const refit = () => { + if (!this.map) return; + + this.map.invalidateSize(); + this.map.fitBounds(imageBounds, { + animate: false, + padding: [16, 16], + }); + this.bounds = imageBounds; + this.element.dataset.bounds = imageBounds.toBBoxString(); + }; + + const timeoutIds = [0, 100, 300].map((delay) => + window.setTimeout(refit, delay) + ); + let firstFrame = null; + let secondFrame = null; + + if (typeof window.requestAnimationFrame === 'function') { + firstFrame = window.requestAnimationFrame(() => { + refit(); + secondFrame = window.requestAnimationFrame(refit); }); } - return super.getPreviewOverlay(protocol, url, options); + const resizeObserver = + typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(refit) + : null; + resizeObserver?.observe(this.element); + refit(); + + this.iiifCleanup = () => { + if (firstFrame !== null) window.cancelAnimationFrame(firstFrame); + if (secondFrame !== null) window.cancelAnimationFrame(secondFrame); + timeoutIds.forEach((id) => window.clearTimeout(id)); + resizeObserver?.disconnect(); + }; + } + + addIiifControls() { + if (this.availableValue && this.previewOverlay) { + const opacityControl = this.getControl('Opacity'); + if (opacityControl) this.addControl(opacityControl); + } + + const fullscreenControl = this.getControl('Fullscreen'); + if (fullscreenControl) this.addControl(fullscreenControl); } }