forked from tsai/budibase
committed by
GitHub
9 changed files with 670 additions and 3 deletions
@ -0,0 +1,297 @@ |
|||
<script> |
|||
import L from "leaflet" |
|||
import sanitizeHtml from "sanitize-html" |
|||
import "leaflet/dist/leaflet.css" |
|||
import { Helpers } from "@budibase/bbui" |
|||
import { getContext } from "svelte" |
|||
import { |
|||
FullScreenControl, |
|||
LocationControl, |
|||
initMapControls, |
|||
} from "./EmbeddedMapControls" |
|||
|
|||
initMapControls() |
|||
|
|||
export let dataProvider |
|||
export let error |
|||
export let zoomLevel |
|||
export let zoomEnabled = true |
|||
export let latitudeKey = null |
|||
export let longitudeKey = null |
|||
export let titleKey = null |
|||
export let fullScreenEnabled = true |
|||
export let locationEnabled = true |
|||
export let defaultLocation |
|||
export let tileURL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" |
|||
export let mapAttribution |
|||
|
|||
const { styleable, notificationStore } = getContext("sdk") |
|||
const component = getContext("component") |
|||
const embeddedMapId = `${Helpers.uuid()}-wrapper` |
|||
|
|||
let cachedDeviceCoordinates |
|||
const fallbackCoordinates = [51.5072, -0.1276] //London |
|||
|
|||
let mapInstance |
|||
let mapMarkerGroup = new L.FeatureGroup() |
|||
let mapMarkers = [] |
|||
|
|||
let minZoomLevel = 0 |
|||
let maxZoomLevel = 18 |
|||
let adjustedZoomLevel = !Number.isInteger(zoomLevel) |
|||
? 72 |
|||
: Math.round(zoomLevel * (maxZoomLevel / 100)) |
|||
|
|||
$: zoomControlUpdated(mapInstance, zoomEnabled) |
|||
$: locationControlUpdated(mapInstance, locationEnabled) |
|||
$: fullScreenControlUpdated(mapInstance, fullScreenEnabled) |
|||
$: updateMapDimensions( |
|||
mapInstance, |
|||
$component.styles.normal.width, |
|||
$component.styles.normal.height |
|||
) |
|||
$: addMapMarkers( |
|||
mapInstance, |
|||
dataProvider?.rows, |
|||
latitudeKey, |
|||
longitudeKey, |
|||
titleKey |
|||
) |
|||
$: if (typeof mapInstance === "object" && mapMarkers.length > 0) { |
|||
mapInstance.setZoom(0) |
|||
mapInstance.fitBounds(mapMarkerGroup.getBounds(), { |
|||
paddingTopLeft: [0, 24], |
|||
}) |
|||
} |
|||
|
|||
const updateMapDimensions = mapInstance => { |
|||
if (typeof mapInstance !== "object") { |
|||
return |
|||
} |
|||
mapInstance.invalidateSize() |
|||
} |
|||
|
|||
let isValidLatitude = value => { |
|||
return !isNaN(value) && value > -90 && value < 90 |
|||
} |
|||
|
|||
let isValidLongitude = value => { |
|||
return !isNaN(value) && value > -180 && value < 180 |
|||
} |
|||
|
|||
const parseDefaultLocation = defaultLocation => { |
|||
if (typeof defaultLocation !== "string") { |
|||
return fallbackCoordinates |
|||
} |
|||
let defaultLocationParts = defaultLocation.split(",") |
|||
if (defaultLocationParts.length !== 2) { |
|||
return fallbackCoordinates |
|||
} |
|||
|
|||
let parsedDefaultLatitude = parseFloat(defaultLocationParts[0].trim()) |
|||
let parsedDefaultLongitude = parseFloat(defaultLocationParts[1].trim()) |
|||
|
|||
return isValidLatitude(parsedDefaultLatitude) === true && |
|||
isValidLongitude(parsedDefaultLongitude) === true |
|||
? [parsedDefaultLatitude, parsedDefaultLongitude] |
|||
: fallbackCoordinates |
|||
} |
|||
|
|||
$: defaultCoordinates = |
|||
mapMarkers.length > 0 |
|||
? parseDefaultLocation(defaultLocation) |
|||
: fallbackCoordinates |
|||
|
|||
// Map Button Controls |
|||
let locationControl = new LocationControl({ |
|||
position: "bottomright", |
|||
onLocationFail: err => { |
|||
if (err.code === GeolocationPositionError.PERMISSION_DENIED) { |
|||
notificationStore.actions.error( |
|||
"Location requests not permitted. Ensure location is enabled" |
|||
) |
|||
} else if (err.code === GeolocationPositionError.POSITION_UNAVAILABLE) { |
|||
notificationStore.actions.warning( |
|||
"Location could not be retrieved. Try again" |
|||
) |
|||
} else if (err.code === GeolocationPositionError.TIMEOUT) { |
|||
notificationStore.actions.warning( |
|||
"Location request timed out. Try again" |
|||
) |
|||
} else { |
|||
notificationStore.actions.error("Unknown location error") |
|||
} |
|||
}, |
|||
onLocationSuccess: pos => { |
|||
cachedDeviceCoordinates = pos |
|||
if (typeof mapInstance === "object") { |
|||
mapInstance.setView(cachedDeviceCoordinates, 15) |
|||
} |
|||
}, |
|||
}) |
|||
let fullScreenControl = new FullScreenControl({ |
|||
position: "topright", |
|||
}) |
|||
let zoomControl = L.control.zoom({ |
|||
position: "bottomright", |
|||
}) |
|||
|
|||
const locationControlUpdated = (mapInstance, locationEnabled) => { |
|||
if (typeof mapInstance !== "object") { |
|||
return |
|||
} |
|||
if (locationEnabled) { |
|||
locationControl.addTo(mapInstance) |
|||
} else { |
|||
mapInstance.removeControl(locationControl) |
|||
} |
|||
} |
|||
|
|||
const fullScreenControlUpdated = (mapInstance, fullScreenEnabled) => { |
|||
if (typeof mapInstance !== "object") { |
|||
return |
|||
} |
|||
if (fullScreenEnabled) { |
|||
fullScreenControl.addTo(mapInstance) |
|||
} else { |
|||
mapInstance.removeControl(fullScreenControl) |
|||
} |
|||
} |
|||
|
|||
const zoomControlUpdated = (mapInstance, zoomEnabled) => { |
|||
if (typeof mapInstance !== "object") { |
|||
return |
|||
} |
|||
if (zoomEnabled) { |
|||
zoomControl.addTo(mapInstance) |
|||
mapInstance.scrollWheelZoom.enable() |
|||
} else { |
|||
mapInstance.removeControl(zoomControl) |
|||
mapInstance.scrollWheelZoom.disable() |
|||
} |
|||
} |
|||
|
|||
//Map icon and marker configuration |
|||
const mapIconMarkup = |
|||
'<div><svg width="26px" height="26px" class="spectrum-Icon" focusable="false" stroke="#b12b27" stroke-width="1%">' + |
|||
'<use xlink:href="#spectrum-icon-18-Location" /></svg></div>' |
|||
const mapIcon = L.divIcon({ |
|||
html: mapIconMarkup, |
|||
className: "embedded-map-marker", |
|||
iconSize: [26, 26], |
|||
iconAnchor: [13, 26], |
|||
popupAnchor: [0, -13], |
|||
}) |
|||
const mapMarkerOptions = { |
|||
icon: mapIcon, |
|||
draggable: false, |
|||
alt: "Location Marker", |
|||
} |
|||
let mapOptions = { |
|||
fullScreen: false, |
|||
zoomControl: false, |
|||
scrollWheelZoom: zoomEnabled, |
|||
minZoomLevel, |
|||
maxZoomLevel, |
|||
} |
|||
|
|||
const addMapMarkers = (mapInstance, rows, latKey, lngKey, titleKey) => { |
|||
if (typeof mapInstance !== "object" || !rows || !latKey || !lngKey) { |
|||
return |
|||
} |
|||
|
|||
mapMarkerGroup.clearLayers() |
|||
|
|||
const validRows = rows.filter(row => { |
|||
return isValidLatitude(row[latKey]) && isValidLongitude(row[lngKey]) |
|||
}) |
|||
|
|||
validRows.forEach(row => { |
|||
let markerCoords = [row[latKey], row[lngKey]] |
|||
|
|||
let marker = L.marker(markerCoords, mapMarkerOptions).addTo(mapInstance) |
|||
let markerContent = generateMarkerPopupContent( |
|||
row[latKey], |
|||
row[lngKey], |
|||
row[titleKey] |
|||
) |
|||
|
|||
marker.bindPopup(markerContent).addTo(mapMarkerGroup) |
|||
|
|||
//https://github.com/Leaflet/Leaflet/issues/7331 |
|||
marker.on("click", function () { |
|||
this.openPopup() |
|||
}) |
|||
|
|||
mapMarkers = [...mapMarkers, marker] |
|||
}) |
|||
} |
|||
|
|||
const generateMarkerPopupContent = (latitude, longitude, text) => { |
|||
return text || latitude + "," + longitude |
|||
} |
|||
|
|||
const initMap = () => { |
|||
const initCoords = defaultCoordinates |
|||
|
|||
mapInstance = L.map(embeddedMapId, mapOptions) |
|||
mapMarkerGroup.addTo(mapInstance) |
|||
|
|||
const cleanAttribution = sanitizeHtml(mapAttribution, { |
|||
allowedTags: ["a"], |
|||
allowedAttributes: { |
|||
a: ["href", "target"], |
|||
}, |
|||
}) |
|||
|
|||
L.tileLayer(tileURL, { |
|||
attribution: "© " + cleanAttribution, |
|||
zoom: adjustedZoomLevel, |
|||
}).addTo(mapInstance) |
|||
|
|||
//Initialise the map |
|||
mapInstance.setView(initCoords, adjustedZoomLevel) |
|||
} |
|||
|
|||
const mapAction = () => { |
|||
initMap() |
|||
return { |
|||
destroy() { |
|||
mapInstance.remove() |
|||
mapInstance = undefined |
|||
}, |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<div class="embedded-map-wrapper map-default" use:styleable={$component.styles}> |
|||
{#if error} |
|||
<div>{error}</div> |
|||
{/if} |
|||
|
|||
<div id={embeddedMapId} class="embedded embedded-map" use:mapAction /> |
|||
</div> |
|||
|
|||
<style> |
|||
.embedded-map-wrapper { |
|||
background-color: #f1f1f1; |
|||
} |
|||
.map-default { |
|||
min-height: 180px; |
|||
min-width: 200px; |
|||
} |
|||
.embedded-map :global(.leaflet-top), |
|||
.embedded-map :global(.leaflet-bottom) { |
|||
z-index: 998; |
|||
} |
|||
.embedded-map :global(.embedded-map-marker) { |
|||
color: #ee3b35; |
|||
} |
|||
.embedded-map :global(.embedded-map-control) { |
|||
font-size: 22px; |
|||
} |
|||
.embedded-map { |
|||
height: 100%; |
|||
width: 100%; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,192 @@ |
|||
import L from "leaflet" |
|||
import screenfull from "screenfull" |
|||
|
|||
const createButton = function (html, title, className, container, fn) { |
|||
let link = L.DomUtil.create("a", className, container) |
|||
link.innerHTML = html |
|||
link.href = "#" |
|||
link.title = title |
|||
|
|||
link.setAttribute("role", "button") |
|||
link.setAttribute("aria-label", title) |
|||
|
|||
L.DomEvent.disableClickPropagation(link) |
|||
L.DomEvent.on(link, "click", L.DomEvent.stop) |
|||
L.DomEvent.on(link, "click", fn, this) |
|||
L.DomEvent.on(link, "click", this._refocusOnMap, this) |
|||
|
|||
return link |
|||
} |
|||
|
|||
// Full Screen Control
|
|||
|
|||
const FullScreenControl = L.Control.extend({ |
|||
options: { |
|||
position: "topright", |
|||
fullScreenContent: |
|||
'<span class="embedded-map-control embedded-map-location-icon">' + |
|||
'<svg width="16px" height="16px" class="spectrum-Icon" focusable="false">' + |
|||
'<use xlink:href="#spectrum-icon-18-FullScreen" /></svg><span>', |
|||
fullScreenTitle: "Enter Fullscreen", |
|||
}, |
|||
onAdd: function () { |
|||
var fullScreenClassName = "leaflet-control-fullscreen", |
|||
container = L.DomUtil.create("div", fullScreenClassName + " leaflet-bar"), |
|||
options = this.options |
|||
|
|||
this._fullScreenButton = this._createButton( |
|||
options.fullScreenContent, |
|||
options.fullScreenTitle, |
|||
"map-fullscreen", |
|||
container, |
|||
this._fullScreen |
|||
) |
|||
|
|||
return container |
|||
}, |
|||
_fullScreen: function () { |
|||
var map = this._map |
|||
if (screenfull.isEnabled) { |
|||
screenfull.toggle(map.getContainer()) |
|||
} |
|||
}, |
|||
_createButton: createButton, |
|||
}) |
|||
|
|||
const initFullScreenControl = () => { |
|||
L.Map.mergeOptions({ |
|||
fullScreen: false, |
|||
}) |
|||
|
|||
L.Map.addInitHook(function () { |
|||
if (this.options.fullScreen) { |
|||
this.fullScreenControl = new FullScreenControl() |
|||
this.addControl(this.fullScreenControl) |
|||
} else { |
|||
this.fullScreenControl = null |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// Location Control
|
|||
|
|||
const LocationControl = L.Control.extend({ |
|||
options: { |
|||
position: "topright", |
|||
locationContent: |
|||
'<span class="embedded-map-control embedded-map-location-icon">' + |
|||
'<svg width="16px" height="16px" class="spectrum-Icon" focusable="false">' + |
|||
'<use xlink:href="#spectrum-icon-18-Campaign" /></svg><span>', |
|||
locationTitle: "Show Your Location", |
|||
}, |
|||
onAdd: function () { |
|||
var locationClassName = "leaflet-control-location", |
|||
container = L.DomUtil.create("div", locationClassName + " leaflet-bar"), |
|||
options = this.options |
|||
|
|||
this._locationButton = this._createButton( |
|||
options.locationContent, |
|||
options.locationTitle, |
|||
"map-location", |
|||
container, |
|||
this._location |
|||
) |
|||
|
|||
this._updateDisabled() |
|||
|
|||
return container |
|||
}, |
|||
disable: function () { |
|||
this._disabled = true |
|||
this._updateDisabled() |
|||
return this |
|||
}, |
|||
enable: function () { |
|||
this._disabled = false |
|||
this._updateDisabled() |
|||
return this |
|||
}, |
|||
_location: function () { |
|||
if (this._disabled == true) { |
|||
return |
|||
} |
|||
this.disable() |
|||
|
|||
const success = pos => { |
|||
this._map.closePopup() |
|||
if (typeof this.options.onLocationSuccess === "function") { |
|||
this.options.onLocationSuccess({ |
|||
lat: pos.coords.latitude, |
|||
lng: pos.coords.longitude, |
|||
}) |
|||
} |
|||
} |
|||
|
|||
const error = err => { |
|||
if (typeof this.options.onLocationFail === "function") { |
|||
this.options.onLocationFail(err) |
|||
} |
|||
} |
|||
|
|||
this._getPosition() |
|||
.then(success) |
|||
.catch(error) |
|||
.finally(() => { |
|||
this.enable() |
|||
}) |
|||
}, |
|||
_getPosition: function () { |
|||
var options = { |
|||
enableHighAccuracy: false, |
|||
timeout: 5000, |
|||
maximumAge: 30000, |
|||
} |
|||
|
|||
return new Promise((resolve, reject) => { |
|||
navigator.geolocation.getCurrentPosition(resolve, reject, options) |
|||
}) |
|||
}, |
|||
|
|||
_createButton: createButton, |
|||
|
|||
_updateDisabled: function () { |
|||
let disabledClassName = "leaflet-disabled" |
|||
L.DomUtil.removeClass(this._locationButton, disabledClassName) |
|||
this._locationButton.setAttribute("aria-disabled", "false") |
|||
|
|||
if (this._disabled) { |
|||
L.DomUtil.addClass(this._locationButton, disabledClassName) |
|||
this._locationButton.setAttribute("aria-disabled", "true") |
|||
} |
|||
}, |
|||
}) |
|||
|
|||
const initLocationControl = () => { |
|||
L.Map.mergeOptions({ |
|||
location: false, |
|||
onLocationFail: null, |
|||
onLocationSuccess: null, |
|||
}) |
|||
|
|||
L.Map.addInitHook(function () { |
|||
if (this.options.location) { |
|||
this.localControl = new LocationControl() |
|||
this.addControl(this.LocationControl) |
|||
} else { |
|||
this.localControl = null |
|||
} |
|||
}) |
|||
} |
|||
|
|||
const initMapControls = () => { |
|||
initFullScreenControl() |
|||
initLocationControl() |
|||
} |
|||
|
|||
export { |
|||
initFullScreenControl, |
|||
initLocationControl, |
|||
initMapControls, |
|||
FullScreenControl, |
|||
LocationControl, |
|||
} |
|||
Loading…
Reference in new issue