From 5f960f844dba18ef09f10fa7d0fc2265513b37d8 Mon Sep 17 00:00:00 2001 From: coret Date: Wed, 12 Aug 2026 12:04:40 +0200 Subject: [PATCH 1/7] Draw a geometry value on a map, and let one be drawn in the resource form A geometry value rendered as its raw wkt, and entering one meant typing wkt into a textarea. Both now use a Leaflet map, built entirely from assets inside the module. Rendering. A geometry or geometric-coordinates value is displayed as a map with the geometry drawn on it. Geometric position is not: its origin is the top left corner of an image, so "4,52" means four pixels across and fifty-two down, and on a world map that lands in the Gulf of Guinea. Geometric coordinates store "x,y" rather than wkt, so that type converts through its existing getGeometryPoint() before handing anything to the map. Configuration travels in a data attribute rather than an inline script, so a resource with several geometries draws several maps and none of them needs a known id. Editing. Ctrl+Alt+M inside a geometry or geography field, or the "Select on map" button beside it, opens a map in Omeka's sidebar. What is drawn is written back into that same field as wkt and re-validated as if it had been typed, so nothing about how a value is stored changes. One shape per field: a second would have to be written as MULTIPOLYGON and friends, which this module's own validation rejects. Circles are not offered, having no wkt representation. An editor closed without drawing writes nothing, because reading a value in and writing it back out is not a round trip for every geometry. This closes the "select on map" item in the readme's todo list. Leaflet is loaded lazily in the resource form rather than with the page. The Mapping module puts 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 the editor can see what is already there and reuse it; Leaflet.draw 1.0.4 works against both copies. Layers are configured under the datatypegeometry key, defaults in config/module.config.php and overrides in Omeka's local.config.php. OpenStreetMap ships as the base layer so a stock installation draws something. No overlays ship: which historical maps are worth showing is a property of a collection, not of this module. A layer url is used exactly as written, so a caching proxy in front of a tile server is simply part of the url. The bundled libraries are recorded in asset/vendor/VERSIONS.md, with the traps met while assembling them: leaflet.fullscreen needs its UMD build, since 5.0.0 the default dist is an es module that throws in a script tag; Leaflet.draw's dist was trimmed to the files its css references; and Leaflet 1.9's plus-lighter tile blending is left alone here because these maps stay at integer zoom. .gitignore keeps ignoring asset/vendor except for these three, so the terraformer asset is still fetched by composer. tests/verify-wiring.php checks the wiring against a real installation. The maps themselves need a browser, so tests/browser holds two pages driving the editor: one for its own behaviour, one proving the Mapping module's Leaflet survives it. --- .gitignore | 6 +- README.md | 69 +- asset/css/data-type-geometry.css | 52 ++ asset/js/data-type-geometry-editor.js | 307 ++++++++ asset/js/data-type-geometry-map.js | 96 +++ asset/vendor/VERSIONS.md | 68 ++ asset/vendor/leaflet-draw/LICENSE | 20 + .../leaflet-draw/images/spritesheet-2x.png | Bin 0 -> 3581 bytes .../leaflet-draw/images/spritesheet.png | Bin 0 -> 1906 bytes .../leaflet-draw/images/spritesheet.svg | 156 +++++ asset/vendor/leaflet-draw/leaflet.draw.css | 10 + asset/vendor/leaflet-draw/leaflet.draw.js | 10 + .../leaflet-fullscreen/Control.FullScreen.css | 30 + .../Control.FullScreen.umd.js | 299 ++++++++ asset/vendor/leaflet-fullscreen/LICENSE | 21 + asset/vendor/leaflet/LICENSE | 26 + asset/vendor/leaflet/images/layers-2x.png | Bin 0 -> 1259 bytes asset/vendor/leaflet/images/layers.png | Bin 0 -> 696 bytes .../vendor/leaflet/images/marker-icon-2x.png | Bin 0 -> 2464 bytes asset/vendor/leaflet/images/marker-icon.png | Bin 0 -> 1466 bytes asset/vendor/leaflet/images/marker-shadow.png | Bin 0 -> 618 bytes asset/vendor/leaflet/leaflet.css | 661 ++++++++++++++++++ asset/vendor/leaflet/leaflet.js | 6 + asset/vendor/leaflet/leaflet.js.map | 1 + config/module.config.php | 58 ++ src/DataType/AbstractDataType.php | 4 + src/DataType/Geography.php | 7 +- src/DataType/Geometry.php | 14 +- src/DataType/GeometryCoordinates.php | 15 + src/DataType/GeometryPosition.php | 13 + src/Service/ViewHelper/GeometryMapFactory.php | 20 + src/View/Helper/GeometryMap.php | 192 +++++ tests/browser/README.md | 40 ++ tests/browser/collision.html | 125 ++++ tests/browser/editor.html | 204 ++++++ tests/verify-wiring.php | 290 ++++++++ 36 files changed, 2814 insertions(+), 6 deletions(-) create mode 100644 asset/js/data-type-geometry-editor.js create mode 100644 asset/js/data-type-geometry-map.js create mode 100644 asset/vendor/VERSIONS.md create mode 100644 asset/vendor/leaflet-draw/LICENSE create mode 100644 asset/vendor/leaflet-draw/images/spritesheet-2x.png create mode 100644 asset/vendor/leaflet-draw/images/spritesheet.png create mode 100644 asset/vendor/leaflet-draw/images/spritesheet.svg create mode 100644 asset/vendor/leaflet-draw/leaflet.draw.css create mode 100644 asset/vendor/leaflet-draw/leaflet.draw.js create mode 100644 asset/vendor/leaflet-fullscreen/Control.FullScreen.css create mode 100644 asset/vendor/leaflet-fullscreen/Control.FullScreen.umd.js create mode 100644 asset/vendor/leaflet-fullscreen/LICENSE create mode 100644 asset/vendor/leaflet/LICENSE create mode 100644 asset/vendor/leaflet/images/layers-2x.png create mode 100644 asset/vendor/leaflet/images/layers.png create mode 100644 asset/vendor/leaflet/images/marker-icon-2x.png create mode 100644 asset/vendor/leaflet/images/marker-icon.png create mode 100644 asset/vendor/leaflet/images/marker-shadow.png create mode 100644 asset/vendor/leaflet/leaflet.css create mode 100644 asset/vendor/leaflet/leaflet.js create mode 100644 asset/vendor/leaflet/leaflet.js.map create mode 100644 src/Service/ViewHelper/GeometryMapFactory.php create mode 100644 src/View/Helper/GeometryMap.php create mode 100644 tests/browser/README.md create mode 100644 tests/browser/collision.html create mode 100644 tests/browser/editor.html create mode 100644 tests/verify-wiring.php 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..cc73c42 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 use the **Select on map** +button beside it. 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..4347b96 100644 --- a/asset/css/data-type-geometry.css +++ b/asset/css/data-type-geometry.css @@ -90,6 +90,58 @@ .sidebar #advanced-search .inputs label.type-radio::after { background: initial; } + + /* BCT: 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; + } + + /* Wider than Omeka's default 25%: drawing a shape in a narrow column means + panning instead of seeing where the shape is going. */ + #geometry-map-sidebar { + width: 40%; + min-width: 380px; + } + + #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%; + } } @media screen and (max-width:640px) { diff --git a/asset/js/data-type-geometry-editor.js b/asset/js/data-type-geometry-editor.js new file mode 100644 index 0000000..0f0c9ad --- /dev/null +++ b/asset/js/data-type-geometry-editor.js @@ -0,0 +1,307 @@ +/** + * BCT: 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 + * "Select on map" 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 Leaflet.draw, but only the parts that are missing. + */ + 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 () { + if (window.L && L.Control && L.Control.Draw) { + return null; + } + return Promise.all([loadCss(assets.leafletDrawCss), loadJs(assets.leafletDrawJs)]); + }); + 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('Draw the geometry')); + $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 + }); + 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..4195e79 --- /dev/null +++ b/asset/vendor/VERSIONS.md @@ -0,0 +1,68 @@ +# 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..70a37b9
--- /dev/null
+++ b/tests/browser/editor.html
@@ -0,0 +1,204 @@
+
+
+
+
+editor harness
+
+
+
+
+
+
+
+
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +

