diff --git a/package.json b/package.json
index ed0c823da..b4e19cf40 100644
--- a/package.json
+++ b/package.json
@@ -32,7 +32,7 @@
"@sentry/cli": "^1.73.0",
"dotenv": "^10.0.0",
"esbuild": "^0.14.22",
- "esbuild-plugin-eslinter": "^0.1.1",
+ "esbuild-plugin-eslinter": "^0.1.2",
"esbuild-plugin-mxn-copy": "^1.0.1",
"esbuild-plugin-path-alias": "^1.0.3",
"eslint": "^8.9.0",
@@ -54,6 +54,7 @@
"@sentry/tracing": "^6.18.2",
"@turf/boolean-point-in-polygon": "^6.5.0",
"@turf/center": "^6.3.0",
+ "@turf/destination": "^6.5.0",
"@turf/helpers": "^6.5.0",
"apollo-link-timeout": "^4.0.0",
"apollo-server-core": "^3.5.0",
diff --git a/public/base-locales/de.json b/public/base-locales/de.json
index 22d7b3d85..6cb8f3c5e 100644
--- a/public/base-locales/de.json
+++ b/public/base-locales/de.json
@@ -490,6 +490,24 @@
"loading_icons": "Icons abrufen",
"loading_invasions": "Rocket-Lineup abrufen",
"pvp_ranking_cap": "Level",
+ "scan_next": "Standort scannen",
+ "scan_next_choose": "Ziehen Sie die Markierung per Drag & Drop um die Scanposition festzulegen",
+ "scan_zone": "Scanne ein Gebiet",
+ "scan_zone_choose": "Ziehen Sie die Markierung per Drag & Drop um die Scanposition festzulegen und die Größe zu wählen",
+ "scan_zone_size": "Größe",
+ "scan_zone_range": "Reichweite",
+ "scan_zone_spacing": "Abstand",
+ "scan_zone_radius": "Radius",
+ "scan_requests": "Scan Anfrage",
+ "scan_queue": "Aktuelle Warteschlange",
+ "click_to_scan": "Hier scannen",
+ "scan_confirmed_title": "Scan-Anfrage bestätigt",
+ "scan_confirmed": "Gerät wurde an den Standort geschickt, das Ergebnis wird bald auf der Karte erscheinen!",
+ "scan_loading_title": "Sende Scan-Anfrage",
+ "scan_loading": "Deine Scan-Anfrage wird bearbeitet und wurde erfolgreich abgeschickt!",
+ "scan_error_title": "Fehler",
+ "scan_error": "Es ist ein Fehler bei der Verarbeitung der Scan-Anfrage aufgetreten...",
+ "scan_outside_area": "Dieser Standort liegt außerhalb der Grenzen der zugelassenen Gebiete",
"device_icons": "Gerätesymbole",
"spawnpoint_icons": "Spawnpunkt-Symbole",
"disabled": "Deaktiviert",
diff --git a/public/base-locales/en.json b/public/base-locales/en.json
index db2d7666e..bd14284e3 100644
--- a/public/base-locales/en.json
+++ b/public/base-locales/en.json
@@ -493,6 +493,24 @@
"loading": "Loading {{category}}",
"loading_icons": "Fetching Icons",
"loading_invasions": "Fetching Invasions",
+ "scan_next": "Scan Location",
+ "scan_next_choose": "Drag and Drop the Marker to Set the Scan Location",
+ "scan_zone": "Scan an Area",
+ "scan_zone_choose": "Drag and Drop the Marker to Set the Scan Location and Choose the Size",
+ "scan_zone_size": "Size",
+ "scan_zone_range": "Range",
+ "scan_zone_spacing": "Spacing",
+ "scan_zone_radius": "Radius",
+ "scan_requests": "Scan Requests",
+ "scan_queue": "Current Queue",
+ "click_to_scan": "Scan Here",
+ "scan_confirmed_title": "Scan demand confirmed",
+ "scan_confirmed": "Worker has been sent to location, result will soon appear on the map!",
+ "scan_loading_title": "Sending scan request",
+ "scan_loading": "Your scan request is being processed and sent to the system!",
+ "scan_error_title": "Error",
+ "scan_error": "There has been an error while processing the scan request...",
+ "scan_outside_area": "This location is outside the boundaries of authorized areas",
"pvp_ranking_cap": "Level",
"lc_title": "Follow Your Location",
"lc_metersUnit": "meters",
diff --git a/public/base-locales/fr.json b/public/base-locales/fr.json
index 28f03c13f..25f6f0636 100644
--- a/public/base-locales/fr.json
+++ b/public/base-locales/fr.json
@@ -484,6 +484,24 @@
"loading_invasions": "Récupération des Invasions",
"login_button": 12,
"join_button": 12,
+ "scan_next": "Scanner un emplacement",
+ "scan_next_choose": "Glisser et déposer le marqueur pour définir l'emplacement de scan",
+ "scan_zone": "Scanner une zone",
+ "scan_zone_choose": "Glisser et déposer le marqueur pour définir l'emplacement et choisissez la taille du scan",
+ "scan_zone_size": "Taille",
+ "scan_zone_range": "Portée",
+ "scan_zone_spacing": "Espacement",
+ "scan_zone_radius": "Rayon",
+ "scan_requests": "Demandes de Scan ",
+ "scan_queue": "File d'attente ",
+ "click_to_scan": "Scanner ici",
+ "scan_confirmed_title": "Demande de scan confirmée",
+ "scan_confirmed": "L'appareil a été envoyé à la position de scan, le résultat sera bientôt visible sur la map !",
+ "scan_loading_title": "Envoi de la demande de scan",
+ "scan_loading": "Votre demande de scan est analysée et transmise au système !",
+ "scan_error_title": "Erreur",
+ "scan_error": "Il y a eu une erreur lors du traitement de la demande de scan...",
+ "scan_outside_area": "Cet emplacement est en dehors des zones autorisées",
"pvp_ranking_cap": "Niveau",
"device_icons": "Icônes d'appareils",
"spawnpoint_icons": "Icônes de points d'apparition",
diff --git a/server/src/configs/default.json b/server/src/configs/default.json
index 20ab2d7f4..e26123c37 100644
--- a/server/src/configs/default.json
+++ b/server/src/configs/default.json
@@ -369,6 +369,39 @@
}
]
},
+ "scanner": {
+ "backendConfig": {
+ "platform": "rdm/mad",
+ "apiEndpoint": "http://ip:port/api/",
+ "apiUsername": "username",
+ "apiPassword": "password",
+ "queueRefreshInterval": 5
+ },
+ "scanNext": {
+ "enabled": false,
+ "showScanCount": false,
+ "showScanQueue": false,
+ "scanNextInstance": "scanNext",
+ "scanNextDevice": "Device01",
+ "scanNextSleeptime": 5,
+ "scanNextAreaRestriction": [],
+ "discordRoles": [],
+ "telegramGroups": []
+ },
+ "scanZone": {
+ "enabled": false,
+ "showScanCount": false,
+ "showScanQueue": false,
+ "scanZoneMaxSize": 10,
+ "advancedScanZoneOptions": false,
+ "scanZoneRadius": { "pokemon": 70, "gym": 750 },
+ "scanZoneSpacing": 1,
+ "scanZoneInstance": "scanZone",
+ "scanZoneAreaRestriction": [],
+ "discordRoles": [],
+ "telegramGroups": []
+ }
+ },
"webhooks": [],
"authentication": {
"strategies": [
diff --git a/server/src/graphql/resolvers.js b/server/src/graphql/resolvers.js
index 9ae2dcb77..c2c034e52 100644
--- a/server/src/graphql/resolvers.js
+++ b/server/src/graphql/resolvers.js
@@ -284,6 +284,17 @@ module.exports = {
}
return {}
},
+ scanner: (parent, args, { req }) => {
+ const perms = req.user ? req.user.perms : req.session.perms
+ const { category, method, data } = args
+ if (category === 'getQueue') {
+ return Fetch.scannerApi(category, method, data)
+ }
+ if (perms?.scanner?.includes(category)) {
+ return Fetch.scannerApi(category, method, data)
+ }
+ return {}
+ },
},
Mutation: {
webhook: (_, args, { req }) => {
diff --git a/server/src/graphql/scannerTypes.js b/server/src/graphql/scannerTypes.js
index 0313e70ff..fe3f5bd78 100644
--- a/server/src/graphql/scannerTypes.js
+++ b/server/src/graphql/scannerTypes.js
@@ -195,4 +195,9 @@ module.exports = gql`
updated: Int
polygon: [[Float]]
}
+
+ type ScannerApi {
+ status: String
+ message: String
+ }
`
diff --git a/server/src/graphql/typeDefs.js b/server/src/graphql/typeDefs.js
index b2651340a..5241cdd3a 100644
--- a/server/src/graphql/typeDefs.js
+++ b/server/src/graphql/typeDefs.js
@@ -32,6 +32,7 @@ module.exports = gql`
submissionCells(minLat: Float, maxLat: Float, minLon: Float, maxLon: Float, ts: Int, zoom: Int): [SubmissionCell]
weather: [Weather]
webhook(category: String, status: String, name: String): Poracle
+ scanner(category: String, method: String, data: JSON): ScannerApi
}
type Mutation {
diff --git a/server/src/routes/rootRouter.js b/server/src/routes/rootRouter.js
index 300c8eea6..5e977ff2d 100644
--- a/server/src/routes/rootRouter.js
+++ b/server/src/routes/rootRouter.js
@@ -60,6 +60,7 @@ rootRouter.get('/settings', async (req, res) => {
req.session.perms = {
areaRestrictions: Utility.areaPerms(['none']),
webhooks: [],
+ scanner: [],
}
config.authentication.alwaysEnabledPerms.forEach(perm => {
if (config.authentication.perms[perm]) {
@@ -115,6 +116,21 @@ rootRouter.get('/settings', async (req, res) => {
},
manualAreas: config.manualAreas || {},
icons: config.icons,
+ scanner: {
+ scannerType: config.scanner.backendConfig.platform,
+ enableScanNext: config.scanner.scanNext.enabled,
+ scanNextShowScanCount: config.scanner.scanNext.showScanCount,
+ scanNextShowScanQueue: config.scanner.scanNext.showScanQueue,
+ scanNextAreaRestriction: config.scanner.scanNext.scanNextAreaRestriction,
+ enableScanZone: config.scanner.scanZone.enabled,
+ scanZoneShowScanCount: config.scanner.scanZone.showScanCount,
+ scanZoneShowScanQueue: config.scanner.scanZone.showScanQueue,
+ advancedScanZoneOptions: config.scanner.scanZone.advancedScanZoneOptions,
+ scanZoneRadius: config.scanner.scanZone.scanZoneRadius,
+ scanZoneSpacing: config.scanner.scanZone.scanZoneSpacing,
+ scanZoneMaxSize: config.scanner.scanZone.scanZoneMaxSize,
+ scanZoneAreaRestriction: config.scanner.scanZone.scanZoneAreaRestriction,
+ },
gymValidDataLimit: Date.now() / 1000 - (config.api.gymValidDataLimit * 86400),
},
available: {},
@@ -125,7 +141,7 @@ rootRouter.get('/settings', async (req, res) => {
serverSettings.loggedIn = req.user
// keys that are being sent to the frontend but are not options
- const ignoreKeys = ['map', 'manualAreas', 'limit', 'icons', 'gymValidDataLimit']
+ const ignoreKeys = ['map', 'manualAreas', 'limit', 'icons', 'scanner', 'gymValidDataLimit']
Object.keys(serverSettings.config).forEach(setting => {
try {
diff --git a/server/src/services/DiscordClient.js b/server/src/services/DiscordClient.js
index 41929697e..c4b5f182a 100644
--- a/server/src/services/DiscordClient.js
+++ b/server/src/services/DiscordClient.js
@@ -6,7 +6,7 @@
/* eslint-disable import/no-dynamic-require */
/* global BigInt */
const fs = require('fs')
-const { authentication: { alwaysEnabledPerms }, webhooks } = require('./config')
+const { authentication: { alwaysEnabledPerms }, scanner, webhooks } = require('./config')
const Utility = require('./Utility')
module.exports = class DiscordMapClient {
@@ -58,6 +58,7 @@ module.exports = class DiscordMapClient {
const perms = Object.fromEntries(Object.keys(this.config.perms).map(x => [x, false]))
perms.areaRestrictions = []
perms.webhooks = []
+ perms.scanner = []
try {
const { guildsFull } = user
const guilds = user.guilds.map(guild => guild.id)
@@ -65,6 +66,7 @@ module.exports = class DiscordMapClient {
Object.keys(perms).forEach((key) => perms[key] = true)
perms.areaRestrictions = []
perms.webhooks = webhooks.map(x => x.name)
+ perms.scanner = Object.keys(scanner).map(x => x !== 'backendConfig' && scanner[x].enabled && x).filter(x => x !== false)
console.log(`User ${user.username}#${user.discriminator} (${user.id}) in allowed users list, skipping guild and role check.`)
return perms
}
@@ -98,6 +100,7 @@ module.exports = class DiscordMapClient {
}
perms.areaRestrictions.push(...Utility.areaPerms(userRoles, 'discord'))
perms.webhooks.push(...Utility.webhookPerms(userRoles, 'discordRoles'))
+ perms.scanner.push(...Utility.scannerPerms(userRoles, 'discordRoles'))
}
}
if (perms.areaRestrictions.length) {
diff --git a/server/src/services/Fetch.js b/server/src/services/Fetch.js
index 9e4cd8652..f3532261a 100644
--- a/server/src/services/Fetch.js
+++ b/server/src/services/Fetch.js
@@ -3,6 +3,7 @@ const fetchRaids = require('./api/fetchRaids')
const fetchQuests = require('./api/fetchQuests')
const fetchNests = require('./api/fetchNests')
const webhookApi = require('./api/webhookApi')
+const scannerApi = require('./api/scannerApi')
module.exports = class Fetch {
static async json(url, options) {
@@ -24,4 +25,8 @@ module.exports = class Fetch {
static async webhookApi(category, discordId, method, name, data) {
return webhookApi(category, discordId, method, name, data)
}
+
+ static async scannerApi(category, method, data) {
+ return scannerApi(category, method, data)
+ }
}
diff --git a/server/src/services/Utility.js b/server/src/services/Utility.js
index bdf5f7441..f9ac78887 100644
--- a/server/src/services/Utility.js
+++ b/server/src/services/Utility.js
@@ -11,6 +11,7 @@ const webhook = require('./ui/webhook')
const geocoder = require('./geocoder')
const areaPerms = require('./functions/areaPerms')
const webhookPerms = require('./functions/webhookPerms')
+const scannerPerms = require('./functions/scannerPerms')
const mergePerms = require('./functions/mergePerms')
const evalWebhookId = require('./functions/evalWebhookId')
@@ -67,6 +68,10 @@ module.exports = class Utility {
return webhookPerms(roles, provider)
}
+ static scannerPerms(roles, provider) {
+ return scannerPerms(roles, provider)
+ }
+
static mergePerms(existingPerms, incomingPerms = {}) {
return mergePerms(existingPerms, incomingPerms)
}
diff --git a/server/src/services/api/scannerApi.js b/server/src/services/api/scannerApi.js
new file mode 100644
index 000000000..895d329de
--- /dev/null
+++ b/server/src/services/api/scannerApi.js
@@ -0,0 +1,93 @@
+/* eslint-disable no-console */
+const fetch = require('node-fetch')
+const config = require('../config')
+
+const scannerQueue = {
+ scanNext: {},
+ scanZone: {},
+}
+
+module.exports = async function scannerApi(category, method, data = null) {
+ try {
+ const headers = {}
+ switch (config.scanner.backendConfig.platform) {
+ case 'mad':
+ case 'rdm': Object.assign(headers, { Authorization: `Basic ${Buffer.from(`${config.scanner.backendConfig.apiUsername}:${config.scanner.backendConfig.apiPassword}`).toString('base64')}` }); break
+ default: break
+ }
+ const payloadObj = {}
+ switch (category) {
+ case 'scanNext': {
+ console.log(`[scannerApi] Request to scan new location by ${data.username}${data.userId ? ` (${data.userId})` : ''} - type ${data.scanNextType}: ${data.scanNextLocation[0].toFixed(5)},${data.scanNextLocation[1].toFixed(5)}`)
+ const coords = config.scanner.backendConfig.platform === 'mad' ? `${parseFloat(data.scanNextCoords[0][0].toFixed(5))},${parseFloat(data.scanNextCoords[0][1].toFixed(5))}`
+ : JSON.stringify(data.scanNextCoords.map(coord => (
+ { lat: parseFloat(coord[0].toFixed(5)), lon: parseFloat(coord[1].toFixed(5)) })))
+ Object.assign(payloadObj, {
+ url: config.scanner.backendConfig.platform === 'mad' ? `${config.scanner.backendConfig.apiEndpoint}/send_gps?origin=${encodeURIComponent(config.scanner.scanNext.scanNextDevice)}&coords=${coords}&sleeptime=${config.scanner.scanNext.scanNextSleeptime}`
+ : `${config.scanner.backendConfig.apiEndpoint}/set_data?scan_next=true&instance=${encodeURIComponent(config.scanner.scanNext.scanNextInstance)}&coords=${coords}`,
+ options: { method, headers },
+ })
+ } break
+ case 'scanZone': {
+ console.log(`[scannerApi] Request to scan new zone by ${data.username}${data.userId ? ` (${data.userId})` : ''} - size ${data.scanZoneSize}: ${data.scanZoneLocation[0].toFixed(5)},${data.scanZoneLocation[1].toFixed(5)}`)
+ const coords = JSON.stringify(data.scanZoneCoords.map(coord => (
+ { lat: parseFloat(coord[0].toFixed(5)), lon: parseFloat(coord[1].toFixed(5)) })))
+ Object.assign(payloadObj, {
+ url: `${config.scanner.backendConfig.apiEndpoint}/set_data?scan_next=true&instance=${encodeURIComponent(config.scanner.scanZone.scanZoneInstance)}&coords=${coords}`,
+ options: { method, headers },
+ })
+ } break
+ case 'getQueue':
+ if (scannerQueue[data.typeName].timestamp > (Date.now() - config.scanner.backendConfig.queueRefreshInterval * 1000)) {
+ console.log(`[scannerApi] Returning queue from memory for method ${data.typeName}: ${scannerQueue[data.typeName].queue}`)
+ return { status: 'ok', message: scannerQueue[data.typeName].queue }
+ }
+ console.log(`[scannerApi] Getting queue for method ${data.typeName}`)
+ Object.assign(payloadObj, {
+ url: `${config.scanner.backendConfig.apiEndpoint}/get_data?${data.type}=true&queue_size=true&instance=${encodeURIComponent(config.scanner[data.typeName][`${data.typeName}Instance`])}`,
+ options: { method, headers },
+ }); break
+ default:
+ console.warn('[scannerApi] Api call without category'); break
+ }
+
+ if (payloadObj.options.body) {
+ Object.assign(payloadObj.options.headers, { Accept: 'application/json', 'Content-Type': 'application/json' })
+ }
+ const scannerResponse = await fetch(payloadObj.url, payloadObj.options)
+
+ if (!scannerResponse) {
+ throw new Error('[scannerApi] No data returned from server')
+ }
+
+ if (scannerResponse.status === 200 && category === 'getQueue') {
+ const { data: queueData } = await scannerResponse.json()
+ console.log(`[scannerApi] Returning received queue for method ${data.typeName}: ${queueData.size}`)
+ scannerQueue[data.typeName] = { queue: queueData.size, timestamp: Date.now() }
+ return { status: 'ok', message: queueData.size }
+ }
+
+ switch (scannerResponse.status) {
+ case 200:
+ console.log(`[scannerApi] Request from ${data.username}${data.userId ? ` (${data.userId})` : ''} successful`)
+ return { status: 'ok', message: 'scanner_ok' }
+ case 401:
+ console.log('[scannerApi] Wrong credentials - check your scanner API settings in config')
+ return { status: 'error', message: 'scanner_wrong_credentials' }
+ case 404:
+ console.log(`[scannerApi] Error: instance ${config.scanner[category][`${category}Instance`]} does not exist`)
+ return { status: 'error', message: 'scanner_no_instance' }
+ case 416:
+ console.log(`[scannerApi] Error: instance ${config.scanner[category][`${category}Instance`]} has no device assigned`)
+ return { status: 'error', message: 'scanner_no_device_assigned' }
+ case 500:
+ console.log(`[scannerApi] Error: device ${config.scanner[category][`${category}Device`]} does not exist`)
+ return { status: 'error', message: 'scanner_no_device' }
+ default:
+ return { status: 'error', message: 'scanner_error' }
+ }
+ } catch (e) {
+ console.log('[scannerApi] There was a problem processing that scanner request')
+ return { status: 'error', message: 'scanner_error' }
+ }
+}
diff --git a/server/src/services/functions/scannerPerms.js b/server/src/services/functions/scannerPerms.js
new file mode 100644
index 000000000..212f9bb3a
--- /dev/null
+++ b/server/src/services/functions/scannerPerms.js
@@ -0,0 +1,18 @@
+const { scanner } = require('../config')
+
+module.exports = function scannerPerms(roles, provider) {
+ let perms = []
+ if (Object.keys(scanner).length) {
+ roles.forEach(role => {
+ Object.keys(scanner).forEach(mode => {
+ if (scanner[mode][provider]?.includes(role)) {
+ perms.push(mode)
+ }
+ })
+ })
+ }
+ if (perms.length) {
+ perms = [...new Set(perms)]
+ }
+ return perms
+}
diff --git a/server/src/services/sessionStore.js b/server/src/services/sessionStore.js
index cc01e7443..5992c4445 100644
--- a/server/src/services/sessionStore.js
+++ b/server/src/services/sessionStore.js
@@ -44,12 +44,12 @@ const isValidSession = async (userId) => {
return results.length < maxSessions
}
-const clearOtherSessions = async (userId, currentSessionId, botName) => {
+const clearOtherSessions = async (userId, currentSessionId) => {
const results = await Session.query()
.whereRaw(`json_extract(data, '$.passport.user.id') = ${userId}`)
.andWhere('session_id', '!=', currentSessionId || '')
.delete()
- console.log(`[Session${botName && ` - ${botName}`}] Clear Result:`, results)
+ console.log('[Session] Clear Result:', results)
}
const clearDiscordSessions = async (discordId, botName) => {
diff --git a/server/src/strategies/local.js b/server/src/strategies/local.js
index 47b75742b..b85ee0f94 100644
--- a/server/src/strategies/local.js
+++ b/server/src/strategies/local.js
@@ -25,6 +25,7 @@ const authHandler = async (req, username, password, done) => {
),
areaRestrictions: Utility.areaPerms(localPerms, 'local'),
webhooks: [],
+ scanner: [],
},
}
diff --git a/server/src/strategies/telegram.js b/server/src/strategies/telegram.js
index 9a08e3c42..ba5ad5462 100644
--- a/server/src/strategies/telegram.js
+++ b/server/src/strategies/telegram.js
@@ -49,6 +49,7 @@ const authHandler = async (req, profile, done) => {
user.perms.areaRestrictions = Utility.areaPerms(groupInfo, 'telegram')
user.perms.webhooks = Utility.webhookPerms(groupInfo, 'telegramGroups')
+ user.perms.scanner = Utility.scannerPerms(groupInfo, 'telegramGroups')
try {
await User.query()
diff --git a/src/components/Map.jsx b/src/components/Map.jsx
index 4ca9224f3..2f0cfd1c1 100644
--- a/src/components/Map.jsx
+++ b/src/components/Map.jsx
@@ -9,6 +9,8 @@ import { useStatic, useStore } from '@hooks/useStore'
import Nav from './layout/Nav'
import QueryData from './QueryData'
import Webhook from './layout/dialogs/webhooks/Webhook'
+import ScanNext from './layout/dialogs/scanner/ScanNext'
+import ScanZone from './layout/dialogs/scanner/ScanZone'
const userSettingsCategory = category => {
switch (category) {
@@ -32,7 +34,8 @@ const getTileServer = (tileServers, settings, isNight) => {
return tileServers[settings.tileServers] || fallbackTs
}
-export default function Map({ serverSettings: { config: { map: config, tileServers }, Icons, webhooks }, params }) {
+export default function Map({ serverSettings:
+ { config: { map: config, tileServers, scanner }, Icons, webhooks }, params }) {
Utility.analytics(window.location.pathname)
const map = useMap()
@@ -58,6 +61,8 @@ export default function Map({ serverSettings: { config: { map: config, tileServe
const userSettings = useStore(state => state.userSettings)
const [webhookMode, setWebhookMode] = useState(false)
+ const [scanNextMode, setScanNextMode] = useState(false)
+ const [scanZoneMode, setScanZoneMode] = useState(false)
const [manualParams, setManualParams] = useState(params)
const [lc] = useState(L.control.locate({
position: 'bottomright',
@@ -176,6 +181,23 @@ export default function Map({ serverSettings: { config: { map: config, tileServe
})
)
}
+ {scanNextMode && (
+
+ )}
+ {scanZoneMode && (
+
+ )}