Skip to content

Repository files navigation

CI Built With Stencil

OpenGeoMetadata Viewer

A web-based viewer for previewing OpenGeoMetadata records. Try the online demo!

Installation

You can add the viewer to your project by including the following script tag in your HTML:

<script type="module" src="https://unpkg.com/ogm-viewer"></script>

If using a bundler, you can install it via npm:

npm install ogm-viewer

Then add it to your entrypoint file:

import 'ogm-viewer';

That one import is all of it, whichever way you installed it: the theme and the icons the viewer's own chrome is drawn with are part of the bundle, so there is no stylesheet to import beside it, nothing to copy into your app's assets, and no bundler configuration to add.

Usage

Once installed, the viewer can be used in your HTML as a web component:

<ogm-viewer record-url="https://example.com/record.json"></ogm-viewer>

The record-url attribute should point to a valid OpenGeoMetadata Aardvark record in JSON format.

You can also programmatically set the record URL using JavaScript:

const viewer = document.querySelector('ogm-viewer');
viewer.recordUrl = 'https://example.com/record.json';

When the record URL changes, the viewer will automatically fetch and display the record data.

Dark mode support

The viewer supports dark mode. If your system preference is set to prefer dark mode, the viewer will automatically apply dark styles.

To programmatically control dark mode, you can use the theme attribute with a value of dark or light:

<ogm-viewer record-url="https://example.com/record.json" theme="dark"></ogm-viewer>

Basemaps

By default, the viewer draws on one of CARTO's basemaps: Dark Matter in dark mode, Positron in light mode. To use a different one for either mode, set dark-basemap and/or light-basemap to a URL for a MapLibre style document, from CARTO or anywhere else:

<ogm-viewer record-url="https://example.com/record.json" light-basemap="https://api.maptiler.com/maps/basic-v2/style.json?key=your-key"></ogm-viewer>

CARTO watermarks a basemap requested without an API key. If you have your own CARTO API key, build the full style URL yourself and pass that:

<ogm-viewer
  record-url="https://example.com/record.json"
  dark-basemap="https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json?key=your-key"
  light-basemap="https://basemaps.cartocdn.com/gl/positron-gl-style/style.json?key=your-key"
></ogm-viewer>

You can find basemaps that don't require an API key to use offered by OpenFreeMap.

Both attributes are also DOM properties, so they can be set from JavaScript the same way theme can. They're supported everywhere theme is - <ogm-viewer>, <ogm-preview>, <ogm-previews>, <ogm-map>, <ogm-locator>, and <ogm-overview> - and GeoBlacklight uses them to let a site configure its own basemaps.

Colors

You can style the viewer's colors by setting CSS custom properties on its element.

ogm-viewer {
  --ogm-data-color: #8f1414;
}

Here are the supported properties and what they apply to:

Property Applies to
--ogm-data-color Polygon fill, line geometry, and circle fill
--ogm-highlight-color The same, for a hovered feature
--ogm-selected-color The same, for the feature whose attributes are shown
--ogm-invalid-color The same, for a feature marked unavailable
--ogm-marker-color Disc a numbered result's marker is drawn on
--ogm-marker-highlight-color The same, for the highlighted result
--ogm-stroke-color Polygon outlines and circle borders
--ogm-stroke-highlight-color Outline of a hovered feature
--ogm-stroke-selected-color Outline of the selected feature
--ogm-stroke-invalid-color Outline of a feature marked unavailable
--ogm-text-color Feature label text color
--ogm-text-halo-color Feature label text outline color
--ogm-text-size Feature label font size, in pixels
--ogm-font-family Feature label font name (e.g. "Noto Sans Regular")
--ogm-marker-font CSS font stack for a numbered marker's numeral
--ogm-data-opacity Initial opacity of drawn data
--ogm-highlight-opacity Opacity of a highlighted feature
--ogm-bounds-opacity Initial opacity of a bounding box or index map geometry
--ogm-padding Gap kept between the data and the view edge (pixels)
--ogm-overview-padding Gap for locator and overview maps (pixels)

By default, the viewer uses styles from Web Awesome that match the current mode (dark or light).

You usually only need the four --ogm-*-color properties, plus --ogm-text-color. The rest are derived:

  • Each outline comes from the color it outlines, moved away from the basemap: darker in light mode, lighter in dark mode. A color you name is used in both modes, but its outline follows the mode, so one declaration reads on either basemap.
  • The label halo comes from --ogm-text-color, as black or white — whichever contrasts more, the same choice CSS contrast-color() makes. It doesn't consult the mode, because the text color already did.
  • The disc a numbered result sits on comes from the same color, sunk to a fixed depth rather than moved by a step, so the numeral on it is legible on either basemap. The numeral's own color follows from the disc, the same way a halo follows from its text.

A numbered marker is drawn as one image — a disc with its numeral on it — rather than as a circle with text over it. That's what keeps every marker the same size at every zoom, keeps a numeral from ever landing on the marker next to it, and lets the numerals be bold: --ogm-font-family names a glyph set the basemap has to serve, while --ogm-marker-font is an ordinary CSS stack this library draws with itself.

