diff --git a/.gitignore b/.gitignore index ccf21ea..eca042a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ -/asset/vendor/ +/asset/vendor/* +!/asset/vendor/leaflet/ +!/asset/vendor/leaflet-draw/ +!/asset/vendor/leaflet-fullscreen/ +!/asset/vendor/VERSIONS.md /build/ /language/debug.po /language/debug.mo diff --git a/README.md b/README.md index 17696e4..e5c1017 100644 --- a/README.md +++ b/README.md @@ -328,13 +328,75 @@ and markers targets operate in a single SQL statement per batch and scale to large collections; the Cartography target runs through the API and is paced by the job dispatcher. +### Maps + +A `geometry` or `geometric coordinates` value is displayed as a Leaflet map with +the geometry drawn on it, rather than as raw WKT. A `geometric position` is not: +its origin is the top left corner of an image, so it stays text. + +In the resource form, a `geometry` or `geography` value can be drawn instead of +typed. Press **Ctrl+Alt+M** inside the field, or click the button beside it, +**Use geometry editor**. What you draw is written back into that field as WKT +and validated as if it had been typed, so nothing about how the value is stored +changes. One shape per field: drawing a second replaces the first, because a +value that needed `MULTIPOLYGON` would be rejected by this module's own +validation. Circles are not offered — WKT has no way to carry a radius. Closing +the editor without drawing leaves the value exactly as it was. + +Everything the maps need is bundled in `asset/vendor`; nothing is fetched from a +CDN at runtime. Leaflet is loaded lazily in the resource form, and reused if +another module (such as [Mapping]) already put it on the page. + +The maps are configured under the `datatypegeometry` key. The defaults live in +`config/module.config.php` and are overridden from Omeka's +`config/local.config.php`; arrays merge, so naming one key leaves the rest +alone: + +```php +'datatypegeometry' => [ + // Map defaults: height, center, zoom, max_zoom, fit_max_zoom, and the + // Leaflet path style the geometry is drawn in. + 'map' => [ + 'height' => 400, + ], + // Exactly one is active at a time, the first by default. Ships with + // OpenStreetMap so a stock installation draws something. + 'base_layers' => [ + 'osm' => [ + 'label' => 'OpenStreetMap', + 'type' => 'tile', + 'url' => 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + 'options' => ['maxZoom' => 19, 'attribution' => '…'], + ], + ], + // Any number may be switched on, all off until then. Empty by default: + // which historical maps are worth showing belongs to a collection, not to + // this module. + 'extra_layers' => [ + 'hisgis' => [ + 'label' => 'HISGIS minuutplannen', + 'type' => 'tile', + 'url' => 'https://tileserver.huc.knaw.nl/{z}/{x}/{y}', + 'options' => ['minZoom' => 10, 'maxZoom' => 21, 'attribution' => 'Tiles HUC KNAW'], + ], + ], +], +``` + +An entry's `type` is `tile` or `wms`, its `url` is used exactly as written — so +a caching or rewriting proxy in front of a tile server is simply part of the +url — and its `options` are passed straight to Leaflet. + +To draw the same map from a theme template: +`echo $this->geometryMap('POINT (4.7027444 52.0097589)');` + TODO ---- - [x] Remove doctrine:lexer from composer vendor. - [ ] Add a checkbox in resource form to append marker to map of module Mapping or a main option? -- [ ] Add a button "select on map" in resource form to specify coordinates directly. +- [x] Add a button "select on map" in resource form to specify coordinates directly. - [ ] Add a js to convert wkt into svg icon (via geojson/d3 or directly). - [ ] Upgrade terraformer to terraformer.js (need a precompiled js). - [x] Rename api keys to "geometry", "geography", "geography:coordinates" for Omeka S v4. @@ -393,8 +455,9 @@ of the CeCILL license and that you accept its terms. ### Libraries -This module uses many open source leaflet libraries. See `asset/vendor` for -details. +This module bundles Leaflet, Leaflet.draw, leaflet.fullscreen and +@terraformer/wkt. See [asset/vendor/VERSIONS.md](asset/vendor/VERSIONS.md) for +the exact versions, their licences, and how to check a copy against upstream. Copyright diff --git a/asset/css/data-type-geometry.css b/asset/css/data-type-geometry.css index e1f42ea..f2ee457 100644 --- a/asset/css/data-type-geometry.css +++ b/asset/css/data-type-geometry.css @@ -90,6 +90,77 @@ .sidebar #advanced-search .inputs label.type-radio::after { background: initial; } + + /* The map editor. */ + + .geometry-map-open { + margin-top: 6px; + } + + /* Font Awesome 5.15.4 "map-marked-alt", from the solid face Omeka already + loads. The glyph itself is all this rule supplies: the font family and + weight come from core's [class*="o-icon-"]:before, which is why the class + has to keep the o-icon- prefix even though the name is this module's own. + + Deliberately not "draw-polygon" (\f5ee) or "map-marker-alt" (\f3c5): those + two are already the icons for the geometry and geography data types, and + they sit a few pixels away in the same row. */ + .o-icon-map-select:before { + content: "\f5a0"; + } + + .geometry-map-open:before { + margin-right: 6px; + } + + #geometry-map-sidebar .geometry-map-canvas { + width: 100%; + height: 400px; + } + + #geometry-map-sidebar .geometry-map-notice { + margin-bottom: 6px; + } + + #geometry-map-sidebar .geometry-map-actions { + margin-top: 12px; + } + + #geometry-map-sidebar .geometry-map-cancel { + background-color: transparent; + color: inherit; + } + + /* The value being rendered on a public page. */ + .datatype-geometry-map { + max-width: 100%; + } + + /* Leaflet 1.9 composites tiles with `mix-blend-mode: plus-lighter`, to hide + the dark seam a tile would otherwise show while it fades in. It costs + more than it saves here. + + The map sits at an integer zoom, so the tiles are not scaled by Leaflet. + They are still scaled by the browser: at 110% page zoom, or on a display + with fractional scaling, a 256px tile becomes 281.6 device pixels and + neighbouring tiles overlap by a fraction of a pixel. plus-lighter adds + both contributions, and on a base layer as light as OpenStreetMap the sum + clips to white, so every tile edge draws as a white line on a ~281px + grid. Measured on a flat OSM-coloured tile at 1.1: 1006 pure-white pixels + across the map interior with plus-lighter, none with normal. Compositing + normally is never worse at any scale factor tested (1, 1.1, 1.25, 1.5), + and strictly better at three of them. + + Both of this module's maps: the value on a public page, and the editor's + canvas in the sidebar. Specific enough (0,3,1) to beat Leaflet's own + (0,2,1) without depending on stylesheet order, which matters because the + Mapping module may have loaded its Leaflet after ours. Scoped to this + module so other modules' maps keep whatever they chose. Keep it while any + Leaflet on the page sets plus-lighter; see asset/vendor/VERSIONS.md. */ + .datatype-geometry-map.leaflet-container img.leaflet-tile, + .geometry-map-canvas.leaflet-container img.leaflet-tile { + mix-blend-mode: normal; + } } @media screen and (max-width:640px) { @@ -99,3 +170,27 @@ text-align: initial; } } + +/* Wider than Omeka's default 25%: drawing a shape in a narrow column means + panning instead of seeing where the shape is going. + + Core couples the width to the offset — .sidebar is width:25% parked at + left:100%, and .active slides it to left:75% — so the two have to keep adding + up to 100%. Widening one alone pushes the panel's right edge past the + viewport, where .sidebar's own overflow-x:hidden crops it, and what gets + cropped is whatever sits at the right edge of the map: the layer switcher. + Hence left:60% alongside width:40%, and no min-width, which would break the + arithmetic again between 641px and 950px. + + Kept above core's 640px breakpoint. Below it the sidebar is already full + width and positioned differently, and there is nothing to widen. */ +@media screen and (min-width: 641px) { + #geometry-map-sidebar { + width: 40%; + } + + #geometry-map-sidebar.active, + #geometry-map-sidebar.always-open { + left: 60%; + } +} diff --git a/asset/js/data-type-geometry-editor.js b/asset/js/data-type-geometry-editor.js new file mode 100644 index 0000000..5fc6085 --- /dev/null +++ b/asset/js/data-type-geometry-editor.js @@ -0,0 +1,319 @@ +/** + * Draw a geometry on a map instead of typing wkt into the field. + * + * Opened with Ctrl+Alt+M from inside a geometry or geography field, or with the + * "Use geometry editor" button beside it. What is drawn is written back into that same + * field as wkt, and the field's own validation runs on it as if it had been + * typed: this editor is a way of writing into the input, not a second way of + * storing a value. Omeka collects the value on submit by reading + * data-value-key, so there is no form plumbing here at all. + * + * Leaflet is loaded on first use rather than with the page. The Mapping module + * loads its own copy on the same item edit form, a second one would replace + * window.L under it, and the order in which two modules append to headScript is + * not something either can control. Loading late means we can see what is + * already there and take it: Leaflet.draw 1.0.4 works against both the 1.9.3 + * Mapping ships and the 1.9.4 in this module's asset/vendor. + */ +(function ($) { + 'use strict'; + + var config = window.DataTypeGeometryConfig || {}; + var settings = config.map || {}; + var assets = config.assets || {}; + + var FIELDS = 'textarea.value.geometry, textarea.value.geography'; + + var loading = null; + var $sidebar = null; + var map = null; + var drawnFeatures = null; + var $target = null; + // Whether anything was drawn, edited or deleted since the editor opened. An + // untouched editor must not write: reading a value in and writing it back + // out is not a round trip for every geometry, and a cataloguer who opened + // the map to look at a value should not have it rewritten underneath them. + var dirty = false; + + function translate(string) { + return window.Omeka && Omeka.jsTranslate ? Omeka.jsTranslate(string) : string; + } + + function loadCss(url) { + return new Promise(function (resolve, reject) { + var link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = url; + link.onload = resolve; + link.onerror = reject; + document.head.appendChild(link); + }); + } + + function loadJs(url) { + return new Promise(function (resolve, reject) { + var script = document.createElement('script'); + script.src = url; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); + } + + /** + * Load Leaflet and its two plugins, but only the parts that are missing. + * + * Strictly ordered: a plugin registers itself on L, so Leaflet has to be + * there first. The two plugins are independent of each other and load + * together. + */ + function ensureLeaflet() { + if (loading) { + return loading; + } + loading = Promise.resolve() + .then(function () { + if (window.L) { + return null; + } + return Promise.all([loadCss(assets.leafletCss), loadJs(assets.leafletJs)]); + }) + .then(function () { + var wanted = []; + if (!(L.Control && L.Control.Draw)) { + wanted.push(loadCss(assets.leafletDrawCss), loadJs(assets.leafletDrawJs)); + } + if (!(L.Control && L.Control.FullScreen)) { + wanted.push(loadCss(assets.fullscreenCss), loadJs(assets.fullscreenJs)); + } + return wanted.length ? Promise.all(wanted) : null; + }); + return loading; + } + + function buildLayer(spec) { + var options = spec.options || {}; + return spec.type === 'wms' + ? L.tileLayer.wms(spec.url, options) + : L.tileLayer(spec.url, options); + } + + /** The same base layers and overlays the public map uses. */ + function addLayers(theMap) { + var bases = {}; + var overlays = {}; + var first = true; + + Object.keys(config.baseLayers || {}).forEach(function (key) { + var spec = config.baseLayers[key]; + var layer = buildLayer(spec); + bases[spec.label || key] = layer; + if (first) { + layer.addTo(theMap); + first = false; + } + }); + Object.keys(config.extraLayers || {}).forEach(function (key) { + var spec = config.extraLayers[key]; + overlays[spec.label || key] = buildLayer(spec); + }); + + if (Object.keys(bases).length > 1 || Object.keys(overlays).length) { + L.control.layers(bases, overlays).addTo(theMap); + } + } + + function buildSidebar() { + if ($sidebar) { + return $sidebar; + } + $sidebar = $( + '' + ); + $sidebar.find('.geometry-map-title').text(translate('Geometry editor')); + $sidebar.find('.geometry-map-apply').text(translate('Apply')); + $sidebar.find('.geometry-map-cancel').text(translate('Cancel')); + // Inside #content so that Omeka's own delegated handler closes it. + $('#content').append($sidebar); + return $sidebar; + } + + function notice(message) { + var $notice = $sidebar.find('.geometry-map-notice'); + if (message) { + $notice.text(message).prop('hidden', false); + } else { + $notice.text('').prop('hidden', true); + } + } + + function buildMap() { + if (map) { + return map; + } + map = L.map($sidebar.find('.geometry-map-canvas')[0], { + center: settings.center || [0, 0], + zoom: settings.zoom || 16, + maxZoom: settings.max_zoom || 21, + // The sidebar is a narrow column, and drawing a large shape in it + // means panning rather than seeing the shape. Same control as the + // public map, registered by Control.FullScreen.umd.js. + fullscreenControl: true + }); + addLayers(map); + + drawnFeatures = new L.FeatureGroup(); + map.addLayer(drawnFeatures); + + map.addControl(new L.Control.Draw({ + draw: { + marker: true, + polyline: true, + polygon: true, + // A rectangle is a polygon, so it survives the round trip. + rectangle: true, + // Circles do not exist in wkt: they are a centre and a radius, + // and nothing would carry the radius. + circle: false, + circlemarker: false + }, + edit: {featureGroup: drawnFeatures} + })); + + // One shape per field. The field holds a single value, and a second + // shape would have to be written as MULTIPOINT, MULTILINESTRING or + // MULTIPOLYGON, which this module's own validator rejects. + map.on('draw:created', function (e) { + drawnFeatures.clearLayers(); + drawnFeatures.addLayer(e.layer); + dirty = true; + }); + map.on('draw:edited', function () { + dirty = true; + }); + map.on('draw:deleted', function () { + dirty = true; + }); + + return map; + } + + /** Put the field's current value on the map, if it can be read. */ + function seed() { + drawnFeatures.clearLayers(); + dirty = false; + notice(''); + + var wkt = $.trim($target.val()); + if (!wkt) { + map.setView(settings.center || [0, 0], settings.zoom || 16); + return; + } + + var geometry; + try { + geometry = Terraformer.wktToGeoJSON(wkt); + } catch (e) { + notice(translate('The current value is not a geometry this editor can read. Drawing will replace it.')); + return; + } + + L.geoJSON(geometry, {style: settings.style || {}}).eachLayer(function (layer) { + drawnFeatures.addLayer(layer); + }); + + if (!drawnFeatures.getLayers().length) { + return; + } + // A collection comes in as several layers but can only go back out as + // one, so say so rather than truncating it silently on apply. + if (drawnFeatures.getLayers().length > 1) { + notice(translate('The current value is not a geometry this editor can read. Drawing will replace it.')); + } + map.fitBounds(drawnFeatures.getBounds(), {maxZoom: settings.fit_max_zoom || 19}); + } + + function close() { + Omeka.closeSidebar($sidebar); + } + + function apply() { + // Nothing was touched, so leave the value exactly as it was found. + if (dirty) { + var features = drawnFeatures.toGeoJSON().features; + var wkt = features.length ? Terraformer.geojsonToWKT(features[0].geometry) : ''; + // The change is what re-runs the field's validation: setting a value + // from script fires no event by itself. + $target.val(wkt).trigger('change'); + } + close(); + } + + function openEditor($field) { + if (!$field || !$field.length) { + return; + } + $target = $field.first(); + + buildSidebar(); + ensureLeaflet() + .then(function () { + buildMap(); + Omeka.openSidebar($sidebar); + // Leaflet measured a container that was still off-screen. + map.invalidateSize(); + seed(); + }) + .catch(function (e) { + console.error('DataTypeGeometry: could not load the map', e); + window.alert(translate('The map could not be loaded.')); + }); + } + + $(document).on('keydown', FIELDS, function (e) { + if (!e.ctrlKey || !e.altKey || !e.key) { + return; + } + if (e.key.toLowerCase() !== 'm') { + return; + } + e.preventDefault(); + openEditor($(this)); + }); + + $(document).on('click', '.geometry-map-open', function (e) { + e.preventDefault(); + // Scoped to this value row, so the button edits its own field rather + // than the first one on the page. Rows are cloned at runtime, which is + // why every handler here is delegated. + // + // Anchored on .input-body, the wrapper Omeka puts around a data type's + // own markup, rather than on .value: the field carries that class too + // ("value to-require geometry"), so .value is ambiguous the moment + // anything looks for it from inside the field rather than from the + // button beside it. + var $row = $(this).closest('.input-body'); + openEditor(($row.length ? $row : $(this).closest('.value')).find(FIELDS)); + }); + + $(document).on('click', '.geometry-map-apply', apply); + $(document).on('click', '.geometry-map-cancel', function () { + close(); + }); + + $(document).on('keydown', function (e) { + if (e.key === 'Escape' && $sidebar && $sidebar.hasClass('active')) { + close(); + } + }); +})(jQuery); diff --git a/asset/js/data-type-geometry-map.js b/asset/js/data-type-geometry-map.js new file mode 100644 index 0000000..6ce4d91 --- /dev/null +++ b/asset/js/data-type-geometry-map.js @@ -0,0 +1,96 @@ +/** + * Draws the geometry described by each [data-geometry-map] element. + * + * Everything is read from the element's data attribute, so nothing here is tied + * to a particular element id and any number of maps can share a page. The + * previous implementation bound to a hardcoded id="map" and ran at parse time, + * which meant a resource with two geometries drew only the first. + */ +(function () { + 'use strict'; + + /** Build a Leaflet layer from a configured entry. */ + function buildLayer(spec) { + var options = spec.options || {}; + return spec.type === 'wms' + ? L.tileLayer.wms(spec.url, options) + : L.tileLayer(spec.url, options); + } + + /** + * Add the base layers and overlays, and a switcher if there is a choice. + * + * The first base layer is the active one. Overlays all start off: they are + * historical maps and aerial photography, and the point of the map is the + * geometry, not what happens to be underneath it. + */ + function addLayers(map, config) { + var bases = {}; + var overlays = {}; + var first = true; + + Object.keys(config.baseLayers || {}).forEach(function (key) { + var spec = config.baseLayers[key]; + var layer = buildLayer(spec); + bases[spec.label || key] = layer; + if (first) { + layer.addTo(map); + first = false; + } + }); + + Object.keys(config.extraLayers || {}).forEach(function (key) { + var spec = config.extraLayers[key]; + overlays[spec.label || key] = buildLayer(spec); + }); + + if (Object.keys(bases).length > 1 || Object.keys(overlays).length) { + L.control.layers(bases, overlays).addTo(map); + } + } + + function drawMap(element) { + var config; + try { + config = JSON.parse(element.getAttribute('data-geometry-map')); + } catch (e) { + console.error('DataTypeGeometry: unreadable map configuration', e); + return; + } + + var settings = config.map || {}; + var map = L.map(element, { + center: settings.center || [0, 0], + zoom: settings.zoom || 16, + maxZoom: settings.max_zoom || 21, + // Registered by Control.FullScreen.umd.js. Loading only the plugin's + // stylesheet, as this module used to, styles a button that the map + // never creates, and this option is then silently ignored. + fullscreenControl: true + }); + + addLayers(map, config); + + if (!config.wkt) { + return; + } + + try { + var layer = L.geoJSON({ + type: 'Feature', + geometry: Terraformer.wktToGeoJSON(config.wkt) + }, { + style: settings.style || {} + }).addTo(map); + // Overrides the configured centre and zoom whenever there is a + // geometry, which is why those are only a fallback. + map.fitBounds(layer.getBounds(), {maxZoom: settings.fit_max_zoom || 19}); + } catch (e) { + console.error('DataTypeGeometry: unreadable wkt "' + config.wkt + '"', e); + } + } + + document.addEventListener('DOMContentLoaded', function () { + document.querySelectorAll('[data-geometry-map]').forEach(drawMap); + }); +})(); diff --git a/asset/vendor/VERSIONS.md b/asset/vendor/VERSIONS.md new file mode 100644 index 0000000..8d166c3 --- /dev/null +++ b/asset/vendor/VERSIONS.md @@ -0,0 +1,85 @@ +# Bundled libraries + +Nothing here is fetched from a CDN at runtime, so these copies are what the module +actually runs. Recorded because a file on disk cannot otherwise be matched against an +advisory: two of the four say nothing about their own version. + +| Directory | Library | Version | Licence | Upstream | +|---|---|---|---|---| +| `leaflet/` | Leaflet | 1.9.4 | BSD-2-Clause | | +| `leaflet-draw/` | Leaflet.draw | 1.0.4 | MIT | | +| `leaflet-fullscreen/` | leaflet.fullscreen | 5.3.3 | MIT | | +| `terraformer-wkt/` | @terraformer/wkt | 2.2.1 | MIT | | + +All four are at their current release, except @terraformer/wkt, which is one patch behind +(2.2.2). Leaflet 2.0.0 exists only as an alpha and is not a candidate. + +**terraformer-wkt is the odd one out: it is not committed.** It is fetched at install time +by `sempia/external-assets`, from the pin in `composer.json` under +`extra.external-assets`, which is why it is the only entry here that `.gitignore` still +excludes. Change the version in `composer.json`, not here. + +**leaflet.fullscreen ships the UMD build deliberately.** From 5.0.0 its +`dist/Control.FullScreen.js` is an ES module; loaded in a plain ` + + + + + + + + + + + +
+
+
+ + +
+
+ +
+
+ +