+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/verify-wiring.php b/tests/verify-wiring.php
new file mode 100644
index 0000000..5e348b7
--- /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(
+        ['Select on map', 'Draw the geometry', '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);

From 5012f47328a210d76e23f01d870364e113d63d65 Mon Sep 17 00:00:00 2001
From: coret 
Date: Wed, 12 Aug 2026 12:08:46 +0200
Subject: [PATCH 2/7] Drop the BCT markers from the map comments

The marker distinguished a local patch from the module around it. In the
module's own history it distinguishes nothing, so it is noise. The
comments themselves are kept: what they explain is still worth knowing.

The hash comment style went with it, that having been part of the same
convention; the rest of the codebase uses //.

Comments only: no statement changes.
---
 asset/css/data-type-geometry.css      | 2 +-
 asset/js/data-type-geometry-editor.js | 2 +-
 config/module.config.php              | 6 +++---
 src/DataType/AbstractDataType.php     | 4 ++--
 src/DataType/Geography.php            | 4 ++--
 src/DataType/Geometry.php             | 8 ++++----
 src/DataType/GeometryCoordinates.php  | 2 +-
 src/DataType/GeometryPosition.php     | 2 +-
 src/View/Helper/GeometryMap.php       | 2 +-
 9 files changed, 16 insertions(+), 16 deletions(-)

diff --git a/asset/css/data-type-geometry.css b/asset/css/data-type-geometry.css
index 4347b96..75f5773 100644
--- a/asset/css/data-type-geometry.css
+++ b/asset/css/data-type-geometry.css
@@ -91,7 +91,7 @@
         background: initial;
     }
 
-    /* BCT: the map editor. */
+    /* The map editor. */
 
     .geometry-map-open {
         margin-top: 6px;
diff --git a/asset/js/data-type-geometry-editor.js b/asset/js/data-type-geometry-editor.js
index 0f0c9ad..ab4e9d0 100644
--- a/asset/js/data-type-geometry-editor.js
+++ b/asset/js/data-type-geometry-editor.js
@@ -1,5 +1,5 @@
 /**
- * BCT: draw a geometry on a map instead of typing wkt into the field.
+ * 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
  * "Select on map" button beside it. What is drawn is written back into that same
diff --git a/config/module.config.php b/config/module.config.php
index 252af05..b220de6 100644
--- a/config/module.config.php
+++ b/config/module.config.php
@@ -181,7 +181,7 @@
         'factories' => [
             'databaseVersion' => Service\ViewHelper\DatabaseVersionFactory::class,
             'geometryFieldset' => Service\ViewHelper\GeometryFieldsetFactory::class,
-            // BCT: draws a wkt value on a Leaflet map, and prepares the editor.
+            // Draws a wkt value on a Leaflet map, and prepares the editor.
             'geometryMap' => Service\ViewHelper\GeometryMapFactory::class,
             'normalizeGeometryQuery' => Service\ViewHelper\NormalizeGeometryQueryFactory::class,
         ],
@@ -222,7 +222,7 @@
         'Please enter a valid wkt for the geometry.', // @translate
         '"multipoint", "multiline" and "multipolygon" are not supported for now. Use collection instead.', // @translate
         'Error in input.', // @translate
-        // BCT: strings used by the map editor (asset/js/data-type-geometry-editor.js).
+        // Strings used by the map editor (asset/js/data-type-geometry-editor.js).
         'Select on map', // @translate
         'Draw the geometry', // @translate
         'Apply', // @translate
@@ -260,7 +260,7 @@
             'datatypegeometry_support_geographic_search' => false,
         ],
 
-        // BCT: settings for the Leaflet map that renders a wkt value on a public
+        // Settings for the Leaflet map that renders a wkt value on a public
         // page, and for the editor that draws one in the resource form. Both use
         // the same layers, so what a cataloguer draws on is what a visitor sees.
         //
diff --git a/src/DataType/AbstractDataType.php b/src/DataType/AbstractDataType.php
index b1da5eb..81fb314 100644
--- a/src/DataType/AbstractDataType.php
+++ b/src/DataType/AbstractDataType.php
@@ -30,8 +30,8 @@ public function prepareForm(PhpRenderer $view): void
             ->appendFile($assetUrl('vendor/terraformer-wkt/t-wkt.umd-2.2.1.js', 'DataTypeGeometry'), 'text/javascript', ['defer' => 'defer'])
             ->appendFile($assetUrl('js/data-type-geometry.js', 'DataTypeGeometry'), 'text/javascript', ['defer' => 'defer']);
 
-        # BCT: let a wkt value be drawn on a map instead of typed. Only adds the
-        # editor's own script here; it loads Leaflet itself, on first use.
+        // Let a wkt value be drawn on a map instead of typed. Only adds the
+        // editor's own script here; it loads Leaflet itself, on first use.
         $view->geometryMap()->prepareEditor();
     }
 
diff --git a/src/DataType/Geography.php b/src/DataType/Geography.php
index bed01ef..5304bf3 100644
--- a/src/DataType/Geography.php
+++ b/src/DataType/Geography.php
@@ -52,8 +52,8 @@ public function form(PhpRenderer $view)
 
         return '
' . $view->formTextarea($element) - # BCT: opens the map editor for this value. Ctrl+Alt+M does the same - # from inside the field; the button is what makes it discoverable. + // Opens the map editor for this value. Ctrl+Alt+M does the same + // from inside the field; the button is what makes it discoverable. . ''; diff --git a/src/DataType/Geometry.php b/src/DataType/Geometry.php index 45477a2..f42a7bf 100644 --- a/src/DataType/Geometry.php +++ b/src/DataType/Geometry.php @@ -41,8 +41,8 @@ public function form(PhpRenderer $view) return '
' . $view->formTextarea($element) - # BCT: opens the map editor for this value. Ctrl+Alt+M does the same - # from inside the field; the button is what makes it discoverable. + // Opens the map editor for this value. Ctrl+Alt+M does the same + // from inside the field; the button is what makes it discoverable. . ''; @@ -55,8 +55,8 @@ public function getEntityClass(): string public function render(PhpRenderer $view, ValueRepresentation $value) { - # BCT: render the wkt geometry as a Leaflet map, from this module's own - # assets. See src/View/Helper/GeometryMap.php. + // Render the wkt geometry as a Leaflet map, from this module's own + // assets. See src/View/Helper/GeometryMap.php. return $view->geometryMap((string) $value->value()); } diff --git a/src/DataType/GeometryCoordinates.php b/src/DataType/GeometryCoordinates.php index e1250a1..3fa34ce 100644 --- a/src/DataType/GeometryCoordinates.php +++ b/src/DataType/GeometryCoordinates.php @@ -104,7 +104,7 @@ public function getJsonLd(ValueRepresentation $value) } /** - * BCT: the map inherited from Geometry, given something it can read. + * The map inherited from Geometry, given something it can read. * * This type stores "x,y", not wkt, so the inherited render() handed the map * a string no wkt parser accepts and drew an empty one. The point is the diff --git a/src/DataType/GeometryPosition.php b/src/DataType/GeometryPosition.php index 064ef39..6265ae9 100644 --- a/src/DataType/GeometryPosition.php +++ b/src/DataType/GeometryPosition.php @@ -94,7 +94,7 @@ public function hydrate(array $valueObject, Value $value, AbstractEntityAdapter } /** - * BCT: undo the map that Geometry::render() would otherwise inherit here. + * Undo the map that Geometry::render() would otherwise inherit here. * * A position is not a place. Its origin is the top left corner of an image, * as used by an image editor, iiif or alto, so "4,52" means four pixels diff --git a/src/View/Helper/GeometryMap.php b/src/View/Helper/GeometryMap.php index bbe914b..5012abb 100644 --- a/src/View/Helper/GeometryMap.php +++ b/src/View/Helper/GeometryMap.php @@ -5,7 +5,7 @@ use Laminas\View\Helper\AbstractHelper; /** - * BCT: draw a wkt value on a Leaflet map, and prepare the resource-form editor. + * Draw a wkt value on a Leaflet map, and prepare the resource-form editor. * * Both sides are here because they share one thing: the "datatypegeometry" * settings, which say what the map is made of. A visitor looking at a value and From 4bcb7ee927cc53f629038be6339d6637e50a1a8f Mon Sep 17 00:00:00 2001 From: coret Date: Wed, 12 Aug 2026 12:17:15 +0200 Subject: [PATCH 3/7] Keep the editor's sidebar inside the viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel was widened to 40% so a shape could be drawn without panning, but core couples that width to an offset: .sidebar is width:25% parked at left:100%, and .active slides it to left:75%. Widening one alone left the panel running from 75% to 115%, and .sidebar's own overflow-x:hidden cropped the overhang. What sits in the cropped strip is the layer switcher, in the map's top right corner, so it was invisible. Sets left:60% alongside width:40%, and drops the min-width, which broke the same arithmetic again between 641px and 950px. Both are scoped above core's 640px breakpoint, below which the sidebar is full width already. tests/browser/sidebar-layout.html covers it: it loads Omeka's own stylesheet, which the other two browser pages do not, because without it the sidebar has no geometry and a map overflowing the panel looks fine. It measures against the viewport as well as against the panel — the switcher was positioned correctly relative to its parent throughout — and confirms with elementFromPoint that the control is reachable rather than merely placed. Appending ?bug=1 restores the mistake, and three assertions fail. --- asset/css/data-type-geometry.css | 31 ++++-- tests/browser/README.md | 12 +++ tests/browser/sidebar-layout.html | 156 ++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 tests/browser/sidebar-layout.html diff --git a/asset/css/data-type-geometry.css b/asset/css/data-type-geometry.css index 75f5773..d3e887b 100644 --- a/asset/css/data-type-geometry.css +++ b/asset/css/data-type-geometry.css @@ -113,13 +113,6 @@ margin-right: 6px; } - /* Wider than Omeka's default 25%: drawing a shape in a narrow column means - panning instead of seeing where the shape is going. */ - #geometry-map-sidebar { - width: 40%; - min-width: 380px; - } - #geometry-map-sidebar .geometry-map-canvas { width: 100%; height: 400px; @@ -151,3 +144,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/tests/browser/README.md b/tests/browser/README.md index 6762053..c9eaa0d 100644 --- a/tests/browser/README.md +++ b/tests/browser/README.md @@ -12,6 +12,18 @@ item edit page, which a headless run cannot reach. |---|---| | `editor.html` | Ctrl+Alt+M and the button open the editor, Leaflet and Leaflet.draw load lazily on first use, the map gets a real size inside the sidebar, an existing value is seeded onto it, drawing writes a single non-`MULTI*` wkt back into the field with a `change` event, an untouched editor writes nothing, and each button edits its own row | | `collision.html` | The Mapping module's Leaflet 1.9.3 is left alone: the editor adds no second Leaflet, does not replace `window.L`, reuses the Leaflet.draw already there, and Mapping's own map keeps working | +| `sidebar-layout.html` | The panel and the map inside it stay within the viewport, and the layer switcher at the map's top right corner is painted, unclipped and reachable by `elementFromPoint` | + +`sidebar-layout.html` is the only one that loads Omeka's own `style.css`, and it +has to: the sidebar has no geometry without it, so a map overflowing the panel +looks perfectly fine in the other two. It exists because that is precisely what +shipped — the panel was widened to 40% while core's `.sidebar.active` kept it at +`left:75%`, so 15% of it hung off the right edge and `overflow-x: hidden` cropped +the layer switcher away. Append **`?bug=1`** to restore that mistake and watch the +assertions catch it; three of them fail. + +Measure, do not eyeball, and assert against the viewport as well as the parent: +the switcher was correctly positioned *relative to the panel* the whole time. Both point at `https://www.goudatijdmachine.nl/omeka/`. Change the `BASE` constant, and the ` + + + + +
+
+
+
+ + +
+
+
+
+ +

+
+
+
+
+
+
+
+
+

From 25c208c346950f6cdf269c4ca95103dc0808ea47 Mon Sep 17 00:00:00 2001
From: coret 
Date: Wed, 12 Aug 2026 12:25:46 +0200
Subject: [PATCH 4/7] Give the editor's map the fullscreen button too

The public map got the fullscreen control when this work replaced the
stylesheet-without-its-script arrangement that had styled a button no map
ever created. The editor never did: it lazy-loads what it needs, and the
list was Leaflet and Leaflet.draw. Passing fullscreenControl to a map
whose plugin was never loaded is ignored in silence, which is exactly the
failure this work set out to remove, reproduced in the other half of it.

The plugin now loads alongside Leaflet.draw, under the same rule as the
rest: only if it is missing. On the item edit form it usually is not.
Mapping loads leaflet.fullscreen 2.4.0 there, an ordinary script rather
than the es module later majors ship, registering the same
L.Control.FullScreen under the same leaflet-control-zoom-fullscreen
class, so the editor takes it and adds nothing.

collision.html now preloads all three of the files Mapping's item form
loads, rather than two, and asserts the sharper thing: that the editor
pulls nothing whatsoever from this module's asset/vendor when the page
already has the stack. Its old assertion counted script[src*="leaflet"],
which a plugin path also matches, so it read this commit's extra file as
a second copy of Leaflet.
---
 asset/js/data-type-geometry-editor.js | 22 ++++++++++---
 src/View/Helper/GeometryMap.php       |  3 ++
 tests/browser/README.md               |  2 +-
 tests/browser/collision.html          | 45 +++++++++++++++++++++++----
 tests/browser/editor.html             | 10 +++++-
 tests/browser/sidebar-layout.html     | 23 +++++++++++++-
 6 files changed, 91 insertions(+), 14 deletions(-)

diff --git a/asset/js/data-type-geometry-editor.js b/asset/js/data-type-geometry-editor.js
index ab4e9d0..33b33b3 100644
--- a/asset/js/data-type-geometry-editor.js
+++ b/asset/js/data-type-geometry-editor.js
@@ -61,7 +61,11 @@
     }
 
     /**
-     * Load Leaflet and Leaflet.draw, but only the parts that are missing.
+     * 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) {
@@ -75,10 +79,14 @@
                 return Promise.all([loadCss(assets.leafletCss), loadJs(assets.leafletJs)]);
             })
             .then(function () {
-                if (window.L && L.Control && L.Control.Draw) {
-                    return null;
+                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 Promise.all([loadCss(assets.leafletDrawCss), loadJs(assets.leafletDrawJs)]);
+                return wanted.length ? Promise.all(wanted) : null;
             });
         return loading;
     }
@@ -156,7 +164,11 @@
         map = L.map($sidebar.find('.geometry-map-canvas')[0], {
             center: settings.center || [0, 0],
             zoom: settings.zoom || 16,
-            maxZoom: settings.max_zoom || 21
+            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);
 
diff --git a/src/View/Helper/GeometryMap.php b/src/View/Helper/GeometryMap.php
index 5012abb..5e14931 100644
--- a/src/View/Helper/GeometryMap.php
+++ b/src/View/Helper/GeometryMap.php
@@ -110,6 +110,9 @@ public function prepareEditor(): self
             'leafletJs' => $assetUrl('vendor/leaflet/leaflet.js', 'DataTypeGeometry'),
             'leafletDrawCss' => $assetUrl('vendor/leaflet-draw/leaflet.draw.css', 'DataTypeGeometry'),
             'leafletDrawJs' => $assetUrl('vendor/leaflet-draw/leaflet.draw.js', 'DataTypeGeometry'),
+            'fullscreenCss' => $assetUrl('vendor/leaflet-fullscreen/Control.FullScreen.css', 'DataTypeGeometry'),
+            // The UMD build, for the same reason as on the public side.
+            'fullscreenJs' => $assetUrl('vendor/leaflet-fullscreen/Control.FullScreen.umd.js', 'DataTypeGeometry'),
         ];
 
         $view->headScript()
diff --git a/tests/browser/README.md b/tests/browser/README.md
index c9eaa0d..0bdb880 100644
--- a/tests/browser/README.md
+++ b/tests/browser/README.md
@@ -11,7 +11,7 @@ item edit page, which a headless run cannot reach.
 | Page | What it proves |
 |---|---|
 | `editor.html` | Ctrl+Alt+M and the button open the editor, Leaflet and Leaflet.draw load lazily on first use, the map gets a real size inside the sidebar, an existing value is seeded onto it, drawing writes a single non-`MULTI*` wkt back into the field with a `change` event, an untouched editor writes nothing, and each button edits its own row |
-| `collision.html` | The Mapping module's Leaflet 1.9.3 is left alone: the editor adds no second Leaflet, does not replace `window.L`, reuses the Leaflet.draw already there, and Mapping's own map keeps working |
+| `collision.html` | The Mapping module's stack is left alone: it preloads the same three files Mapping's item form does (Leaflet 1.9.3, Leaflet.draw 1.0.4, leaflet.fullscreen 2.4.0) and checks the editor adds no second Leaflet, pulls nothing at all from this module's own `asset/vendor`, does not replace `window.L`, and leaves Mapping's own map working |
 | `sidebar-layout.html` | The panel and the map inside it stay within the viewport, and the layer switcher at the map's top right corner is painted, unclipped and reachable by `elementFromPoint` |
 
 `sidebar-layout.html` is the only one that loads Omeka's own `style.css`, and it
diff --git a/tests/browser/collision.html b/tests/browser/collision.html
index b2245a2..2055f51 100644
--- a/tests/browser/collision.html
+++ b/tests/browser/collision.html
@@ -10,9 +10,16 @@
      Leaflet.draw 1.0.4 on the very same item edit form. This is the collision
      the editor's lazy loading exists to survive: a second Leaflet would replace
      window.L under Mapping and break its map. -->
+     Mapping loads all three of these on the item edit form, in this order:
+     see Mapping/view/common/mapping-item-form.phtml lines 2-11. Its
+     leaflet.fullscreen is 2.4.0, an ordinary script rather than the es module
+     later majors ship, and it registers the same L.Control.FullScreen under the
+     same leaflet-control-zoom-fullscreen class, so the editor can take it. -->
 
+
 
 
+
 
 
 
@@ -47,7 +54,9 @@
         leafletCss: BASE + 'vendor/leaflet/leaflet.css',
         leafletJs: BASE + 'vendor/leaflet/leaflet.js',
         leafletDrawCss: BASE + 'vendor/leaflet-draw/leaflet.draw.css',
-        leafletDrawJs: BASE + 'vendor/leaflet-draw/leaflet.draw.js'
+        leafletDrawJs: BASE + 'vendor/leaflet-draw/leaflet.draw.js',
+        fullscreenCss: BASE + 'vendor/leaflet-fullscreen/Control.FullScreen.css',
+        fullscreenJs: BASE + 'vendor/leaflet-fullscreen/Control.FullScreen.umd.js'
     }
 };
 
@@ -64,8 +73,19 @@
 var mappingLeaflet = L.version;
 var mappingL = window.L;
 
-function countLeafletScripts() {
-    return $('script[src*="leaflet"]').length;
+// Leaflet core only. A substring match on "leaflet" also catches the plugins,
+// whose paths contain the word, and would report a plugin load as a second
+// Leaflet.
+function countLeafletCore() {
+    return $('script[src]').filter(function () {
+        return /\/leaflet(-src)?\.js(\?|$)/.test(this.src);
+    }).length;
+}
+
+// Anything at all pulled from this module's vendor directory. When the page
+// already has the whole stack, the editor should add nothing.
+function countOwnVendor() {
+    return $('script[src*="/DataTypeGeometry/asset/vendor/"], link[href*="/DataTypeGeometry/asset/vendor/"]').length;
 }
 
 
@@ -73,7 +93,8 @@
 
 
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ +
+ +

+
+
+
+
+
+
+
+

From e91649555d62278700f401a148eea287f4228f18 Mon Sep 17 00:00:00 2001
From: coret 
Date: Wed, 12 Aug 2026 12:56:09 +0200
Subject: [PATCH 6/7] Rename the editor's two labels

"Select on map" becomes "Use geometry editor", and the panel heading
"Draw the geometry" becomes "Geometry editor". Both are translatable
strings, so the entries in js_translate_strings move with them, as does
the pair verify-wiring.php checks for.

The readme's todo list keeps its original wording: that line is upstream's
own request, and rewording it would misrepresent what was asked for rather
than record that it is now done.

Also closes an html comment in collision.html that a previous commit ended
one line early, leaving five lines of prose loose in the document head.
---
 README.md                             |  4 ++--
 asset/js/data-type-geometry-editor.js |  4 ++--
 config/module.config.php              |  4 ++--
 src/DataType/Geography.php            |  4 ++--
 src/DataType/Geometry.php             |  4 ++--
 tests/browser/collision.html          | 15 ++++++++-------
 tests/browser/editor.html             |  4 ++--
 tests/browser/sidebar-layout.html     |  2 +-
 tests/verify-wiring.php               |  2 +-
 9 files changed, 22 insertions(+), 21 deletions(-)

diff --git a/README.md b/README.md
index cc73c42..888e46a 100644
--- a/README.md
+++ b/README.md
@@ -335,8 +335,8 @@ 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 use the **Select on map**
-button beside it. What you draw is written back into that field as WKT and
+typed. Press **Ctrl+Alt+M** inside the field, or click the **Use geometry
+editor** button beside it. 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
diff --git a/asset/js/data-type-geometry-editor.js b/asset/js/data-type-geometry-editor.js
index 33b33b3..5fc6085 100644
--- a/asset/js/data-type-geometry-editor.js
+++ b/asset/js/data-type-geometry-editor.js
@@ -2,7 +2,7 @@
  * 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
- * "Select on map" button beside it. What is drawn is written back into that same
+ * "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
@@ -140,7 +140,7 @@
             + ''
             + ''
         );
-        $sidebar.find('.geometry-map-title').text(translate('Draw the geometry'));
+        $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.
diff --git a/config/module.config.php b/config/module.config.php
index b220de6..a6d1d8c 100644
--- a/config/module.config.php
+++ b/config/module.config.php
@@ -223,8 +223,8 @@
         '"multipoint", "multiline" and "multipolygon" are not supported for now. Use collection instead.', // @translate
         'Error in input.', // @translate
         // Strings used by the map editor (asset/js/data-type-geometry-editor.js).
-        'Select on map', // @translate
-        'Draw the geometry', // @translate
+        'Use geometry editor', // @translate
+        'Geometry editor', // @translate
         'Apply', // @translate
         'Cancel', // @translate
         'The map could not be loaded.', // @translate
diff --git a/src/DataType/Geography.php b/src/DataType/Geography.php
index 5304bf3..a6153e6 100644
--- a/src/DataType/Geography.php
+++ b/src/DataType/Geography.php
@@ -55,8 +55,8 @@ public function form(PhpRenderer $view)
             // Opens the map editor for this value. Ctrl+Alt+M does the same
             // from inside the field; the button is what makes it discoverable.
             . '';
+            . $escapeAttr($translate('Use geometry editor')) . '">' // @translate
+            . $escapeAttr($translate('Use geometry editor')) . '';
     }
 
     /**
diff --git a/src/DataType/Geometry.php b/src/DataType/Geometry.php
index f42a7bf..f978bf4 100644
--- a/src/DataType/Geometry.php
+++ b/src/DataType/Geometry.php
@@ -44,8 +44,8 @@ public function form(PhpRenderer $view)
             // Opens the map editor for this value. Ctrl+Alt+M does the same
             // from inside the field; the button is what makes it discoverable.
             . '';
+            . $escapeAttr($translate('Use geometry editor')) . '">' // @translate
+            . $escapeAttr($translate('Use geometry editor')) . '';
     }
 
     public function getEntityClass(): string
diff --git a/tests/browser/collision.html b/tests/browser/collision.html
index 2055f51..24f630b 100644
--- a/tests/browser/collision.html
+++ b/tests/browser/collision.html
@@ -6,12 +6,13 @@
 
 
 
-
-     Mapping loads all three of these on the item edit form, in this order:
-     see Mapping/view/common/mapping-item-form.phtml lines 2-11. Its
+
@@ -27,7 +28,7 @@
   
- +
diff --git a/tests/browser/editor.html b/tests/browser/editor.html index 729829d..de45fc1 100644 --- a/tests/browser/editor.html +++ b/tests/browser/editor.html @@ -19,7 +19,7 @@
- + @@ -27,7 +27,7 @@
- +
diff --git a/tests/browser/sidebar-layout.html b/tests/browser/sidebar-layout.html index da34e17..e134125 100644 --- a/tests/browser/sidebar-layout.html +++ b/tests/browser/sidebar-layout.html @@ -20,7 +20,7 @@
- +
diff --git a/tests/verify-wiring.php b/tests/verify-wiring.php index 5e348b7..218ddcb 100644 --- a/tests/verify-wiring.php +++ b/tests/verify-wiring.php @@ -278,7 +278,7 @@ function section($title) checking('the editor\'s strings are in js_translate_strings', function () use ($services) { $strings = $services->get('Config')['js_translate_strings'] ?? []; $missing = array_diff( - ['Select on map', 'Draw the geometry', 'Apply', 'Cancel', 'The map could not be loaded.'], + ['Use geometry editor', 'Geometry editor', 'Apply', 'Cancel', 'The map could not be loaded.'], $strings ); return $missing ? 'missing: ' . implode(', ', $missing) : true; From 851902122febc9ab57444b03e0ef0a7b33f95028 Mon Sep 17 00:00:00 2001 From: coret Date: Wed, 12 Aug 2026 12:57:07 +0200 Subject: [PATCH 7/7] Rewrap the editor paragraph in the readme The rename split "Use geometry editor" across a line break, which reads fine but cannot be grepped, and pushed two lines past the file's margin. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 888e46a..e5c1017 100644 --- a/README.md +++ b/README.md @@ -335,9 +335,9 @@ 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 **Use geometry -editor** button beside it. 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 +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