--ogm-marker-*, --ogm-stroke-* and --ogm-text-halo-color are there if you want particular ones instead. Like the other colors, one you name is used in both modes.

Restricted content

For previews of data that need authentication to access, you can set a custom requestTransform function to add headers or cookies to the request. It's a DOM property on <ogm-viewer> that you can set in JavaScript, like the recordUrl property:

viewer.requestTransform = (url, resourceType) => {
  // If we aren't requesting something from the restricted area, don't do anything
  if (!url.startsWith('https://geo.my-domain.edu/restricted/')) return undefined;

  // Otherwise, add an Authorization header with a bearer token
  return { headers: { Authorization: `Bearer ${token}` } };
};

If you're building a Resource by hand instead, pass the same kind of function as its last constructor argument (or to resourcesFor, if you're building several from a record):

import { GeoJsonResource } from 'ogm-viewer/lib';

const resource = new GeoJsonResource('my-layer', 'https://example.com/restricted/data.json', undefined, requestTransform);

The requestTransform will be applied to all requests made by the viewer for that resource, including metadata and tiles, as well as the requests for the MapLibre basemap. The one exception to this is Georeferenced maps using the Allmaps plugin – there's currently no way to fetch these using authentication (see below for more).

Georeferenced maps

A scanned map with a IIIF Georeference Annotation is previewable two ways: as an image to page through, and as a layer warped onto the map. Both come from one IIIFManifestResource, so <ogm-viewer> shows them as two tabs, image first.

The viewer finds the annotation itself, looking in this order:

  1. Inside the manifest, following the annotation pages a canvas links until it finds one.
  2. Failing that, a dct_references_s key of https://iiif.io/api/extension/georef/1/context.json pointing at a standalone annotation.

When a record has both, the copy in the manifest wins. Only the first canvas is inspected, so a paged object with an annotation per page is left alone for now.

The map tab is drawn flat, has no globe button, and can't be tilted. These are constraints based on Allmaps' rendering engine, which is used to warp the image. There's also no way to hook into Allmaps' tile requests, so if the annotation points at a restricted image, it won't be able to fetch it. The viewer will still show the image tab, but the map tab will be blank.

To build a preview by hand, the manifest resource takes the standalone annotation URL as its last argument, and works out the rest:

import { GeoreferencePreviewer, IIIFManifestResource } from 'ogm-viewer/lib';

const resource = new IIIFManifestResource('my-map', manifestUrl, undefined, undefined, annotationUrl);
if (await resource.isGeoreferenced()) {
  document.querySelector('ogm-preview').previewer = new GeoreferencePreviewer(resource);
}

Components

If you're building your own viewer, you can adopt <ogm-viewer>'s components individually.

The easiest way to render a single preview without the full viewer is to use the <ogm-preview> component with a Previewer and corresponding Resource. For example, to preview a GeoJSON resource:

import 'ogm-viewer';
import { GeoJsonPreviewer, GeoJsonResource } from 'ogm-viewer/lib';

await customElements.whenDefined('ogm-preview');

const resource = new GeoJsonResource('my-layer', 'https://example.com/data.json');
document.querySelector('ogm-preview').previewer = new GeoJsonPreviewer(resource);

Note that previewer is a DOM property, not an attribute — await for the element to be defined and then set it in JavaScript.

For more than one preview, <ogm-previews> renders the same tab strip <ogm-viewer> uses. Hand it a record and it works out what that record offers; hand it previewers and it uses those instead:

document.querySelector('ogm-previews').previewers = [new GeoJsonPreviewer(geoJsonResource), new OpenIndexMapPreviewer(indexMapResource)];

record and previewers are DOM properties too. Neither component has an intrinsic size, so the embedding page should set it via CSS.

Field definitions

When a record has a reference to an FGDC or ISO 19110 metadata document describing its data's fields, the attribute table names each field from it and reads coded values as what they stand for - so a road whose access is 1 reads "Unrestricted public access (1)", and hovering or tabbing to the field's name explains what the field itself holds. The sidebar also gets a "Fields" tab listing every documented field.

Cooperative gestures

<ogm-preview>, <ogm-locator>, and <ogm-overview> all require a reader to hold Ctrl (⌘ on a Mac) to zoom the map with the scroll wheel, and a second finger to pan it on a touchscreen, so that a map sitting inside a page doesn't steal the scroll a reader meant for the page around it. Set cooperative-gestures to false on any of them to turn this off and answer to the wheel and a single touch right away:

<ogm-locator record-url="https://example.com/record.json" cooperative-gestures="false"></ogm-locator>

Locator maps

To show where a single record is on earth, you can use the <ogm-locator> component. Give it a record-url and it fetches and draws that record's full geometry (locn_geometry) or its bounding box (dcat_bbox) if there's no geometry available - the same URL and format <ogm-viewer> takes:

<ogm-locator record-url="https://example.com/record.json"></ogm-locator>