+
+
+
+
+
+
+
+
+
diff --git a/tests/browser/editor.html b/tests/browser/editor.html
new file mode 100644
index 0000000..de45fc1
--- /dev/null
+++ b/tests/browser/editor.html
@@ -0,0 +1,212 @@
+
+
+
+
+editor harness
+
+
+
+
+
+
+
+
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +

+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/browser/sidebar-layout.html b/tests/browser/sidebar-layout.html
new file mode 100644
index 0000000..e134125
--- /dev/null
+++ b/tests/browser/sidebar-layout.html
@@ -0,0 +1,177 @@
+
+
+
+
+sidebar layout harness
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+ +

+
+
+
+
+
+
+
+
+
diff --git a/tests/browser/tile-seams.html b/tests/browser/tile-seams.html
new file mode 100644
index 0000000..d71dbbe
--- /dev/null
+++ b/tests/browser/tile-seams.html
@@ -0,0 +1,101 @@
+
+
+
+
+tile seam harness
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ +
+ +

+
+
+
+
+
+
+
+
diff --git a/tests/verify-wiring.php b/tests/verify-wiring.php
new file mode 100644
index 0000000..218ddcb
--- /dev/null
+++ b/tests/verify-wiring.php
@@ -0,0 +1,290 @@
+getMessage()));
+    }
+}
+
+function section($title)
+{
+    printf("\n%s\n", $title);
+}
+
+// -------------------------------------------------------------------- boot
+
+require $omekaPath . '/bootstrap.php';
+
+$application = Omeka\Mvc\Application::init(
+    require $omekaPath . '/application/config/application.config.php'
+);
+$services = $application->getServiceManager();
+
+printf("DataTypeGeometry map wiring check\n    omeka  %s\n    module %s\n", $omekaPath, $modulePath);
+
+// ------------------------------------------------------------------ config
+
+section('Configuration');
+
+$config = $services->get('Config')['datatypegeometry'] ?? null;
+
+checking('the datatypegeometry config is merged in', function () use ($config) {
+    return is_array($config) ?: 'no "datatypegeometry" key in the merged config';
+});
+
+checking('the module\'s own settings survived the site override', function () use ($config) {
+    // A local.config.php that replaced this key instead of merging into it
+    // would take the module's existing settings with it.
+    return isset($config['config']['datatypegeometry_locate_srid'])
+        ?: 'the pre-existing "config" sub-key is gone';
+});
+
+checking('a base layer ships, so a stock install draws something', function () use ($config) {
+    return !empty($config['base_layers']) ?: 'base_layers is empty';
+});
+
+checking('the OpenStreetMap default survived the site override', function () use ($config) {
+    return isset($config['base_layers']['osm']['url'])
+        ?: 'base_layers.osm is gone: an override replaced the catalogue rather than adding to it';
+});
+
+checking('the map defaults are complete', function () use ($config) {
+    $missing = array_diff(
+        ['height', 'center', 'zoom', 'max_zoom', 'fit_max_zoom', 'style'],
+        array_keys($config['map'] ?? [])
+    );
+    return $missing ? 'missing: ' . implode(', ', $missing) : true;
+});
+
+checking('every layer entry has a label and a url', function () use ($config) {
+    $problems = [];
+    foreach (['base_layers', 'extra_layers'] as $catalogue) {
+        foreach ($config[$catalogue] ?? [] as $id => $entry) {
+            if (empty($entry['label']) || empty($entry['url'])) {
+                $problems[] = sprintf('%s.%s', $catalogue, $id);
+            }
+        }
+    }
+    return $problems ? implode(', ', $problems) . ' incomplete' : true;
+});
+
+checking('every layer type is one Leaflet can build', function () use ($config) {
+    $problems = [];
+    foreach (['base_layers', 'extra_layers'] as $catalogue) {
+        foreach ($config[$catalogue] ?? [] as $id => $entry) {
+            if (!in_array($entry['type'] ?? '', ['tile', 'wms'], true)) {
+                $problems[] = sprintf('%s.%s is "%s"', $catalogue, $id, $entry['type'] ?? '');
+            }
+        }
+    }
+    return $problems ? implode(', ', $problems) . ', expected tile or wms' : true;
+});
+
+// ------------------------------------------------------------------ assets
+
+section('Bundled assets');
+
+$assets = [
+    'vendor/leaflet/leaflet.js',
+    'vendor/leaflet/leaflet.css',
+    // Leaflet's css asks for these by relative path; a file-by-file copy of the
+    // library leaves them behind and every marker turns into a broken image.
+    'vendor/leaflet/images/marker-icon.png',
+    'vendor/leaflet/images/marker-shadow.png',
+    'vendor/leaflet/images/layers.png',
+    'vendor/leaflet-draw/leaflet.draw.js',
+    'vendor/leaflet-draw/leaflet.draw.css',
+    'vendor/leaflet-draw/images/spritesheet.png',
+    'vendor/leaflet-draw/images/spritesheet.svg',
+    // Not Control.FullScreen.js: since 5.0.0 that one is an es module.
+    'vendor/leaflet-fullscreen/Control.FullScreen.umd.js',
+    'vendor/leaflet-fullscreen/Control.FullScreen.css',
+    'vendor/terraformer-wkt/t-wkt.umd-2.2.1.js',
+    'js/data-type-geometry-map.js',
+    'js/data-type-geometry-editor.js',
+    'css/data-type-geometry.css',
+];
+
+foreach ($assets as $asset) {
+    checking(sprintf('asset/%s is shipped', $asset), function () use ($modulePath, $asset) {
+        return is_file($modulePath . '/asset/' . $asset) ?: 'not on disk';
+    });
+}
+
+checking('the vendored libraries are recorded', function () use ($modulePath) {
+    return is_file($modulePath . '/asset/vendor/VERSIONS.md')
+        ?: 'asset/vendor/VERSIONS.md is missing: a file on disk cannot be matched against an advisory without it';
+});
+
+checking('nothing reaches into Omeka\'s files directory any more', function () use ($modulePath) {
+    // The maps used to be assembled from /omeka/files/js/, which is derivative
+    // territory rather than code, and is shared with pages this module cannot
+    // see. Those files are still in use elsewhere and must stay; the point is
+    // that the module no longer depends on them.
+    $found = [];
+    $directory = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($modulePath . '/src'));
+    foreach ($directory as $file) {
+        if ($file->isFile() && 'php' === $file->getExtension()
+            && false !== strpos((string) file_get_contents($file->getPathname()), '/files/js/')
+        ) {
+            $found[] = $file->getPathname();
+        }
+    }
+    return $found ? implode(', ', $found) : true;
+});
+
+// ------------------------------------------------------------------- helper
+
+section('View helper');
+
+$helpers = $services->get('ViewHelperManager');
+
+checking('geometryMap resolves from the helper manager', function () use ($helpers) {
+    $helper = $helpers->get('geometryMap');
+    return $helper instanceof \DataTypeGeometry\View\Helper\GeometryMap
+        ?: sprintf('got %s', get_class($helper));
+});
+
+checking('it renders a value as a map element carrying its own configuration', function () use ($helpers) {
+    $markup = $helpers->get('geometryMap')->__invoke('POINT (4.7027444 52.0097589)');
+    if (false === strpos($markup, 'data-geometry-map=')) {
+        return 'no data-geometry-map attribute: ' . $markup;
+    }
+    // No id: a resource with two geometries has to draw two maps, which is
+    // exactly what the previous id="map" implementation could not do.
+    if (false !== strpos($markup, 'id=')) {
+        return 'the element has an id, so a second geometry on the page would collide';
+    }
+    return true;
+});
+
+checking('the rendered configuration carries the layers and the value', function () use ($helpers) {
+    $markup = $helpers->get('geometryMap')->__invoke('POINT (4.7027444 52.0097589)');
+    if (!preg_match('~data-geometry-map="([^"]*)"~', $markup, $matches)) {
+        return 'could not read the attribute back';
+    }
+    $decoded = json_decode(html_entity_decode($matches[1], ENT_QUOTES), true);
+    if (!is_array($decoded)) {
+        return 'the attribute is not valid json: ' . json_last_error_msg();
+    }
+    foreach (['map', 'baseLayers', 'wkt'] as $key) {
+        if (!isset($decoded[$key])) {
+            return sprintf('no "%s" in the rendered configuration', $key);
+        }
+    }
+    return 'POINT (4.7027444 52.0097589)' === $decoded['wkt']
+        ?: 'the wkt did not survive: ' . var_export($decoded['wkt'], true);
+});
+
+checking('a value containing a quote cannot break out of the attribute', function () use ($helpers) {
+    $markup = $helpers->get('geometryMap')->__invoke('POINT (1 1)" onload="alert(1)');
+    return false === strpos($markup, 'onload="alert(1)"')
+        ?: 'the value escaped its attribute: ' . $markup;
+});
+
+// --------------------------------------------------------------- data types
+
+section('Data types');
+
+$dataTypes = $services->get('Omeka\DataTypeManager');
+
+checking('geometry renders as a map', function () use ($dataTypes) {
+    $method = new ReflectionMethod($dataTypes->get('geometry'), 'render');
+    return \DataTypeGeometry\DataType\Geometry::class === $method->getDeclaringClass()->getName()
+        ?: 'render() comes from ' . $method->getDeclaringClass()->getName();
+});
+
+checking('geometric coordinates render as a map, not as an unreadable string', function () use ($dataTypes) {
+    // This type stores "x,y" rather than wkt, so it needs its own render() to
+    // convert; the one inherited from Geometry would hand the map a string no
+    // wkt parser accepts.
+    $method = new ReflectionMethod($dataTypes->get('geometry:coordinates'), 'render');
+    return \DataTypeGeometry\DataType\GeometryCoordinates::class === $method->getDeclaringClass()->getName()
+        ?: 'render() comes from ' . $method->getDeclaringClass()->getName();
+});
+
+checking('geometric position does NOT inherit the map', function () use ($dataTypes) {
+    // Its origin is the top left corner of an image, so "4,52" means four
+    // pixels across and fifty-two down. Drawn on a world map it lands in the
+    // Gulf of Guinea.
+    $method = new ReflectionMethod($dataTypes->get('geometry:position'), 'render');
+    return \DataTypeGeometry\DataType\GeometryPosition::class === $method->getDeclaringClass()->getName()
+        ?: 'render() comes from ' . $method->getDeclaringClass()->getName() . ', which draws a map';
+});
+
+// ------------------------------------------------------------- translations
+
+section('Translatable strings');
+
+checking('the editor\'s strings are in js_translate_strings', function () use ($services) {
+    $strings = $services->get('Config')['js_translate_strings'] ?? [];
+    $missing = array_diff(
+        ['Use geometry editor', 'Geometry editor', 'Apply', 'Cancel', 'The map could not be loaded.'],
+        $strings
+    );
+    return $missing ? 'missing: ' . implode(', ', $missing) : true;
+});
+
+// ------------------------------------------------------------------ verdict
+
+printf("\n%d checks, %d failures\n", $checks, $failures);
+exit($failures ? 1 : 0);