Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions frontend/src/__tests__/geoblacklight/leaflet_viewer_controller.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
150 changes: 150 additions & 0 deletions frontend/src/geoblacklight/iiif_image_layer.js
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading
Loading