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 |