|
|
|
@ -2,8 +2,8 @@ |
|
|
|
import L from "leaflet" |
|
|
|
import sanitizeHtml from "sanitize-html" |
|
|
|
import "leaflet/dist/leaflet.css" |
|
|
|
import { Helpers } from "@budibase/bbui" |
|
|
|
import { getContext } from "svelte" |
|
|
|
import { Helpers, Button } from "@budibase/bbui" |
|
|
|
import { onMount, getContext } from "svelte" |
|
|
|
import { |
|
|
|
FullScreenControl, |
|
|
|
LocationControl, |
|
|
|
@ -24,64 +24,136 @@ |
|
|
|
export let defaultLocation |
|
|
|
export let tileURL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" |
|
|
|
export let mapAttribution |
|
|
|
export let onMarkerClick |
|
|
|
export let onMapClick |
|
|
|
export let creationEnabled = false |
|
|
|
export let onClickMarker |
|
|
|
export let onCreateMarker |
|
|
|
|
|
|
|
const { styleable, notificationStore } = getContext("sdk") |
|
|
|
const component = getContext("component") |
|
|
|
const embeddedMapId = `${Helpers.uuid()}-wrapper` |
|
|
|
|
|
|
|
let cachedDeviceCoordinates |
|
|
|
// Map Button Controls |
|
|
|
const 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) |
|
|
|
} |
|
|
|
}, |
|
|
|
}) |
|
|
|
const fullScreenControl = new FullScreenControl({ |
|
|
|
position: "topright", |
|
|
|
}) |
|
|
|
const zoomControl = L.control.zoom({ |
|
|
|
position: "bottomright", |
|
|
|
}) |
|
|
|
|
|
|
|
// Map and marker configuration |
|
|
|
const defaultMarkerOptions = { |
|
|
|
html: |
|
|
|
'<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>', |
|
|
|
className: "embedded-map-marker", |
|
|
|
iconSize: [26, 26], |
|
|
|
iconAnchor: [13, 26], |
|
|
|
popupAnchor: [0, -13], |
|
|
|
} |
|
|
|
const mapMarkerOptions = { |
|
|
|
icon: L.divIcon(defaultMarkerOptions), |
|
|
|
draggable: false, |
|
|
|
alt: "Location Marker", |
|
|
|
} |
|
|
|
const candidateMarkerOptions = { |
|
|
|
icon: L.divIcon({ |
|
|
|
...defaultMarkerOptions, |
|
|
|
className: "embedded-map-marker--candidate", |
|
|
|
}), |
|
|
|
draggable: false, |
|
|
|
alt: "Location Marker", |
|
|
|
} |
|
|
|
const mapOptions = { |
|
|
|
fullScreen: false, |
|
|
|
zoomControl: false, |
|
|
|
scrollWheelZoom: zoomEnabled, |
|
|
|
minZoomLevel, |
|
|
|
maxZoomLevel, |
|
|
|
} |
|
|
|
const fallbackCoordinates = [51.5072, -0.1276] //London |
|
|
|
|
|
|
|
let mapInstance |
|
|
|
let mapMarkerGroup = new L.FeatureGroup() |
|
|
|
let mapMarkers = [] |
|
|
|
let loaded = false |
|
|
|
|
|
|
|
let candidateMarkerGroup = new L.FeatureGroup() |
|
|
|
let candidateMarkerPosition |
|
|
|
let mounted = false |
|
|
|
let initialMarkerZoomCompleted = false |
|
|
|
let minZoomLevel = 0 |
|
|
|
let maxZoomLevel = 18 |
|
|
|
let adjustedZoomLevel = !Number.isInteger(zoomLevel) |
|
|
|
? 72 |
|
|
|
: Math.round(zoomLevel * (maxZoomLevel / 100)) |
|
|
|
let cachedDeviceCoordinates |
|
|
|
|
|
|
|
$: validRows = getValidRows(dataProvider?.rows, latitudeKey, longitudeKey) |
|
|
|
$: safeZoomLevel = parseZoomLevel(zoomLevel) |
|
|
|
$: defaultCoordinates = parseDefaultLocation(defaultLocation) |
|
|
|
$: initMap(tileURL, mapAttribution, safeZoomLevel) |
|
|
|
$: zoomControlUpdated(mapInstance, zoomEnabled) |
|
|
|
$: locationControlUpdated(mapInstance, locationEnabled) |
|
|
|
$: fullScreenControlUpdated(mapInstance, fullScreenEnabled) |
|
|
|
$: updateMapDimensions( |
|
|
|
mapInstance, |
|
|
|
$component.styles.normal.width, |
|
|
|
$component.styles.normal.height |
|
|
|
) |
|
|
|
$: width = $component.styles.normal.width |
|
|
|
$: height = $component.styles.normal.height |
|
|
|
$: width, height, mapInstance?.invalidateSize() |
|
|
|
$: defaultCoordinates, resetView() |
|
|
|
$: addMapMarkers( |
|
|
|
mapInstance, |
|
|
|
dataProvider?.rows, |
|
|
|
validRows, |
|
|
|
latitudeKey, |
|
|
|
longitudeKey, |
|
|
|
titleKey, |
|
|
|
onMarkerClick |
|
|
|
onClickMarker |
|
|
|
) |
|
|
|
$: if (!loaded && typeof mapInstance === "object" && mapMarkers.length > 0) { |
|
|
|
loaded = true |
|
|
|
mapInstance.setZoom(0) |
|
|
|
mapInstance.fitBounds(mapMarkerGroup.getBounds(), { |
|
|
|
paddingTopLeft: [0, 24], |
|
|
|
}) |
|
|
|
|
|
|
|
const isValidLatitude = value => { |
|
|
|
return !isNaN(value) && value > -90 && value < 90 |
|
|
|
} |
|
|
|
|
|
|
|
const updateMapDimensions = mapInstance => { |
|
|
|
if (typeof mapInstance !== "object") { |
|
|
|
return |
|
|
|
} |
|
|
|
mapInstance.invalidateSize() |
|
|
|
const isValidLongitude = value => { |
|
|
|
return !isNaN(value) && value > -180 && value < 180 |
|
|
|
} |
|
|
|
|
|
|
|
let isValidLatitude = value => { |
|
|
|
return !isNaN(value) && value > -90 && value < 90 |
|
|
|
const getValidRows = (rows, latKey, lngKey) => { |
|
|
|
if (!rows?.length || !latKey || !lngKey) { |
|
|
|
return [] |
|
|
|
} |
|
|
|
return rows.filter(row => { |
|
|
|
return isValidLatitude(row[latKey]) && isValidLongitude(row[lngKey]) |
|
|
|
}) |
|
|
|
} |
|
|
|
|
|
|
|
let isValidLongitude = value => { |
|
|
|
return !isNaN(value) && value > -180 && value < 180 |
|
|
|
const parseZoomLevel = zoomLevel => { |
|
|
|
let zoom = zoomLevel |
|
|
|
if (zoom == null || isNaN(zoom)) { |
|
|
|
zoom = 50 |
|
|
|
} else { |
|
|
|
zoom = parseFloat(zoom) |
|
|
|
zoom = Math.max(0, Math.min(100, zoom)) |
|
|
|
} |
|
|
|
return Math.round((zoom * maxZoomLevel) / 100) |
|
|
|
} |
|
|
|
|
|
|
|
const parseDefaultLocation = defaultLocation => { |
|
|
|
@ -102,44 +174,19 @@ |
|
|
|
: 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 resetView = () => { |
|
|
|
if (!mapInstance) { |
|
|
|
return |
|
|
|
} |
|
|
|
if (mapMarkerGroup.getLayers().length) { |
|
|
|
mapInstance.setZoom(0) |
|
|
|
mapInstance.fitBounds(mapMarkerGroup.getBounds(), { |
|
|
|
paddingTopLeft: [0, 24], |
|
|
|
}) |
|
|
|
} else { |
|
|
|
mapInstance.setView(defaultCoordinates, safeZoomLevel) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
const locationControlUpdated = (mapInstance, locationEnabled) => { |
|
|
|
if (typeof mapInstance !== "object") { |
|
|
|
@ -176,51 +223,21 @@ |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
//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, |
|
|
|
validRows, |
|
|
|
latKey, |
|
|
|
lngKey, |
|
|
|
titleKey, |
|
|
|
onClick |
|
|
|
) => { |
|
|
|
if (typeof mapInstance !== "object" || !rows || !latKey || !lngKey) { |
|
|
|
if (!mapInstance || !validRows?.length) { |
|
|
|
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], |
|
|
|
@ -242,57 +259,91 @@ |
|
|
|
}) |
|
|
|
}) |
|
|
|
} |
|
|
|
|
|
|
|
mapMarkers = [...mapMarkers, marker] |
|
|
|
}) |
|
|
|
|
|
|
|
// Zoom to markers if this is the first time |
|
|
|
if (!initialMarkerZoomCompleted) { |
|
|
|
resetView() |
|
|
|
initialMarkerZoomCompleted = true |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
const generateMarkerPopupContent = (latitude, longitude, text) => { |
|
|
|
return text || latitude + "," + longitude |
|
|
|
} |
|
|
|
|
|
|
|
const initMap = () => { |
|
|
|
const initCoords = defaultCoordinates |
|
|
|
|
|
|
|
const initMap = (tileURL, attribution, zoom) => { |
|
|
|
if (!mounted) { |
|
|
|
return |
|
|
|
} |
|
|
|
if (mapInstance) { |
|
|
|
mapInstance.remove() |
|
|
|
} |
|
|
|
mapInstance = L.map(embeddedMapId, mapOptions) |
|
|
|
mapMarkerGroup.addTo(mapInstance) |
|
|
|
candidateMarkerGroup.addTo(mapInstance) |
|
|
|
|
|
|
|
const cleanAttribution = sanitizeHtml(mapAttribution, { |
|
|
|
// Add attribution |
|
|
|
const cleanAttribution = sanitizeHtml(attribution, { |
|
|
|
allowedTags: ["a"], |
|
|
|
allowedAttributes: { |
|
|
|
a: ["href", "target"], |
|
|
|
}, |
|
|
|
}) |
|
|
|
|
|
|
|
L.tileLayer(tileURL, { |
|
|
|
attribution: "© " + cleanAttribution, |
|
|
|
zoom: adjustedZoomLevel, |
|
|
|
zoom, |
|
|
|
}).addTo(mapInstance) |
|
|
|
|
|
|
|
//Initialise the map |
|
|
|
mapInstance.setView(initCoords, adjustedZoomLevel) |
|
|
|
|
|
|
|
// Add click handler |
|
|
|
mapInstance?.on("click", e => { |
|
|
|
console.log("map clicked!") |
|
|
|
if (onMapClick) { |
|
|
|
onMapClick({ |
|
|
|
lat: e.latlng.lat, |
|
|
|
lng: e.latlng.lng, |
|
|
|
}) |
|
|
|
} |
|
|
|
}) |
|
|
|
mapInstance.on("click", handleMapClick) |
|
|
|
|
|
|
|
// Reset view |
|
|
|
resetView() |
|
|
|
} |
|
|
|
|
|
|
|
const mapAction = () => { |
|
|
|
initMap() |
|
|
|
return { |
|
|
|
destroy() { |
|
|
|
mapInstance.remove() |
|
|
|
mapInstance = undefined |
|
|
|
}, |
|
|
|
const handleMapClick = e => { |
|
|
|
if (!creationEnabled) { |
|
|
|
return |
|
|
|
} |
|
|
|
candidateMarkerGroup.clearLayers() |
|
|
|
candidateMarkerPosition = [e.latlng.lat, e.latlng.lng] |
|
|
|
let candidateMarker = L.marker( |
|
|
|
candidateMarkerPosition, |
|
|
|
candidateMarkerOptions |
|
|
|
) |
|
|
|
candidateMarker |
|
|
|
.bindTooltip("New marker", { |
|
|
|
permanent: true, |
|
|
|
direction: "top", |
|
|
|
offset: [0, -25], |
|
|
|
}) |
|
|
|
.addTo(candidateMarkerGroup) |
|
|
|
.on("click", clearCandidateMarker) |
|
|
|
} |
|
|
|
|
|
|
|
const createMarker = async () => { |
|
|
|
if (!onCreateMarker) { |
|
|
|
return |
|
|
|
} |
|
|
|
const res = await onCreateMarker({ |
|
|
|
lat: candidateMarkerPosition[0], |
|
|
|
lng: candidateMarkerPosition[1], |
|
|
|
}) |
|
|
|
if (res !== false) { |
|
|
|
clearCandidateMarker() |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
const clearCandidateMarker = () => { |
|
|
|
candidateMarkerGroup.clearLayers() |
|
|
|
candidateMarkerPosition = null |
|
|
|
} |
|
|
|
|
|
|
|
onMount(() => { |
|
|
|
mounted = true |
|
|
|
initMap(tileURL, mapAttribution, safeZoomLevel) |
|
|
|
}) |
|
|
|
</script> |
|
|
|
|
|
|
|
<div class="embedded-map-wrapper map-default" use:styleable={$component.styles}> |
|
|
|
@ -300,7 +351,14 @@ |
|
|
|
<div>{error}</div> |
|
|
|
{/if} |
|
|
|
|
|
|
|
<div id={embeddedMapId} class="embedded embedded-map" use:mapAction /> |
|
|
|
<div id={embeddedMapId} class="embedded embedded-map" /> |
|
|
|
|
|
|
|
{#if candidateMarkerPosition} |
|
|
|
<div class="button-container"> |
|
|
|
<Button secondary quiet on:click={clearCandidateMarker}>Cancel</Button> |
|
|
|
<Button cta on:click={createMarker}>Create marker</Button> |
|
|
|
</div> |
|
|
|
{/if} |
|
|
|
</div> |
|
|
|
|
|
|
|
<style> |
|
|
|
@ -318,6 +376,9 @@ |
|
|
|
.embedded-map :global(.embedded-map-marker) { |
|
|
|
color: #ee3b35; |
|
|
|
} |
|
|
|
.embedded-map :global(.embedded-map-marker--candidate) { |
|
|
|
color: var(--primaryColor); |
|
|
|
} |
|
|
|
.embedded-map :global(.embedded-map-control) { |
|
|
|
font-size: 22px; |
|
|
|
} |
|
|
|
@ -325,4 +386,12 @@ |
|
|
|
height: 100%; |
|
|
|
width: 100%; |
|
|
|
} |
|
|
|
.button-container { |
|
|
|
display: flex; |
|
|
|
flex-direction: row; |
|
|
|
justify-content: center; |
|
|
|
align-items: center; |
|
|
|
gap: var(--spacing-xl); |
|
|
|
margin-top: var(--spacing-xl); |
|
|
|
} |
|
|
|
</style> |
|
|
|
|