If you already have an OgmRecord, hand it over directly instead:

await customElements.whenDefined('ogm-locator');

document.querySelector('ogm-locator').record = record;

record is a DOM property, not an attribute; recordUrl is both, so either can be set from HTML or JavaScript. If you've built a LocationPreviewer yourself, set previewer instead to override them.

Like the other components, <ogm-locator> has no intrinsic size, so the embedding page should give it one:

ogm-locator {
  height: 250px;
}

Overviews and geosearch

For showing where several records are and searching/filtering them, you can use the <ogm-overview> component. Hand it a list of OgmRecords as records and it draws a numbered marker for each one, in the order you gave them.

await customElements.whenDefined('ogm-overview');

document.querySelector('ogm-overview').records = [record1, record2];

Nothing of a record is drawn but its number, at the middle of its extent. A record with no geometry still takes up its number, so the numbers keep matching the rows in the list beside the map. Hand it a list of LocationPreviewers you built and those are numbered instead of the records (GeoBlacklight does this).

A user's pointer over a number highlights that record and draws its geometry or bounding box temporarily. The highlightChange event says which record it is, so you can use this information to update other parts of the page.

overview.addEventListener('highlightChange', event => markRow(event.detail?.place));

The event detail carries the result's place in the list (counted from one) and the id of the record it came from and is null once the pointer has left every number.

To highlight a record explicitly, set highlighted to either its number in the list (counted from one), or the id of the record it came from. Anything else will clear the current highlight. Setting highlighted to explicitly highlight a record doesn't fire highlightChange, so you won't get an infinite loop.

overview.highlighted = record.id; // or 3, or undefined to clear it

Enable geosearch and a reader can search the map by holding shift and dragging a box over it. The area they drew is reported through the boundsChange event. The help text in the viewer about how to search can be controlled with searchHelpText.

<ogm-overview geosearch></ogm-overview>
document.querySelector('ogm-overview').addEventListener('boundsChange', event => {
  // west, south, east, north - with east numerically west of west for a box crossing the antimeridian
  console.log('New bounds:', event.detail);
});

Set search-bounds to the area a search is currently filtered to. It's drawn as a box and the camera frames it, with the same gap everything else on the map gets. It will accept an ENVELOPE string, or anything else MapLibre reads as bounds — and being a string, it can come from an attribute:

<ogm-overview search-bounds="ENVELOPE(-124.41,-114.13,42.01,32.53)"></ogm-overview>

Set view-bounds to where the map should open in place of the whole world, when there's no active search and no results of its own to frame - a catalog's home page, say. Given in the same form as search-bounds:

<ogm-overview view-bounds="ENVELOPE(-124.41,-114.13,42.01,32.53)"></ogm-overview>

Content Security Policy

The viewer runs work off the main thread in web workers: MapLibre's own, and one pool that decodes Cloud Optimized GeoTIFF tiles. Both are built from source the bundle carries rather than from files of their own, so a page that sets a Content-Security-Policy needs blob: in worker-src - or in whichever of child-src, script-src or default-src it falls back to. In a document with an opaque origin, such as a sandboxed iframe, the decoder uses a data: URL instead, which a policy would have to allow in the same place.

Without either, the map cannot be drawn at all, and COG tiles are decoded on the main thread: slower, but still drawn.

Decoding those tiles needs 'wasm-unsafe-eval' in script-src as well, wherever it happens: the LZW and LERC decoders are WebAssembly modules, and a policy that won't instantiate one fails the preview rather than slowing it down - the alert in place of the map quotes the directive that stopped it.

The icons the viewer's chrome is drawn with are carried the same way, as data URLs, and Web Awesome draws an icon by fetching whichever URL it is handed - so connect-src needs data: as well, or every icon comes out blank. LERC's WebAssembly module is compiled in as a data URL for the same reason and reached the same way, so it needs nothing further. The theme asks for nothing at all: it is adopted as a constructed stylesheet, which no CSP directive covers.

Development

After cloning the repository, install dependencies:

npm install

You can start a local development web server with:

npm start

Formatting

Code is formatted using Prettier. To format your code for a pull request, run:

npx prettier --write .

To type-check and lint your code, run:

npm run lint

Tests

You can run all tests together or specify a test type:

npm test                  # runs all tests
npm run test:unit         # runs only unit tests
npm run test:component    # runs only component tests

Unit tests use the *.test.ts extension, while component tests use *.test.tsx.

For more information on testing, see the Stencil documentation.

Releasing

Pushing a version tag publishes it. Update the version in package.json, run npm install so the lockfile agrees, and commit that on main. Then tag it:

git tag vX.Y.Z # replace with your new version number
git push origin vX.Y.Z

The Release workflow checks that the tag and package.json agree, lints, tests, publishes to npm, and drafts the GitHub release with generated notes. It authenticates with npm over OIDC using trusted publishing.

About

A web-based viewer for OpenGeoMetadata records

Resources

Stars

4 stars

Watchers

2 watching

Forks

Releases

Used by

Contributors

Languages