UNPKG

@ulb-darmstadt/shacl-form

Version:
196 lines (181 loc) 13 kB
import * as L from 'leaflet' import 'leaflet-editable/src/Leaflet.Editable.js' import leafletCss from 'leaflet/dist/leaflet.css?raw' import leafletFullscreenCss from 'leaflet.fullscreen/Control.FullScreen.css?raw' import 'leaflet.fullscreen/Control.FullScreen.js' import { Term } from '@rdfjs/types' import { Plugin, PluginOptions } from '../plugin' import { Editor, fieldFactory } from '../theme' import { ShaclPropertyTemplate } from '../property-template' import { Geometry, geometryToWkt, wktToGeometry, worldBounds } from './map-util' const css = ` #shaclMapDialog .closeButton { position: absolute; right: 0; top: 0; z-index: 1; padding: 6px 8px; cursor: pointer; border: 0; background-color: #FFFA; font-size: 24px; z-index: 1000; } #shaclMapDialog { padding: 0; width:90vw; height: 90vh; margin: auto; } #shaclMapDialog::backdrop { background-color: #0007; } #shaclMapDialog .closeButton:hover { background-color: #FFF } #shaclMapDialog .hint { position: absolute; right: 60px; top: 3px; z-index: 1; padding: 4px 6px; background-color: #FFFA; border-radius: 4px; z-index: 1000; pointer-events: none; } .leaflet-container { min-height: 300px; } .fullscreen-icon { background-image: url(data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjYgNTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIwLjYgMzYuN0gxNmEuOS45IDAgMCAxLS44LS44di00LjVjMC0uMi4yLS40LjQtLjRoMS40Yy4zIDAgLjUuMi41LjR2M2gzYy4yIDAgLjQuMi40LjV2MS40YzAgLjItLjIuNC0uNC40em0tOS45LS44di00LjVjMC0uMi0uMi0uNC0uNC0uNEg4LjljLS4zIDAtLjUuMi0uNS40djNoLTNjLS4yIDAtLjQuMi0uNC41djEuNGMwIC4yLjIuNC40LjRIMTBjLjQgMCAuOC0uNC44LS44em0wIDEwLjdWNDJjMC0uNC0uNC0uOC0uOC0uOEg1LjRjLS4yIDAtLjQuMi0uNC40djEuNGMwIC4zLjIuNS40LjVoM3YzYzAgLjIuMi40LjUuNGgxLjRjLjIgMCAuNC0uMi40LS40em02LjkgMHYtM2gzYy4yIDAgLjQtLjIuNC0uNXYtMS40YzAtLjItLjItLjQtLjQtLjRIMTZjLS40IDAtLjguNC0uOC44djQuNWMwIC4yLjIuNC40LjRoMS40Yy4zIDAgLjUtLjIuNS0uNHpNNSAxMC4zVjUuOWMwLS41LjQtLjkuOS0uOWg0LjRjLjIgMCAuNC4yLjQuNFY3YzAgLjItLjIuNC0uNC40aC0zdjNjMCAuMi0uMi40LS40LjRINS40YS40LjQgMCAwIDEtLjQtLjR6bTEwLjMtNC45VjdjMCAuMi4yLjQuNC40aDN2M2MwIC4yLjIuNC40LjRoMS41Yy4yIDAgLjQtLjIuNC0uNFY1LjljMC0uNS0uNC0uOS0uOS0uOWgtNC40Yy0uMiAwLS40LjItLjQuNHptNS4zIDkuOUgxOWMtLjIgMC0uNC4yLS40LjR2M2gtM2MtLjIgMC0uNC4yLS40LjR2MS41YzAgLjIuMi40LjQuNGg0LjRjLjUgMCAuOS0uNC45LS45di00LjRjMC0uMi0uMi0uNC0uNC0uNHptLTkuOSA1LjNWMTljMC0uMi0uMi0uNC0uNC0uNGgtM3YtM2MwLS4yLS4yLS40LS40LS40SDUuNGMtLjIgMC0uNC4yLS40LjR2NC40YzAgLjUuNC45LjkuOWg0LjRjLjIgMCAuNC0uMi40LS40eiIgZmlsbD0iY3VycmVudENvbG9yIi8+PC9zdmc+); } #shaclMapDialogContainer { width:100%; height: 100%; } ` const dialogTemplate = ` <dialog id="shaclMapDialog" onclick="event.target==this && this.close()"> <div id="shaclMapDialogContainer"></div> <div class="hint">&#x24D8; Draw a polygon or marker, then close dialog</div> <button class="closeButton" type="button" onclick="this.parentElement.close()">&#x2715;</button> </dialog>` const defaultCenter = { lng: 8.657238961696038, lat: 49.87627570549512 } const attribution = '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>' const tileSource = 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' // const tileSource = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}' const markerIcon = L.icon({ iconUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAApCAYAAADAk4LOAAAFgUlEQVR4Aa1XA5BjWRTN2oW17d3YaZtr2962HUzbDNpjszW24mRt28p47v7zq/bXZtrp/lWnXr337j3nPCe85NcypgSFdugCpW5YoDAMRaIMqRi6aKq5E3YqDQO3qAwjVWrD8Ncq/RBpykd8oZUb/kaJutow8r1aP9II0WmLKLIsJyv1w/kqw9Ch2MYdB++12Onxee/QMwvf4/Dk/Lfp/i4nxTXtOoQ4pW5Aj7wpici1A9erdAN2OH64x8OSP9j3Ft3b7aWkTg/Fm91siTra0f9on5sQr9INejH6CUUUpavjFNq1B+Oadhxmnfa8RfEmN8VNAsQhPqF55xHkMzz3jSmChWU6f7/XZKNH+9+hBLOHYozuKQPxyMPUKkrX/K0uWnfFaJGS1QPRtZsOPtr3NsW0uyh6NNCOkU3Yz+bXbT3I8G3xE5EXLXtCXbbqwCO9zPQYPRTZ5vIDXD7U+w7rFDEoUUf7ibHIR4y6bLVPXrz8JVZEql13trxwue/uDivd3fkWRbS6/IA2bID4uk0UpF1N8qLlbBlXs4Ee7HLTfV1j54APvODnSfOWBqtKVvjgLKzF5YdEk5ewRkGlK0i33Eofffc7HT56jD7/6U+qH3Cx7SBLNntH5YIPvODnyfIXZYRVDPqgHtLs5ABHD3YzLuespb7t79FY34DjMwrVrcTuwlT55YMPvOBnRrJ4VXTdNnYug5ucHLBjEpt30701A3Ts+HEa73u6dT3FNWwflY86eMHPk+Yu+i6pzUpRrW7SNDg5JHR4KapmM5Wv2E8Tfcb1HoqqHMHU+uWDD7zg54mz5/2BSnizi9T1Dg4QQXLToGNCkb6tb1NU+QAlGr1++eADrzhn/u8Q2YZhQVlZ5+CAOtqfbhmaUCS1ezNFVm2imDbPmPng5wmz+gwh+oHDce0eUtQ6OGDIyR0uUhUsoO3vfDmmgOezH0mZN59x7MBi++WDL1g/eEiU3avlidO671bkLfwbw5XV2P8Pzo0ydy4t2/0eu33xYSOMOD8hTf4CrBtGMSoXfPLchX+J0ruSePw3LZeK0juPJbYzrhkH0io7B3k164hiGvawhOKMLkrQLyVpZg8rHFW7E2uHOL888IBPlNZ1FPzstSJM694fWr6RwpvcJK60+0HCILTBzZLFNdtAzJaohze60T8qBzyh5ZuOg5e7uwQppofEmf2++DYvmySqGBuKaicF1blQjhuHdvCIMvp8whTTfZzI7RldpwtSzL+F1+wkdZ2TBOW2gIF88PBTzD/gpeREAMEbxnJcaJHNHrpzji0gQCS6hdkEeYt9DF/2qPcEC8RM28Hwmr3sdNyht00byAut2k3gufWNtgtOEOFGUwcXWNDbdNbpgBGxEvKkOQsxivJx33iow0Vw5S6SVTrpVq11ysA2Rp7gTfPfktc6zhtXBBC+adRLshf6sG2RfHPZ5EAc4sVZ83yCN00Fk/4kggu40ZTvIEm5g24qtU4KjBrx/BTTH8ifVASAG7gKrnWxJDcU7x8X6Ecczhm3o6YicvsLXWfh3Ch1W0k8x0nXF+0fFxgt4phz8QvypiwCCFKMqXCnqXExjq10beH+UUA7+nG6mdG/Pu0f3LgFcGrl2s0kNNjpmoJ9o4B29CMO8dMT4Q5ox8uitF6fqsrJOr8qnwNbRzv6hSnG5wP+64C7h9lp30hKNtKdWjtdkbuPA19nJ7Tz3zR/ibgARbhb4AlhavcBebmTHcFl2fvYEnW0ox9xMxKBS8btJ+KiEbq9zA4RthQXDhPa0T9TEe69gWupwc6uBUphquXgf+/FrIjweHQS4/pduMe5ERUMHUd9xv8ZR98CxkS4F2n3EUrUZ10EYNw7BWm9x1GiPssi3GgiGRDKWRYZfXlON+dfNbM+GgIwYdwAAAAASUVORK5CYII=', shadowUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACkAAAApCAQAAAACach9AAACMUlEQVR4Ae3ShY7jQBAE0Aoz/f9/HTMzhg1zrdKUrJbdx+Kd2nD8VNudfsL/Th///dyQN2TH6f3y/BGpC379rV+S+qqetBOxImNQXL8JCAr2V4iMQXHGNJxeCfZXhSRBcQMfvkOWUdtfzlLgAENmZDcmo2TVmt8OSM2eXxBp3DjHSMFutqS7SbmemzBiR+xpKCNUIRkdkkYxhAkyGoBvyQFEJEefwSmmvBfJuJ6aKqKWnAkvGZOaZXTUgFqYULWNSHUckZuR1HIIimUExutRxwzOLROIG4vKmCKQt364mIlhSyzAf1m9lHZHJZrlAOMMztRRiKimp/rpdJDc9Awry5xTZCte7FHtuS8wJgeYGrex28xNTd086Dik7vUMscQOa8y4DoGtCCSkAKlNwpgNtphjrC6MIHUkR6YWxxs6Sc5xqn222mmCRFzIt8lEdKx+ikCtg91qS2WpwVfBelJCiQJwvzixfI9cxZQWgiSJelKnwBElKYtDOb2MFbhmUigbReQBV0Cg4+qMXSxXSyGUn4UbF8l+7qdSGnTC0XLCmahIgUHLhLOhpVCtw4CzYXvLQWQbJNmxoCsOKAxSgBJno75avolkRw8iIAFcsdc02e9iyCd8tHwmeSSoKTowIgvscSGZUOA7PuCN5b2BX9mQM7S0wYhMNU74zgsPBj3HU7wguAfnxxjFQGBE6pwN+GjME9zHY7zGp8wVxMShYX9NXvEWD3HbwJf4giO4CFIQxXScH1/TM+04kkBiAAAAAElFTkSuQmCC', iconSize: [25, 41], // size of the icon shadowSize: [41, 41], // size of the shadow iconAnchor: [12, 41], // point of the icon which will correspond to marker's location shadowAnchor: [14, 41], // the same for the shadow popupAnchor: [-3, -76] // point from which the popup should open relative to the iconAnchor }) export class LeafletPlugin extends Plugin { map: L.Map | undefined currentEditor: Editor | undefined createdGeometry: Geometry | undefined displayedShape: L.Polygon | L.Marker | undefined constructor(options: PluginOptions) { super(options, leafletCss + '\n' + leafletFullscreenCss + '\n' + css) } initEditMode(form: HTMLElement): HTMLDialogElement { form.insertAdjacentHTML('beforeend', dialogTemplate) const container = form.querySelector('#shaclMapDialogContainer') as HTMLElement this.map = L.map(container, { fullscreenControl: true, editable: true, layers: [ L.tileLayer(tileSource) ], zoom: 5, maxBounds: worldBounds, center: defaultCenter }) this.map.attributionControl.addAttribution(attribution) const EditControl = L.Control.extend({ options: { position: 'topleft', callback: null, kind: '', html: '' }, onAdd: function (map: L.Map) { let container = L.DomUtil.create('div', 'leaflet-control leaflet-bar') let link = L.DomUtil.create('a', '', container) link.href = '#'; link.title = 'Create a new ' + this.options.kind; link.innerHTML = this.options.html; L.DomEvent.on(link, 'click', L.DomEvent.stop).on(link, 'click', () => { // @ts-ignore window.LAYER = this.options.callback.call(map.editTools) }, this) return container } }) this.map.addControl(new (EditControl.extend({ options: { callback: () => { this.displayedShape?.remove() this.displayedShape = this.map?.editTools.startPolygon() }, kind: 'polygon', html: '▰' } }))()) this.map.addControl(new (EditControl.extend({ options: { callback: () => { this.displayedShape?.remove() this.displayedShape = this.map?.editTools.startMarker(undefined, { icon: markerIcon }) }, kind: 'marker', html: '•' } }))()) this.map.on('editable:drawing:end', () => { this.saveChanges() }) this.map.on('editable:vertex:dragend', () => { this.saveChanges() }) const dialog = form.querySelector('#shaclMapDialog') as HTMLDialogElement dialog.addEventListener('close', () => { const scrollY = document.body.style.top document.body.style.position = '' document.body.style.top = '' window.scrollTo(0, parseInt(scrollY || '0') * -1) // set wkt in editor if (this.currentEditor && this.createdGeometry) { this.currentEditor.value = geometryToWkt(this.createdGeometry) this.currentEditor.dispatchEvent(new Event('change', { bubbles: true })) } }) return dialog } createEditor(template: ShaclPropertyTemplate, value?: Term): HTMLElement { let dialog = template.config.form.querySelector('#shaclMapDialog') as HTMLDialogElement if (!dialog) { dialog = this.initEditMode(template.config.form) } const button = template.config.theme.createButton('Open&#160;map...', false) button.style.marginLeft = '5px' button.classList.add('open-map-button') button.onclick = () => { this.currentEditor = instance.querySelector('.editor') as Editor this.createdGeometry = undefined this.displayedShape?.remove() this.drawAndZoomToGeometry(wktToGeometry(this.currentEditor.value || ''), this.map!) document.body.style.top = `-${window.scrollY}px` document.body.style.position = 'fixed' dialog.showModal() } const instance = fieldFactory(template, value || null) instance.appendChild(button) return instance } createViewer(template: ShaclPropertyTemplate, value: Term): HTMLElement { const container = document.createElement('div') const geometry = wktToGeometry(value.value) if (geometry?.coordinates?.length) { const map = L.map(container, { fullscreenControl: true, layers: [ L.tileLayer(tileSource) ], zoom: 5, center: defaultCenter, maxBounds: worldBounds }) map.attributionControl.addAttribution(attribution) this.drawAndZoomToGeometry(geometry, map) } return container } drawAndZoomToGeometry(geometry: Geometry | undefined, map: L.Map) { setTimeout(() => { map.invalidateSize() }) if (geometry?.type === 'Point') { const coords = { lng: geometry.coordinates[0], lat: geometry.coordinates[1] } this.displayedShape = L.marker(coords, { icon: markerIcon }).addTo(map) map.setView(coords, 15, { animate: false }) } else if (geometry?.type === 'Polygon') { const coords = geometry.coordinates[0].map((pos) => { return { lng: pos[0], lat: pos[1] }}) const polygon = L.polygon(coords).addTo(map) this.displayedShape = polygon map.fitBounds(polygon.getBounds(), { animate: false }) setTimeout(() => { map.fitBounds(polygon.getBounds(), { animate: false }) map.setView(polygon.getCenter(), undefined, { animate: false }) }, 1) } else { map.setZoom(5) } } saveChanges() { if (this.displayedShape instanceof L.Marker) { const pos = this.displayedShape.getLatLng() this.createdGeometry = { type: 'Point', coordinates: [pos.lng, pos.lat] } } else if (this.displayedShape instanceof L.Polygon) { const positions = this.displayedShape.getLatLngs() as L.LatLng[][] // force closed polygon if (!positions[0][0].equals(positions[0][positions[0].length - 1])) { positions[0].push(positions[0][0]) } this.createdGeometry = { type: 'Polygon', coordinates: [positions[0].map((pos) => { return [ pos.lng, pos.lat ] })] } } else { this.createdGeometry = undefined } } }