diff --git a/assets/icons/arrow-left.svg b/assets/icons/arrow-left.svg new file mode 100644 index 0000000..587d4fe --- /dev/null +++ b/assets/icons/arrow-left.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/icons/arrow-right.svg b/assets/icons/arrow-right.svg new file mode 100644 index 0000000..2362904 --- /dev/null +++ b/assets/icons/arrow-right.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/icons/arrows-fullscreen.svg b/assets/icons/arrows-fullscreen.svg new file mode 100644 index 0000000..7633e3f --- /dev/null +++ b/assets/icons/arrows-fullscreen.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/icons/house.svg b/assets/icons/house.svg new file mode 100644 index 0000000..cb57f68 --- /dev/null +++ b/assets/icons/house.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/icons/zoom-in.svg b/assets/icons/zoom-in.svg new file mode 100644 index 0000000..438e9bc --- /dev/null +++ b/assets/icons/zoom-in.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/assets/icons/zoom-out.svg b/assets/icons/zoom-out.svg new file mode 100644 index 0000000..8be9f29 --- /dev/null +++ b/assets/icons/zoom-out.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 93819b1..1ee388a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,13 @@ "license": "MIT", "dependencies": { "@geomatico/maplibre-cog-protocol": "^0.5.0", + "@iiif/presentation-2": "^1.0.4", + "@iiif/presentation-3": "^2.2.3", "@shoelace-style/shoelace": "^2.20.1", "@terraformer/wkt": "^2.2.1", "d3-scale": "^4.0.2", - "maplibre-gl": "^5.6.0" + "maplibre-gl": "^5.6.0", + "openseadragon": "^5.0.1" }, "devDependencies": { "@stencil/core": "^4.27.1", @@ -530,6 +533,24 @@ "integrity": "sha512-2kpyIS7bD1vPRcy/7M2Qul/HZKs9ZLaNjyI3XLrtdriToMLIPgNd+YbuZKPbJ9l+UwvNXlNSgyeMrkpVDq/X2A==", "license": "MIT" }, + "node_modules/@iiif/presentation-2": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@iiif/presentation-2/-/presentation-2-1.0.4.tgz", + "integrity": "sha512-hJakpq62VBajesLJrYPtFm6hcn6c/HkKP7CmKZ5atuzu40m0nifWYsqigR1l9sZGvhhHb/DRshPmiW/0GNrJoA==", + "license": "MIT", + "peerDependencies": { + "@iiif/presentation-3": "*" + } + }, + "node_modules/@iiif/presentation-3": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@iiif/presentation-3/-/presentation-3-2.2.3.tgz", + "integrity": "sha512-xCLbUr9euqegsrxGe65M2fWbv6gKpiUhHXCpOn+V+qtawkMbOSNWbYOISo2aLQdYVg4DGYD0g2bMzSCF33uNOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.10" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -3889,6 +3910,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openseadragon": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/openseadragon/-/openseadragon-5.0.1.tgz", + "integrity": "sha512-a/hjouW9i3UfWxRADVYN2MyRhXMGnE7x9VVL7/4jXCcDLFyO4UM5o4RStYtqa5BfaHw/wMNAaD2WbxQF8f1pJg==", + "license": "BSD-3-Clause", + "funding": { + "url": "https://opencollective.com/openseadragon" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", diff --git a/package.json b/package.json index dd51dce..9e60eb4 100644 --- a/package.json +++ b/package.json @@ -47,9 +47,12 @@ "license": "MIT", "dependencies": { "@geomatico/maplibre-cog-protocol": "^0.5.0", + "@iiif/presentation-2": "^1.0.4", + "@iiif/presentation-3": "^2.2.3", "@shoelace-style/shoelace": "^2.20.1", "@terraformer/wkt": "^2.2.1", "d3-scale": "^4.0.2", - "maplibre-gl": "^5.6.0" + "maplibre-gl": "^5.6.0", + "openseadragon": "^5.0.1" } } diff --git a/src/components.d.ts b/src/components.d.ts index fe376de..a39b8ba 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -10,6 +10,10 @@ import { EaseToOptions } from "maplibre-gl"; export { OgmRecord } from "./utils/record"; export { EaseToOptions } from "maplibre-gl"; export namespace Components { + interface OgmImage { + "record": OgmRecord; + "theme": 'light' | 'dark'; + } interface OgmMap { "easeMapTo": (options: EaseToOptions) => Promise; "previewOpacity": number; @@ -35,10 +39,15 @@ export namespace Components { "theme": 'light' | 'dark'; } interface OgmViewer { + "loadRecord": (record: OgmRecord) => Promise; "recordUrl": string; "theme": 'light' | 'dark'; } } +export interface OgmImageCustomEvent extends CustomEvent { + detail: T; + target: HTMLOgmImageElement; +} export interface OgmMapCustomEvent extends CustomEvent { detail: T; target: HTMLOgmMapElement; @@ -52,6 +61,24 @@ export interface OgmSettingsCustomEvent extends CustomEvent { target: HTMLOgmSettingsElement; } declare global { + interface HTMLOgmImageElementEventMap { + "imageLoaded": void; + "imageLoading": void; + } + interface HTMLOgmImageElement extends Components.OgmImage, HTMLStencilElement { + addEventListener(type: K, listener: (this: HTMLOgmImageElement, ev: OgmImageCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLOgmImageElement, ev: OgmImageCustomEvent) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; + } + var HTMLOgmImageElement: { + prototype: HTMLOgmImageElement; + new (): HTMLOgmImageElement; + }; interface HTMLOgmMapElementEventMap { "mapIdle": void; "mapLoading": void; @@ -123,6 +150,7 @@ declare global { new (): HTMLOgmViewerElement; }; interface HTMLElementTagNameMap { + "ogm-image": HTMLOgmImageElement; "ogm-map": HTMLOgmMapElement; "ogm-menubar": HTMLOgmMenubarElement; "ogm-metadata": HTMLOgmMetadataElement; @@ -132,6 +160,12 @@ declare global { } } declare namespace LocalJSX { + interface OgmImage { + "onImageLoaded"?: (event: OgmImageCustomEvent) => void; + "onImageLoading"?: (event: OgmImageCustomEvent) => void; + "record"?: OgmRecord; + "theme"?: 'light' | 'dark'; + } interface OgmMap { "onMapIdle"?: (event: OgmMapCustomEvent) => void; "onMapLoading"?: (event: OgmMapCustomEvent) => void; @@ -164,6 +198,7 @@ declare namespace LocalJSX { "theme"?: 'light' | 'dark'; } interface IntrinsicElements { + "ogm-image": OgmImage; "ogm-map": OgmMap; "ogm-menubar": OgmMenubar; "ogm-metadata": OgmMetadata; @@ -176,6 +211,7 @@ export { LocalJSX as JSX }; declare module "@stencil/core" { export namespace JSX { interface IntrinsicElements { + "ogm-image": LocalJSX.OgmImage & JSXBase.HTMLAttributes; "ogm-map": LocalJSX.OgmMap & JSXBase.HTMLAttributes; "ogm-menubar": LocalJSX.OgmMenubar & JSXBase.HTMLAttributes; "ogm-metadata": LocalJSX.OgmMetadata & JSXBase.HTMLAttributes; diff --git a/src/components/ogm-image/ogm-image.css b/src/components/ogm-image/ogm-image.css new file mode 100644 index 0000000..2f78254 --- /dev/null +++ b/src/components/ogm-image/ogm-image.css @@ -0,0 +1,21 @@ +:host { + display: block; + width: 100%; + height: 100%; + position: relative; +} + +#openseadragon { + height: 100%; + background-color: var(--sl-panel-background-color); +} + +.controls { + position: absolute; + top: 0.5rem; + right: 0.5rem; + z-index: 1; + display: flex; + flex-direction: column; + gap: 0.5rem; +} diff --git a/src/components/ogm-image/ogm-image.tsx b/src/components/ogm-image/ogm-image.tsx new file mode 100644 index 0000000..54ae58e --- /dev/null +++ b/src/components/ogm-image/ogm-image.tsx @@ -0,0 +1,82 @@ +import { Component, Element, h, Host, Watch, Prop, Event, EventEmitter } from '@stencil/core'; +import { Viewer } from 'openseadragon'; + +import type { OgmRecord } from '../../utils/record'; + +@Component({ + tag: 'ogm-image', + styleUrl: 'ogm-image.css', + shadow: true, +}) +export class OgmImage { + @Element() el: HTMLElement; + @Prop() record: OgmRecord; + @Prop() theme: 'light' | 'dark'; + @Event() imageLoaded: EventEmitter; + @Event() imageLoading: EventEmitter; + + // OpenSeadragon viewer instance + private viewer: Viewer; + + // Set up OpenSeadragon viewer on load + async componentDidLoad() { + this.viewer = new Viewer({ + element: this.el.shadowRoot.getElementById('openseadragon'), + prefixUrl: 'https://cdnjs.cloudflare.com/ajax/libs/openseadragon/2.4.2/images/', + visibilityRatio: 1, + sequenceMode: true, + drawer: (typeof jest === 'undefined') ? 'webgl' : 'html', // No WebGL in tests + zoomInButton: this.el.shadowRoot.querySelector('.zoom-in'), + zoomOutButton: this.el.shadowRoot.querySelector('.zoom-out'), + homeButton: this.el.shadowRoot.querySelector('.home'), + fullPageButton: this.el.shadowRoot.querySelector('.full-page'), + nextButton: this.el.shadowRoot.querySelector('.next'), + previousButton: this.el.shadowRoot.querySelector('.prev'), + }); + this.viewer.addHandler('open', () => this.imageLoaded.emit()); + if (this.record) await this.loadImages(); + } + + // Update preview when record changes + @Watch('record') + async onRecordChange() { + if (this.record) await this.loadImages(); + } + + // Get all of the IIIF image URLs and send them to OpenSeadragon + // This makes a request to fetch and cache the manifest + private async loadImages() { + this.imageLoading.emit(); + const images = await this.record.references.iiifImages(); + this.viewer.open(images); + } + + render() { + return ( + +
+
+ + + + + + + + + + + + + + + + + + +
+
+
+ ); + } +} diff --git a/src/components/ogm-map/ogm-map.tsx b/src/components/ogm-map/ogm-map.tsx index b8857f5..bdd67a7 100644 --- a/src/components/ogm-map/ogm-map.tsx +++ b/src/components/ogm-map/ogm-map.tsx @@ -47,6 +47,7 @@ export class OgmMap { this.containerEl = this.el.parentElement.parentElement; this.addControls(); this.map.on('idle', () => this.mapIdle.emit()); + this.map.on('load', () => this.previewRecord(this.record)); } // Add controls to the map diff --git a/src/components/ogm-settings/ogm-settings.tsx b/src/components/ogm-settings/ogm-settings.tsx index 64a0ce9..7265c2a 100644 --- a/src/components/ogm-settings/ogm-settings.tsx +++ b/src/components/ogm-settings/ogm-settings.tsx @@ -26,7 +26,7 @@ export class OgmSettings { render() { return (
- +
); } diff --git a/src/components/ogm-viewer/ogm-viewer.css b/src/components/ogm-viewer/ogm-viewer.css index 12118f0..d20b11d 100644 --- a/src/components/ogm-viewer/ogm-viewer.css +++ b/src/components/ogm-viewer/ogm-viewer.css @@ -19,7 +19,7 @@ height: 100%; } -.map-container { +.main-container { position: relative; min-height: 400px; height: 100%; diff --git a/src/components/ogm-viewer/ogm-viewer.tsx b/src/components/ogm-viewer/ogm-viewer.tsx index a06b523..6cc7cf4 100644 --- a/src/components/ogm-viewer/ogm-viewer.tsx +++ b/src/components/ogm-viewer/ogm-viewer.tsx @@ -1,5 +1,5 @@ -import { Component, Element, Prop, Watch, State, Listen, h, getAssetPath } from '@stencil/core'; import { setBasePath } from '@shoelace-style/shoelace/dist/utilities/base-path.js'; +import { Component, Element, Listen, Method, Prop, State, Watch, getAssetPath, h } from '@stencil/core'; import { OgmRecord } from '../../utils/record'; @@ -7,11 +7,12 @@ import { OgmRecord } from '../../utils/record'; setBasePath(getAssetPath('')); // Import all required Shoelace components -import '@shoelace-style/shoelace/dist/components/icon-button/icon-button.js'; -import '@shoelace-style/shoelace/dist/components/spinner/spinner.js'; import '@shoelace-style/shoelace/dist/components/drawer/drawer.js'; +import '@shoelace-style/shoelace/dist/components/button/button.js'; +import '@shoelace-style/shoelace/dist/components/icon-button/icon-button.js'; import '@shoelace-style/shoelace/dist/components/icon/icon.js'; import '@shoelace-style/shoelace/dist/components/range/range.js'; +import '@shoelace-style/shoelace/dist/components/spinner/spinner.js'; import '@shoelace-style/shoelace/dist/components/tab-group/tab-group.js'; import '@shoelace-style/shoelace/dist/components/tab-panel/tab-panel.js'; import '@shoelace-style/shoelace/dist/components/tab/tab.js'; @@ -25,42 +26,50 @@ import '@shoelace-style/shoelace/dist/components/tooltip/tooltip.js'; export class OgmViewer { @Element() el: HTMLElement; @Prop() recordUrl: string; - @Prop() theme: 'light' | 'dark'; + @Prop() theme: 'light' | 'dark' = this.getThemePreference(); @State() record: OgmRecord; @State() sidebarOpen: boolean = false; @State() previewOpacity: number = 100; - @State() loading: boolean = false; + private loading: boolean = false; private map: HTMLOgmMapElement; + // Prior to rendering, fetch the record if a URL is provided async componentWillLoad() { - // If no theme provided, detect the user's system preference - if (!this.theme) { - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - this.theme = prefersDark ? 'dark' : 'light'; - } - - // Fetch the record if a URL is provided if (this.recordUrl) return await this.updateRecord(); } - componentDidLoad() { + // Check the user's theme preference via CSS media query + private getThemePreference() { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + + // After rendering, find the map element in the shadow DOM, if it's there + componentDidRender() { this.map = this.el.shadowRoot.querySelector('ogm-map'); } - // Shift the map over when the sidebar is toggled open + // Shift the map/image over when the sidebar is toggled open @Listen('sidebarToggled') toggleSidebar() { this.sidebarOpen = !this.sidebarOpen; + if (!this.map) return; if (this.sidebarOpen) this.map.easeMapTo({ padding: { left: 400 } }); else this.map.easeMapTo({ padding: { left: 20 } }); } + // When URL changes, fetch the new record @Watch('recordUrl') async updateRecord() { this.record = await this.fetchRecord(this.recordUrl); } + // Can be called externally to set the record directly + @Method() + async loadRecord(record: OgmRecord) { + this.record = record; + } + // Listen for opacity changes from the sidebar and adjust the preview layer @Listen('opacityChange') adjustPreviewOpacity(event: CustomEvent) { @@ -69,12 +78,14 @@ export class OgmViewer { // Listen for map to report loading started @Listen('mapLoading') + @Listen('imageLoading') setLoadingStarted() { this.loading = true; } // Listen for map to report loading finished @Listen('mapIdle') + @Listen('imageLoaded') setLoadingFinished() { this.loading = false; } @@ -86,13 +97,19 @@ export class OgmViewer { return new OgmRecord(data); } + // Choose a preview component based on the record type + private renderPreview() { + if (this.record && this.record.references.iiifOnly) return ; + return ; + } + render() { return (
-
+
- + {this.renderPreview()}
); diff --git a/src/components/ogm-viewer/test/ogm-viewer.e2e.ts b/src/components/ogm-viewer/test/ogm-viewer.e2e.ts new file mode 100644 index 0000000..c3f52bb --- /dev/null +++ b/src/components/ogm-viewer/test/ogm-viewer.e2e.ts @@ -0,0 +1,56 @@ +import { newE2EPage } from '@stencil/core/testing'; +import { OgmRecord } from '../../../utils/record'; + +describe('ogm-viewer', () => { + describe('with a record with a WMS reference', () => { + it('renders the map', async () => { + const page = await newE2EPage({ + html: ``, + }); + const record = new OgmRecord({ + id: 'stanford-ff359cr8805', + dct_title_s: 'Coho Salmon Watersheds: San Francisco Bay Area, California, 2011', + dct_description_sm: [], + dct_format_s: '', + gbl_resourceType_sm: ['Polygon data'], + gbl_resourceClass_sm: ['Datasets'], + dct_accessRights_s: 'Public', + gbl_mdVersion_s: 'Aardvark', + dct_references_s: JSON.stringify({ + 'http://www.opengis.net/def/serviceType/ogc/wms': 'fake-geoserver.com', + }), + }); + const viewerEl = await page.find('ogm-viewer'); + await viewerEl.callMethod('loadRecord', record); + await page.waitForChanges(); + const mapEl = await page.find('ogm-viewer >>> ogm-map'); + expect(mapEl).toBeTruthy(); + }); + }); + + describe('with a record with a IIIF image reference', () => { + it('renders the IIIF image viewer', async () => { + const page = await newE2EPage({ + html: ``, + }); + const record = new OgmRecord({ + id: 'stanford-ff359cr8805', + dct_title_s: 'Coho Salmon Watersheds: San Francisco Bay Area, California, 2011', + dct_description_sm: [], + dct_format_s: '', + gbl_resourceType_sm: ['Image'], + gbl_resourceClass_sm: ['Datasets'], + dct_accessRights_s: 'Public', + gbl_mdVersion_s: 'Aardvark', + dct_references_s: JSON.stringify({ + 'http://iiif.io/api/image': 'https://example.com/iiif/stanford-ff359cr8805/info.json', + }), + }); + const viewerEl = await page.find('ogm-viewer'); + await viewerEl.callMethod('loadRecord', record); + await page.waitForChanges(); + const iiifViewerEl = await page.find('ogm-viewer >>> ogm-image'); + expect(iiifViewerEl).toBeTruthy(); + }); + }); +}); diff --git a/src/index.html b/src/index.html index 70446c4..f4d7499 100644 --- a/src/index.html +++ b/src/index.html @@ -114,7 +114,15 @@

OpenGeoMetadata Viewer Preview

+ +
diff --git a/src/utils/references.ts b/src/utils/references.ts index 1935730..9a692be 100644 --- a/src/utils/references.ts +++ b/src/utils/references.ts @@ -1,3 +1,6 @@ +import iiif3 from '@iiif/presentation-3'; +import iiif2 from '@iiif/presentation-2'; + // Map reference URI keys to user-friendly names export const REFERENCE_URIS = { 'https://github.com/cogeotiff/cog-spec': 'COG', @@ -50,6 +53,9 @@ export class References { // Underlying object to hold references private references: ReferencesRecord; + // Cache for fetched IIIF manifest + iiifManifest: iiif3.Manifest | iiif2.Manifest | null; + // Create a new instance with the JSON string from a record constructor(dct_references_s: string) { try { @@ -61,30 +67,68 @@ export class References { } // The WMS URL, if any - get wms() { + get wmsUrl() { return this.references['http://www.opengis.net/def/serviceType/ogc/wms']; } // The cloud-optimized GeoTIFF URL, if any - get cog() { + get cogUrl() { return this.references['https://github.com/cogeotiff/cog-spec']; } // The TMS URL, if any - get tms() { + get tmsUrl() { return this.references['https://wiki.osgeo.org/wiki/Tile_Map_Service_Specification']; } // The XYZ tiles URL, if any - get xyz() { + get xyzUrl() { return this.references['https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames']; } // The GeoJSON URL, if any - get geojson() { + get geojsonUrl() { return this.references['http://geojson.org/geojson-spec.html']; } + // The TileJSON URL, if any + get tilejsonUrl() { + return this.references['https://tilejson.org/specification/2.2.0/schema.json']; + } + + // The Index map URL, if any + get indexMapUrl() { + return this.references['https://openindexmaps.org']; + } + + // The PMTiles URL, if any + get pmtilesUrl() { + return this.references['https://pmtiles.org']; + } + + // The WMTS URL, if any + get wmtsUrl() { + return this.references['http://www.opengis.net/def/serviceType/ogc/wmts']; + } + + // The IIIF image URL, if any + get iiifImageUrl() { + return this.references['http://iiif.io/api/image']; + } + + // The IIIF manifest URL, if any + get iiifManifestUrl() { + return this.references['http://iiif.io/api/presentation#manifest']; + } + + async iiifImages() { + if (this.iiifImageUrl) return [this.iiifImageUrl]; + if (!this.iiifManifest && this.iiifManifestUrl) await this.fetchManifest(); + if (!this.iiifManifest) return []; + if (this.iiifVersion == 3) return this.extractIiif3ImageUrls(this.iiifManifest as iiif3.Manifest); + if (this.iiifVersion == 2) return this.extractIiif2ImageUrls(this.iiifManifest as iiif2.Manifest); + } + // List of download links with URL and label get downloadLinks(): LabelledLinks { const fieldContents = this.references['http://schema.org/downloadUrl']; @@ -99,4 +143,93 @@ export class References { .filter(([uri]) => METADATA_REFERENCE_URIS.includes(uri)) .map(([uri, url]: [ReferenceURI, string]) => ({ url, label: REFERENCE_URIS[uri] })); } + + // True if the record has at least one reference that can be rendered for preview + get previewable() { + return this.previewableReferences.some(Boolean); + } + + // True if the record has a reference that can be rendered on a map + get mapPreviewable() { + return this.mapPreviewableReferences.some(Boolean); + } + + // True if the record has any IIIF references (image or manifest) + get iiifPreviewable() { + return this.iiifReferences.some(Boolean); + } + + // True if the record can only be previewed via IIIF references (image or manifest) + get iiifOnly() { + return !this.mapPreviewable && this.iiifPreviewable; + } + + // Get all references that can be rendered for preview + private get previewableReferences() { + return this.mapPreviewableReferences.concat(this.iiifReferences); + } + + // Get all references that can be rendered on a map + private get mapPreviewableReferences() { + return [this.wmsUrl, this.cogUrl, this.tmsUrl, this.xyzUrl, this.geojsonUrl, this.tilejsonUrl, this.indexMapUrl, this.pmtilesUrl, this.wmtsUrl]; + } + + // Get all IIIF references (image and manifest) + private get iiifReferences() { + return [this.iiifImageUrl, this.iiifManifestUrl]; + } + + // Get the IIIF presentation spec version of the manifest, if we have one + private get iiifVersion() { + if (!this.iiifManifest) return null; + return this.iiifManifest['@context']?.includes('http://iiif.io/api/presentation/3/context.json') ? 3 : 2; + } + + // Given a v2 manifest, extract all of the IIIF images and format as info.json URLs + private extractIiif2ImageUrls(manifest: iiif2.Manifest): string[] { + return manifest.sequences + .flatMap(seq => seq.canvases) + .flatMap(can => can.images) + .flatMap(img => img.resource) + .flatMap(res => (res['@type'] === 'dctypes:Image' ? res.service['@id'] + '/info.json' : [])); + } + + // Given a v3 manifest, extract all of the IIIF images and format as info.json URLs + private extractIiif3ImageUrls(manifest: iiif3.Manifest): string[] { + // Recursively search the '.items' key until we end up with nodes that have type 'ImageService2' + return ( + manifest.items + .flatMap(canvas => canvas.items) + .flatMap(annotationPage => annotationPage.items) + .flatMap(annotation => { + if (annotation.body instanceof Array) { + return annotation.body; + } else { + return [annotation.body]; + } + }) + //@ts-ignore + .flatMap(annotationBody => annotationBody.service) + .flatMap(service => service.id + '/info.json') + ); + } + + // TODO: use navPlace as the bounds source if available + + // Attempt to fetch and parse the IIIF manifest, if any + async fetchManifest(): Promise { + if (!this.iiifManifestUrl) return null; + + try { + const response = await fetch(this.iiifManifestUrl); + if (!response.ok) throw new Error(`Unexpected response fetching IIIF manifest: ${response.statusText}`); + const manifest = await response.json(); + this.iiifManifest = manifest; + return manifest; + } catch (error) { + console.error(error.message); + this.iiifManifest = null; + return null; + } + } } diff --git a/src/utils/sources.ts b/src/utils/sources.ts index 14ed7b7..95b7b6f 100644 --- a/src/utils/sources.ts +++ b/src/utils/sources.ts @@ -97,7 +97,7 @@ const getRecordSource = (record: OgmRecord): AddSourceObject => { const recordXYZSource = (record: OgmRecord): AddSourceObject => { // If no XYZ reference, nothing to do - const xyzUrl = record.references.xyz; + const xyzUrl = record.references.xyzUrl; if (!xyzUrl) return null; return { @@ -115,7 +115,7 @@ const recordXYZSource = (record: OgmRecord): AddSourceObject => { // Given a record, create a MapLibre TMS source, if possible const recordTMSSource = (record: OgmRecord): AddSourceObject => { // If no TMS reference, nothing to do - const tmsUrl = record.references.tms; + const tmsUrl = record.references.tmsUrl; if (!tmsUrl) return null; return { @@ -133,7 +133,7 @@ const recordTMSSource = (record: OgmRecord): AddSourceObject => { // Given a record, create a MapLibre GeoJSON source, if possible const recordGeoJSONSource = (record: OgmRecord): AddSourceObject => { // If no GeoJSON reference, nothing to do - const geojsonUrl = record.references.geojson; + const geojsonUrl = record.references.geojsonUrl; if (!geojsonUrl) return null; // Create a GeoJSON source with the record's ID and attribution @@ -150,7 +150,7 @@ const recordGeoJSONSource = (record: OgmRecord): AddSourceObject => { // Given a record, create a MapLibre COG source, if possible const recordCOGSource = (record: OgmRecord): AddSourceObject => { // If no COG reference, nothing to do - const cogUrl = record.references.cog; + const cogUrl = record.references.cogUrl; if (!cogUrl) return null; // Add the cog:// protocol that will tell MapLibre to use the plugin @@ -170,7 +170,7 @@ const recordCOGSource = (record: OgmRecord): AddSourceObject => { // Given a record, create a MapLibre WMS source, if possible const recordWMSSource = (record: OgmRecord): AddSourceObject => { // If no WMS reference or no WXS layer identifier, nothing we can do - const wmsUrl = record.references.wms; + const wmsUrl = record.references.wmsUrl; if (!wmsUrl) return null; const layerIds = [record.wxsIdentifier]; if (!layerIds[0]) return null;