diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index 197928b25..1351ab34e 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -1462,6 +1462,10 @@ "__name": "SCANNER_SCAN_NEXT_SCAN_NEXT_SLEEPTIME", "__format": "number" }, + "nineCellScan": { + "__name": "SCANNER_SCAN_NEXT_NINE_CELL_SCAN", + "__format": "boolean" + }, "userCooldownSeconds": { "__name": "SCANNER_SCAN_NEXT_USER_COOLDOWN_SECONDS", "__format": "number" @@ -2327,4 +2331,4 @@ } } } -} \ No newline at end of file +} diff --git a/config/default.json b/config/default.json index c1f2b8d8d..8ce4e60b8 100644 --- a/config/default.json +++ b/config/default.json @@ -631,6 +631,7 @@ "scanNextInstance": "scanNext", "scanNextDevice": "Device01", "scanNextSleeptime": 5, + "nineCellScan": false, "userCooldownSeconds": 0, "scanNextAreaRestriction": [], "discordRoles": [], diff --git a/packages/locales/lib/human/en.json b/packages/locales/lib/human/en.json index c2e936cda..121554947 100644 --- a/packages/locales/lib/human/en.json +++ b/packages/locales/lib/human/en.json @@ -539,6 +539,7 @@ "loading_invasions": "Fetching Invasions", "scan_next": "Scan Location", "scan_next_choose": "Drag and Drop the Marker to Set the Scan Location", + "scan_next_size_9x9": "9x9", "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", diff --git a/packages/types/lib/config.d.ts b/packages/types/lib/config.d.ts index 3c17ecf67..26309f65e 100644 --- a/packages/types/lib/config.d.ts +++ b/packages/types/lib/config.d.ts @@ -136,6 +136,7 @@ export type Config = DeepMerge< discordRoles: string[] telegramGroups: string[] local: string[] + nineCellScan?: boolean } scanZone: { discordRoles: string[] diff --git a/server/src/graphql/resolvers.js b/server/src/graphql/resolvers.js index a0b4705bf..a62d19c86 100644 --- a/server/src/graphql/resolvers.js +++ b/server/src/graphql/resolvers.js @@ -415,6 +415,7 @@ const resolvers = { gymRadius: scanner.scanZone.scanZoneRadius.gym, spacing: scanner.scanZone.scanZoneSpacing, maxSize: scanner.scanZone.scanZoneMaxSize, + nineCellScan: false, cooldown: scanner.scanZone.userCooldownSeconds, refreshQueue: scanner.backendConfig.queueRefreshInterval, enabled: scanner[mode].enabled, @@ -424,6 +425,7 @@ const resolvers = { showScanCount: scanner.scanNext.showScanCount, showScanQueue: scanner.scanNext.showScanQueue, cooldown: scanner.scanNext.userCooldownSeconds, + nineCellScan: scanner.scanNext.nineCellScan, refreshQueue: scanner.backendConfig.queueRefreshInterval, enabled: scanner[mode].enabled, } diff --git a/server/src/graphql/typeDefs/map.graphql b/server/src/graphql/typeDefs/map.graphql index 4621b1c95..72b7a67c9 100644 --- a/server/src/graphql/typeDefs/map.graphql +++ b/server/src/graphql/typeDefs/map.graphql @@ -203,6 +203,7 @@ type ScannerConfig { gymRadius: Int spacing: Int maxSize: Int + nineCellScan: Boolean refreshQueue: Int enabled: Boolean } diff --git a/src/features/scanner/ContextProvider.jsx b/src/features/scanner/ContextProvider.jsx index bb89597b5..621d557b4 100644 --- a/src/features/scanner/ContextProvider.jsx +++ b/src/features/scanner/ContextProvider.jsx @@ -11,6 +11,7 @@ export const DEFAULT = /** @type {import('./hooks/store').ScanConfig} */ ({ gymRadius: 750, spacing: 1, maxSize: 1, + nineCellScan: false, cooldown: 1, refreshQueue: 5, }) diff --git a/src/features/scanner/ScanOnDemand.jsx b/src/features/scanner/ScanOnDemand.jsx index 9214f2209..617886450 100644 --- a/src/features/scanner/ScanOnDemand.jsx +++ b/src/features/scanner/ScanOnDemand.jsx @@ -130,7 +130,7 @@ function BaseScanOnDemand({ mode }) { next.scanZoneSize !== prev.scanZoneSize || next.scanLocation.some((x, i) => x !== prev.scanLocation[i])) ) { - const scanCoords = + const { coords: scanCoords, mask: scanCircleMask } = mode === 'scanZone' ? getScanZoneCoords( next.scanLocation, @@ -138,14 +138,16 @@ function BaseScanOnDemand({ mode }) { next.userSpacing, next.scanZoneSize, ) - : getScanNextCoords(next.scanLocation, next.scanNextSize) - useScanStore.setState({ scanCoords }) + : getScanNextCoords(next.scanLocation, next.scanNextSize, { + nineCellScan: config.nineCellScan, + }) + useScanStore.setState({ scanCoords, scanCircleMask }) } }) return () => { subscription() } - }, [mode]) + }, [mode, config.nineCellScan]) if (scanMode !== 'setLocation' || !config.scannerType) return null diff --git a/src/features/scanner/Shared.jsx b/src/features/scanner/Shared.jsx index fd8095040..24810208e 100644 --- a/src/features/scanner/Shared.jsx +++ b/src/features/scanner/Shared.jsx @@ -163,9 +163,13 @@ export function ScanCircles({ radius }) { const scanCoords = useScanStore((s) => s.scanCoords) const userRadius = useScanStore((s) => s.userRadius) const validCoords = useScanStore((s) => s.validCoords) + const scanCircleMask = useScanStore((s) => s.scanCircleMask) const finalRadius = radius || userRadius return scanCoords.map((coords, i) => { + if (scanCircleMask.length && !scanCircleMask[i]) { + return null + } const finalColor = finalRadius <= 70 ? validCoords[i] diff --git a/src/features/scanner/hooks/store.js b/src/features/scanner/hooks/store.js index 7902a8f9f..6c7906ca3 100644 --- a/src/features/scanner/hooks/store.js +++ b/src/features/scanner/hooks/store.js @@ -15,6 +15,7 @@ import { createJSONStorage, persist } from 'zustand/middleware' * gymRadius: number, * spacing: number, * maxSize: number, + * nineCellScan: boolean, * cooldown: number, * refreshQueue: number * enabled: boolean, @@ -25,6 +26,7 @@ import { createJSONStorage, persist } from 'zustand/middleware' * queue: 'init' | '...' | number, * scanLocation: [number, number], * scanCoords: [number, number][], + * scanCircleMask: boolean[], * validCoords: boolean[], * scanNextSize: 'S' | 'M' | 'L' | 'XL', * scanZoneSize: number, @@ -43,6 +45,7 @@ export const useScanStore = create((set) => ({ queue: 'init', scanLocation: [0, 0], scanCoords: [], + scanCircleMask: [], validCoords: [], scanNextSize: 'S', scanZoneSize: 1, diff --git a/src/features/scanner/scanNext/PopupContent.jsx b/src/features/scanner/scanNext/PopupContent.jsx index 59060996f..38482a49c 100644 --- a/src/features/scanner/scanNext/PopupContent.jsx +++ b/src/features/scanner/scanNext/PopupContent.jsx @@ -8,10 +8,12 @@ import { useTranslation } from 'react-i18next' import { SCAN_SIZES } from '@assets/constants' import { useScanStore } from '../hooks/store' +import { ConfigContext } from '../ContextProvider' export function ScanNextPopup() { const { t } = useTranslation() const scanNextSize = useScanStore((s) => s.scanNextSize) + const { nineCellScan } = React.useContext(ConfigContext) const setSize = React.useCallback( (/** @type {typeof SCAN_SIZES[number]} */ size) => () => { @@ -30,7 +32,7 @@ export function ScanNextPopup() { color={size === scanNextSize ? 'primary' : 'secondary'} variant={size === scanNextSize ? 'contained' : 'outlined'} > - {t(size)} + {size === 'XL' && nineCellScan ? t('scan_next_size_9x9') : t(size)} ))} diff --git a/src/features/scanner/scanNext/getCoords.js b/src/features/scanner/scanNext/getCoords.js index 04c4ff491..50e1ac74c 100644 --- a/src/features/scanner/scanNext/getCoords.js +++ b/src/features/scanner/scanNext/getCoords.js @@ -1,35 +1,116 @@ // @ts-check import { point } from '@turf/helpers' import destination from '@turf/destination' +import { S2CellId, S2LatLng } from 'nodes2ts' + +/** + * @typedef {{ coords: import('../hooks/store').UseScanStore['scanCoords'], mask: boolean[] }} ScanCoordResult + */ const OPTIONS = /** @type {const} */ ({ units: 'kilometers' }) const POKEMON_RADIUS = 70 const GYM_RADIUS = 750 -const DISTANCE = { +const HEX_DISTANCE = { M: POKEMON_RADIUS * 1.732, XL: GYM_RADIUS * 1.732, } +const NINE_CELL_LEVEL = 15 +const NINE_CELL_SIZE = 9 +const NINE_CELL_RADIUS = (NINE_CELL_SIZE - 1) / 2 + +/** + * Build a 9x9 grid of S2 level-15 cell centers around the selected point. + * @param {[number, number]} center + * @returns {ScanCoordResult} + */ +const getNineCellCoords = (center) => { + const latLng = S2LatLng.fromDegrees(center[0], center[1]) + const baseCell = S2CellId.fromPoint(latLng.toPoint()).parentL(NINE_CELL_LEVEL) + const size = baseCell.getSizeIJ() + const baseIJO = baseCell.toIJOrientation() + const baseI = S2CellId.getI(baseIJO) + const baseJ = S2CellId.getJ(baseIJO) + const { face } = baseCell + + const offsets = [] + for (let di = -NINE_CELL_RADIUS; di <= NINE_CELL_RADIUS; di += 1) { + for (let dj = -NINE_CELL_RADIUS; dj <= NINE_CELL_RADIUS; dj += 1) { + offsets.push({ + di, + dj, + isPerimeter: + Math.abs(di) === NINE_CELL_RADIUS || + Math.abs(dj) === NINE_CELL_RADIUS, + manhattan: Math.abs(di) + Math.abs(dj), + chebyshev: Math.max(Math.abs(di), Math.abs(dj)), + }) + } + } + + offsets.sort((a, b) => { + if (a.manhattan !== b.manhattan) return a.manhattan - b.manhattan + if (a.chebyshev !== b.chebyshev) return a.chebyshev - b.chebyshev + if (a.di !== b.di) return a.di - b.di + return a.dj - b.dj + }) + + const coords = offsets.map(({ di, dj }) => { + const targetI = baseI + di * size + const targetJ = baseJ + dj * size + const sameFace = + targetI >= 0 && + targetI < S2CellId.MAX_SIZE && + targetJ >= 0 && + targetJ < S2CellId.MAX_SIZE + const cell = ( + sameFace + ? S2CellId.fromFaceIJ(face, targetI, targetJ) + : S2CellId.fromFaceIJWrap(face, targetI, targetJ) + ).parentL(NINE_CELL_LEVEL) + const pointLatLng = cell.toLatLng() + return [pointLatLng.latDegrees, pointLatLng.lngDegrees] + }) + + const mask = offsets.map(({ isPerimeter }) => isPerimeter) + + return { coords, mask } +} + /** * Get scan next coords * @param {[number, number]} center * @param {import('../hooks/store').UseScanStore['scanNextSize']} size - * @returns {import('../hooks/store').UseScanStore['scanCoords']} + * @param {{ nineCellScan?: boolean }} [options] + * @returns {ScanCoordResult} */ -export const getScanNextCoords = (center, size) => { +export const getScanNextCoords = (center, size, options = {}) => { + if (size === 'XL' && options.nineCellScan) { + return getNineCellCoords(center) + } + const coords = [center] - if (size === 'S') return coords + if (size === 'S') { + return { coords, mask: [true] } + } + + const distance = HEX_DISTANCE[size] + if (!distance) { + return { coords, mask: coords.map(() => true) } + } + const start = point([center[1], center[0]]) - return coords.concat( + const extended = coords.concat( [0, 60, 120, 180, 240, 300].map((bearing) => { - const [lon, lat] = destination( - start, - DISTANCE[size] / 1000, - bearing, - OPTIONS, - ).geometry.coordinates + const [lon, lat] = destination(start, distance / 1000, bearing, OPTIONS) + .geometry.coordinates return [lat, lon] }), ) + + return { + coords: extended, + mask: extended.map(() => true), + } } diff --git a/src/features/scanner/scanZone/getCoords.js b/src/features/scanner/scanZone/getCoords.js index 6264523ee..4cc99cec7 100644 --- a/src/features/scanner/scanZone/getCoords.js +++ b/src/features/scanner/scanZone/getCoords.js @@ -19,7 +19,7 @@ const BEARINGS = { * @param {number} radius * @param {number} spacing * @param {import('../hooks/store').UseScanStore['scanZoneSize']} scanZoneSize - * @returns + * @returns {{ coords: import('../hooks/store').UseScanStore['scanCoords'], mask: boolean[] }} */ export const getScanZoneCoords = (center, radius, spacing, scanZoneSize) => { const coords = [center] @@ -43,5 +43,8 @@ export const getScanZoneCoords = (center, radius, spacing, scanZoneSize) => { step += 1 } } - return coords + return { + coords, + mask: coords.map(() => true), + } } diff --git a/src/services/queries/scanner.js b/src/services/queries/scanner.js index 765b6a4ad..2a9c74c4a 100644 --- a/src/services/queries/scanner.js +++ b/src/services/queries/scanner.js @@ -23,6 +23,7 @@ export const SCANNER_CONFIG = gql` gymRadius spacing maxSize + nineCellScan refreshQueue enabled }