From 7f856cd7e9a811107df7a3306605bc72a6e94289 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Sep 2025 18:48:11 +0000 Subject: [PATCH 01/22] chore(deps-dev): bump vite from 6.3.5 to 6.3.6 Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.5 to 6.3.6. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v6.3.6/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v6.3.6/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 6.3.6 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- packages/vite-plugins/package.json | 2 +- yarn.lock | 14 -------------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/vite-plugins/package.json b/packages/vite-plugins/package.json index dd3f39b13..5be7a51cd 100644 --- a/packages/vite-plugins/package.json +++ b/packages/vite-plugins/package.json @@ -16,6 +16,6 @@ "@rm/logger": "*" }, "devDependencies": { - "vite": "^6.2.6" + "vite": "^6.3.6" } } diff --git a/yarn.lock b/yarn.lock index dcc793378..e3655a6ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10021,20 +10021,6 @@ vite-plugin-checker@0.7.2: vscode-languageserver-textdocument "^1.0.1" vscode-uri "^3.0.2" -vite@^6.2.6: - version "6.3.5" - resolved "https://registry.yarnpkg.com/vite/-/vite-6.3.5.tgz#fec73879013c9c0128c8d284504c6d19410d12a3" - integrity sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ== - dependencies: - esbuild "^0.25.0" - fdir "^6.4.4" - picomatch "^4.0.2" - postcss "^8.5.3" - rollup "^4.34.9" - tinyglobby "^0.2.13" - optionalDependencies: - fsevents "~2.3.3" - vite@^6.3.6: version "6.3.6" resolved "https://registry.yarnpkg.com/vite/-/vite-6.3.6.tgz#69a976b64930750d40219fbc68c5200874d315c1" From 72b0a8cfb3fab2ce897b02e8517bb9c324d0cc80 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 15 Sep 2025 01:21:26 -0700 Subject: [PATCH 02/22] fix: relative timer not updating --- src/hooks/useRelativeTime.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/hooks/useRelativeTime.js b/src/hooks/useRelativeTime.js index c02b0aae2..b455e9442 100644 --- a/src/hooks/useRelativeTime.js +++ b/src/hooks/useRelativeTime.js @@ -6,24 +6,25 @@ import { useTranslation } from 'react-i18next' import { useFormatStore } from '@store/useFormatStore' /** + * Hook that returns a periodically updating relative time string for a unix epoch (seconds). + * The interval depends on epochTime so its closure always reflects the current timestamp. * * @param {number} epochTime - * @returns + * @returns {string} */ export function useRelativeTimer(epochTime) { const { t } = useTranslation() const relativeFormat = useFormatStore((s) => s.getRelative) const [relative, setRelative] = useState(relativeFormat(epochTime)) + // Single effect: set immediately on mount/change, then tick every second. useEffect(() => { + // Immediate update for new epochTime before first interval tick + setRelative(relativeFormat(epochTime)) const interval = setInterval(() => { setRelative(relativeFormat(epochTime)) }, 1000) return () => clearInterval(interval) - }, [relativeFormat]) - - useEffect(() => { - setRelative(relativeFormat(epochTime)) }, [epochTime, relativeFormat]) return epochTime ? relative : t('never') From d60f799f9cc181c0e1f1dc6629a45c7faa1048a1 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 15 Sep 2025 08:24:51 +0000 Subject: [PATCH 03/22] chore(release): v1.38.1-develop.1 [skip ci] ## [1.38.1-develop.1](https://github.com/WatWowMap/ReactMap/compare/v1.38.0...v1.38.1-develop.1) (2025-09-15) ### Bug Fixes * relative timer not updating ([72b0a8c](https://github.com/WatWowMap/ReactMap/commit/72b0a8cfb3fab2ce897b02e8517bb9c324d0cc80)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29970c1f8..63aa63e07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [1.38.1-develop.1](https://github.com/WatWowMap/ReactMap/compare/v1.38.0...v1.38.1-develop.1) (2025-09-15) + + +### Bug Fixes + +* relative timer not updating ([72b0a8c](https://github.com/WatWowMap/ReactMap/commit/72b0a8cfb3fab2ce897b02e8517bb9c324d0cc80)) + # [1.38.0](https://github.com/WatWowMap/ReactMap/compare/v1.37.0...v1.38.0) (2025-09-10) diff --git a/package.json b/package.json index ff50fcdea..270ae061f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reactmap", - "version": "1.38.0", + "version": "1.38.1-develop.1", "private": true, "description": "React based frontend map.", "license": "MIT", From 4ac76d00d53eb06966a94a525541302340b638de Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 15 Sep 2025 21:14:28 -0700 Subject: [PATCH 04/22] feat: route share code --- AGENTS.md | 8 +++ packages/locales/lib/human/en.json | 3 + server/src/graphql/typeDefs/scanner.graphql | 1 + server/src/models/Route.js | 3 +- server/src/services/DbManager.js | 4 ++ src/features/route/RoutePopup.jsx | 68 +++++++++++++++++++++ src/services/queries/route.js | 1 + 7 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..c82394a2e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +# AGENTS.md + +## Please read + +Do not touch things in `.gitignore`. +Add new translations in `packages/locales/lib/human/en.json`. +Other languages will be auto generated. +Okay thanks. diff --git a/packages/locales/lib/human/en.json b/packages/locales/lib/human/en.json index 50693178a..993e04662 100644 --- a/packages/locales/lib/human/en.json +++ b/packages/locales/lib/human/en.json @@ -661,6 +661,9 @@ "route_tags": "Route Tags", "routes": "Routes", "route_type": "Route Type", + "route_short_code": "Route Share Code: ", + "shortcode_copied_to_clipboard": "Code copied to clipboard!", + "copy_failed": "Copy failed", "routes_subtitle": "View in game routes and relevant information about them on the map", "description": "Description", "additional_info": "Additional Info", diff --git a/server/src/graphql/typeDefs/scanner.graphql b/server/src/graphql/typeDefs/scanner.graphql index 3210e55ce..0d324f920 100644 --- a/server/src/graphql/typeDefs/scanner.graphql +++ b/server/src/graphql/typeDefs/scanner.graphql @@ -246,6 +246,7 @@ type Waypoint { type Route { id: ID name: String + shortcode: String description: String distance_meters: Int duration_seconds: Int diff --git a/server/src/models/Route.js b/server/src/models/Route.js index b3bc3e1db..918a29845 100644 --- a/server/src/models/Route.js +++ b/server/src/models/Route.js @@ -96,7 +96,7 @@ class Route extends Model { * @param {number} id * @param {import('@rm/types').DbContext} ctx */ - static async getOne(id, { isMad }) { + static async getOne(id, { isMad, hasShortcode }) { /** @type {import('@rm/types').FullRoute} */ const result = isMad ? await this.query() @@ -121,6 +121,7 @@ class Route extends Model { type: 'type', version: 'version', waypoints: 'waypoints', + ...(hasShortcode && { shortcode: 'shortcode' }), }) .select(raw('UNIX_TIMESTAMP(last_updated)').as('updated')) .findOne({ route_id: id }) diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index 8e9f4d9be..ff7a3ec61 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -181,6 +181,9 @@ class DbManager extends Logger { const [polygon] = await schema('nests') .columnInfo() .then((columns) => ['polygon' in columns]) + const [hasShortcode] = await schema('route') + .columnInfo() + .then((columns) => ['shortcode' in columns]) return { isMad, @@ -203,6 +206,7 @@ class DbManager extends Logger { hasShowcaseForm, hasShowcaseType, hasStationedGmax, + hasShortcode, } } diff --git a/src/features/route/RoutePopup.jsx b/src/features/route/RoutePopup.jsx index 69eed7988..5200fb46c 100644 --- a/src/features/route/RoutePopup.jsx +++ b/src/features/route/RoutePopup.jsx @@ -28,6 +28,7 @@ import { useStorage } from '@store/useStorage' import { Title } from '@components/popups/Title' import { Timer } from '@components/popups/Timer' import { Navigation } from '@components/popups/Navigation' +import { Notification } from '@components/Notification' import { useFormatDistance } from './useFormatDistance' @@ -134,6 +135,11 @@ export function RoutePopup({ end, ...props }) { const [route, setRoute] = React.useState({ ...props, tags: [] }) const { config } = useMemory.getState() const formatDistance = useFormatDistance() + const [notification, setNotification] = React.useState({ + open: false, + severity: 'info', + message: '', + }) const [getRoute, { data, called }] = useLazyQuery(Query.routes('getOne'), { variables: { id: props.id }, @@ -169,6 +175,37 @@ export function RoutePopup({ end, ...props }) { const imagesAreEqual = route.image === (end ? route.end_image : route.start_image) + const handleShortcodeCopy = React.useCallback(async () => { + if (route.shortcode) { + try { + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(route.shortcode) + } else { + const textArea = document.createElement('textarea') + textArea.value = route.shortcode + document.body.appendChild(textArea) + textArea.focus() + textArea.select() + document.execCommand('copy') + document.body.removeChild(textArea) + } + setNotification({ + open: true, + severity: 'success', + message: t('shortcode_copied_to_clipboard'), + }) + } catch (e) { + setNotification({ + open: true, + severity: 'error', + message: `${t('copy_failed')}${e?.message ? `: ${e.message}` : ''}`, + }) + // eslint-disable-next-line no-console + console.error(e) + } + } + }, [route.shortcode, t]) + return ( { @@ -186,6 +223,30 @@ export function RoutePopup({ end, ...props }) { {route.name} + {route.shortcode && ( + + + {t('route_short_code')} + + + + {route.shortcode} + + + + )} + setNotification({ ...notification, open: false })} + > + {notification.message} + ) } diff --git a/src/services/queries/route.js b/src/services/queries/route.js index b1f28db2b..a5c48fd5f 100644 --- a/src/services/queries/route.js +++ b/src/services/queries/route.js @@ -18,6 +18,7 @@ export const GET_ROUTE = gql` route(id: $id) { ...CoreRoute name + shortcode image description distance_meters From b39fe04f16bbd8be07ed58d443f9b54c87ea8b02 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 16 Sep 2025 04:19:22 +0000 Subject: [PATCH 05/22] chore(release): v1.39.0-develop.1 [skip ci] # [1.39.0-develop.1](https://github.com/WatWowMap/ReactMap/compare/v1.38.1-develop.1...v1.39.0-develop.1) (2025-09-16) ### Features * route share code ([4ac76d0](https://github.com/WatWowMap/ReactMap/commit/4ac76d00d53eb06966a94a525541302340b638de)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63aa63e07..e6bf2c2ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.39.0-develop.1](https://github.com/WatWowMap/ReactMap/compare/v1.38.1-develop.1...v1.39.0-develop.1) (2025-09-16) + + +### Features + +* route share code ([4ac76d0](https://github.com/WatWowMap/ReactMap/commit/4ac76d00d53eb06966a94a525541302340b638de)) + ## [1.38.1-develop.1](https://github.com/WatWowMap/ReactMap/compare/v1.38.0...v1.38.1-develop.1) (2025-09-15) diff --git a/package.json b/package.json index 270ae061f..df91b7b53 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reactmap", - "version": "1.38.1-develop.1", + "version": "1.39.0-develop.1", "private": true, "description": "React based frontend map.", "license": "MIT", From b0cfe13876b9d81e70ce44c997f4accb48943609 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 15 Sep 2025 21:33:37 -0700 Subject: [PATCH 06/22] fix: skip updated filtering if shortcode is present --- server/src/models/Route.js | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/server/src/models/Route.js b/server/src/models/Route.js index 918a29845..e4d696a40 100644 --- a/server/src/models/Route.js +++ b/server/src/models/Route.js @@ -38,7 +38,7 @@ class Route extends Model { * @param {import("@rm/types").DbContext} ctx * @returns {Promise} */ - static async getAll(perms, args, { isMad }) { + static async getAll(perms, args, { isMad, hasShortcode }) { const { areaRestrictions } = perms const { onlyAreas, onlyDistance } = args.filters const ts = @@ -56,21 +56,31 @@ class Route extends Model { .whereBetween(startLatitude, [args.minLat, args.maxLat]) .andWhereBetween(startLongitude, [args.minLon, args.maxLon]) .andWhereBetween(distanceMeters, distanceInMeters) - .andWhere( - isMad ? raw('UNIX_TIMESTAMP(last_updated)') : 'updated', - '>', - ts, - ) + .andWhere((builder) => { + builder.where( + isMad ? raw('UNIX_TIMESTAMP(last_updated)') : 'updated', + '>', + ts, + ) + if (hasShortcode) { + builder.orWhere('shortcode', '<>', '') + } + }) .union((qb) => { qb.select(isMad ? GET_MAD_ALL_SELECT : GET_ALL_SELECT) .whereBetween(endLatitude, [args.minLat, args.maxLat]) .andWhereBetween(endLongitude, [args.minLon, args.maxLon]) .andWhereBetween(distanceMeters, distanceInMeters) - .andWhere( - isMad ? raw('UNIX_TIMESTAMP(last_updated)') : 'updated', - '>', - ts, - ) + .andWhere((builder) => { + builder.where( + isMad ? raw('UNIX_TIMESTAMP(last_updated)') : 'updated', + '>', + ts, + ) + if (hasShortcode) { + builder.orWhere('shortcode', '<>', '') + } + }) .from('route') getAreaSql(qb, areaRestrictions, onlyAreas, isMad, 'route_end') }) From d8019f3ebfe19d437dbb190827b9bef8f43be5f4 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 16 Sep 2025 04:37:20 +0000 Subject: [PATCH 07/22] chore(release): v1.39.0-develop.2 [skip ci] # [1.39.0-develop.2](https://github.com/WatWowMap/ReactMap/compare/v1.39.0-develop.1...v1.39.0-develop.2) (2025-09-16) ### Bug Fixes * skip updated filtering if shortcode is present ([b0cfe13](https://github.com/WatWowMap/ReactMap/commit/b0cfe13876b9d81e70ce44c997f4accb48943609)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6bf2c2ac..452dba3cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.39.0-develop.2](https://github.com/WatWowMap/ReactMap/compare/v1.39.0-develop.1...v1.39.0-develop.2) (2025-09-16) + + +### Bug Fixes + +* skip updated filtering if shortcode is present ([b0cfe13](https://github.com/WatWowMap/ReactMap/commit/b0cfe13876b9d81e70ce44c997f4accb48943609)) + # [1.39.0-develop.1](https://github.com/WatWowMap/ReactMap/compare/v1.38.1-develop.1...v1.39.0-develop.1) (2025-09-16) diff --git a/package.json b/package.json index df91b7b53..496ec8151 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reactmap", - "version": "1.39.0-develop.1", + "version": "1.39.0-develop.2", "private": true, "description": "React based frontend map.", "license": "MIT", From ba51f47d6c90dc659427148d4cc025ed6bc89950 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 22 Sep 2025 14:33:24 -0700 Subject: [PATCH 08/22] feat: display actual battle bonus via stationed pokemon --- src/features/station/StationPopup.jsx | 26 ++++++++------------------ src/utils/getAttackBonus.js | 11 +++++++++++ 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/features/station/StationPopup.jsx b/src/features/station/StationPopup.jsx index 49b939a12..f0bbaf41a 100644 --- a/src/features/station/StationPopup.jsx +++ b/src/features/station/StationPopup.jsx @@ -17,8 +17,6 @@ import Rating from '@mui/material/Rating' import Typography from '@mui/material/Typography' import Stack from '@mui/material/Stack' import Box from '@mui/material/Box' -import LockOpenIcon from '@mui/icons-material/LockOpen' -import LockIcon from '@mui/icons-material/Lock' import { useMemory } from '@store/useMemory' import { setDeepStore, useGetDeepStore, useStorage } from '@store/useStorage' @@ -37,7 +35,7 @@ import { ExpandWithState, } from '@components/inputs/ExpandCollapse' import { VirtualGrid } from '@components/virtual/VirtualGrid' -import { getStationAttackBonus } from '@utils/getAttackBonus' +import { getStationDamageBoost } from '@utils/getAttackBonus' import { CopyCoords } from '@components/popups/Coords' import { PokeMove } from '@components/popups/PokeMove' @@ -58,7 +56,8 @@ export function StationPopup(station) { )} {station.battle_start < Date.now() / 1000 && - station.battle_end > Date.now() / 1000 && ( + station.battle_end > Date.now() / 1000 && + !!station.total_stationed_pokemon && ( - } - emptyIcon={} - max={4} - /> - {t('battle_bonus')}  ( + {t('battle_bonus')}: +{getStationDamageBoost(total_stationed_pokemon)}% +
+ {t('placed_pokemon')}:{' '} {total_stationed_gmax === undefined || total_stationed_gmax === null ? '' - : `${total_stationed_gmax} / `} - {total_stationed_pokemon} / 40) + : `${total_stationed_gmax}/`} + {total_stationed_pokemon}/40
) @@ -389,15 +383,11 @@ function StationContent({ start_time, end_time, id }) { /** @param {import('@rm/types').Station} props */ function StationMons({ id }) { const { t: tId } = useTranslateById() - const { t } = useTranslation() const mons = useGetStationMons(id) const icons = useMemory((s) => s.Icons) return ( - - {t('placed_pokemon')} - {(index, mon) => { const caption = tId(`${mon.pokemon_id}-${mon.form}`) diff --git a/src/utils/getAttackBonus.js b/src/utils/getAttackBonus.js index 66f280bbe..851143d32 100644 --- a/src/utils/getAttackBonus.js +++ b/src/utils/getAttackBonus.js @@ -8,3 +8,14 @@ export function getStationAttackBonus(stationedPokemon) { if (stationedPokemon > 0) return 1 return 0 } + +/** https://www.reddit.com/r/TheSilphRoad/comments/1nmx8fb/small_update_on_max_battle_parameters_and_the_cpm/ + * @param {number} stationedPokemon */ +export function getStationDamageBoost(stationedPokemon) { + const boostTable = [ + 0, 10, 15, 17, 18, 18.7, 19.1, 19.2, 19.3, 19.4, 19.5, 19.6, 19.7, 19.8, + 19.9, + ] + if (stationedPokemon >= 15) return 20 + return boostTable[stationedPokemon] || 0 +} From 2b83a26928c6a7e38d256a17362eefb20c2725db Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 22 Sep 2025 21:36:41 +0000 Subject: [PATCH 09/22] chore(release): v1.39.0-develop.3 [skip ci] # [1.39.0-develop.3](https://github.com/WatWowMap/ReactMap/compare/v1.39.0-develop.2...v1.39.0-develop.3) (2025-09-22) ### Features * display actual battle bonus via stationed pokemon ([ba51f47](https://github.com/WatWowMap/ReactMap/commit/ba51f47d6c90dc659427148d4cc025ed6bc89950)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 452dba3cc..b77a20b3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.39.0-develop.3](https://github.com/WatWowMap/ReactMap/compare/v1.39.0-develop.2...v1.39.0-develop.3) (2025-09-22) + + +### Features + +* display actual battle bonus via stationed pokemon ([ba51f47](https://github.com/WatWowMap/ReactMap/commit/ba51f47d6c90dc659427148d4cc025ed6bc89950)) + # [1.39.0-develop.2](https://github.com/WatWowMap/ReactMap/compare/v1.39.0-develop.1...v1.39.0-develop.2) (2025-09-16) diff --git a/package.json b/package.json index 496ec8151..c5c4609bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reactmap", - "version": "1.39.0-develop.2", + "version": "1.39.0-develop.3", "private": true, "description": "React based frontend map.", "license": "MIT", From 9144e54fac204bd1c52e88b840fb7b53e52bd77f Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 22 Sep 2025 21:16:45 -0700 Subject: [PATCH 10/22] chore: new AGENTS.md --- AGENTS.md | 43 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c82394a2e..97e67e5be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,39 @@ -# AGENTS.md +# Repository Guidelines -## Please read +## Project Structure & Module Organization -Do not touch things in `.gitignore`. -Add new translations in `packages/locales/lib/human/en.json`. -Other languages will be auto generated. -Okay thanks. +- React client code lives in `src/`, with shared UI in `src/components`, feature bundles in `src/features`, and hooks/services under `src/hooks`, `src/services`, and `src/utils`. +- The Node.js API, GraphQL schema, and auth logic are in `server/src`; configs and migrations sit under `server/src/configs` and `server/src/db`. +- Workspace packages in `packages/` expose shared config (`@rm/config`), localization, logging, masterfile data, and build plugins; treat them as the source of truth for cross-app utilities. +- Static assets are sourced from `public/`, while built bundles land in `dist/`; avoid committing build artefacts or anything listed in `.gitignore`. + +## Build, Test, and Development Commands + +- `yarn install` – install workspace dependencies; rerun after pulling lockfile changes. +- `yarn dev` – start the full dev stack (Nodemon backend + Vite) using local config. +- `yarn watch` – Vite-only hot reload for rapid UI work when the API is proxied elsewhere. +- `yarn build` – create a production bundle in `dist/`; ensure it succeeds before release PRs. +- `yarn lint` / `yarn lint:fix` – run ESLint with the Airbnb ruleset; lint must pass pre-commit. +- `yarn prettier` / `yarn prettier:fix` – enforce formatting for JS/JSX, CSS, HTML, and YAML. +- `yarn config:env` and `yarn locales:generate` – regenerate env files and derived locales after editing base config or strings. + +## Coding Style & Naming Conventions + +- Prettier governs formatting (2-space indent, single quotes in JS, semicolons off); never hand-format conflicting styles. +- Prefer functional React components, PascalCase for components, camelCase for helpers, and `use` prefixes for hooks. + +## Testing Guidelines + +- No dedicated Jest suite today; rely on `yarn lint`, type checks from editor tooling, and manual verification in a local dev session. +- When adding backend features, exercise relevant GraphQL/REST paths via the dev server and document sanity checks in the PR description. + +## Commit & Pull Request Guidelines + +- Use Conventional Commits (`type(scope): summary`), matching existing history (e.g. `feat(map): add weather overlays`). +- Each PR should describe scope, link related issues, list testing steps, and include screenshots or GIFs for UI changes. +- Re-run `yarn lint`, `yarn build`, and integration steps touched by the change before requesting review. + +## Localization Notes + +- Update English copy only in `packages/locales/lib/human/en.json`; run `yarn locales:generate` to refresh derived languages. +- Never edit generated locale files directly—the automation pipeline syncs translations downstream. From 795976e2ea9f68ba54ad5495bbf81af2716b4920 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 23 Sep 2025 11:06:35 -0700 Subject: [PATCH 11/22] feat: Compact Route View (#1131) --- package.json | 1 + src/assets/css/main.css | 25 +++ src/features/drawer/Routes.jsx | 19 ++ src/features/gym/GymTile.jsx | 10 ++ src/features/pokestop/PokestopTile.jsx | 10 ++ src/features/route/RouteLayer.jsx | 222 +++++++++++++++++++++++ src/features/route/RoutePopup.jsx | 34 +++- src/features/route/RouteTile.jsx | 239 ++++++++++++++++++++----- src/features/route/index.js | 2 + src/features/route/useRouteStore.js | 205 +++++++++++++++++++++ src/pages/map/components/QueryData.jsx | 16 +- src/services/queries/route.js | 2 + yarn.lock | 17 +- 13 files changed, 746 insertions(+), 56 deletions(-) create mode 100644 src/features/route/RouteLayer.jsx create mode 100644 src/features/route/useRouteStore.js diff --git a/package.json b/package.json index c5c4609bf..56269d525 100644 --- a/package.json +++ b/package.json @@ -149,6 +149,7 @@ "i18next-http-backend": "2.5.2", "knex": "3.1.0", "leaflet": "1.9.4", + "leaflet-arrowheads": "^1.4.0", "leaflet.locatecontrol": "0.81.0", "lodash": "^4.17.21", "moment-timezone": "^0.5.43", diff --git a/src/assets/css/main.css b/src/assets/css/main.css index e231a06eb..72c7badcd 100644 --- a/src/assets/css/main.css +++ b/src/assets/css/main.css @@ -88,6 +88,31 @@ body { background-color: #ff4b4d; } +.route-count-wrapper { + display: inline-flex; + transform: translate(10px, -12px); +} + +.route-count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + padding: 0 4px; + border-radius: 10px; + background: #ff4b4d; + color: #fff; + font-size: 11px; + font-weight: 700; + line-height: 16px; + border: 1px solid #ffffff; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.4); +} + +.route-count-badge--destination { + background: #2196f3; +} + .invasion-exists { border: 4px solid rgb(141, 13, 13); } diff --git a/src/features/drawer/Routes.jsx b/src/features/drawer/Routes.jsx index 008238457..b7cb322ae 100644 --- a/src/features/drawer/Routes.jsx +++ b/src/features/drawer/Routes.jsx @@ -1,6 +1,9 @@ // @ts-check import * as React from 'react' import ListItem from '@mui/material/ListItem' +import ListItemText from '@mui/material/ListItemText' +import Switch from '@mui/material/Switch' +import { useTranslation } from 'react-i18next' import { useMemory } from '@store/useMemory' import { useStorage, useDeepStore } from '@store/useStorage' @@ -9,8 +12,13 @@ import { SliderTile } from '@components/inputs/SliderTile' import { CollapsibleItem } from './components/CollapsibleItem' const RouteSlider = () => { + const { t } = useTranslation() const enabled = useStorage((s) => !!s.filters?.routes?.enabled) const [filters, setFilters] = useDeepStore('filters.routes.distance') + const [compactView, setCompactView] = useDeepStore( + 'userSettings.routes.compactView', + true, + ) const baseDistance = useMemory.getState().filters?.routes?.distance /** @type {import('@rm/types').RMSlider} */ @@ -31,6 +39,17 @@ const RouteSlider = () => { return ( + setCompactView(checked)} + checked={compactView !== false} + /> + } + > + + { const BaseGymTile = (gym) => { const [markerRef, setMarkerRef] = React.useState(null) const [stateChange, setStateChange] = React.useState(false) + const hasRoutes = useRouteStore((s) => !!s.poiIndex[gym.id]) + const selectPoi = useRouteStore((s) => s.selectPoi) const [ hasRaid, @@ -179,6 +182,13 @@ const BaseGymTile = (gym) => { raidIconSize, ...gym, })} + eventHandlers={{ + click: () => { + if (hasRoutes) { + selectPoi(gym.id) + } + }, + }} > { const [stateChange, setStateChange] = React.useState(false) const [markerRef, setMarkerRef] = React.useState(null) + const hasRoutes = useRouteStore((s) => !!s.poiIndex[pokestop.id]) + const selectPoi = useRouteStore((s) => s.selectPoi) const [ hasLure, @@ -130,6 +133,13 @@ const BasePokestopTile = (pokestop) => { ref={setMarkerRef} position={[pokestop.lat, pokestop.lon]} icon={icon} + eventHandlers={{ + click: () => { + if (hasRoutes) { + selectPoi(pokestop.id) + } + }, + }} > { + const baseIcon = React.useMemo( + () => icon || routeMarker(variant === 'destination' ? 'end' : 'start'), + [icon, variant], + ) + const badgeIcon = React.useMemo(() => { + if (routeCount <= 1) return null + return divIcon({ + className: 'route-count-wrapper', + html: `${routeCount}`, + iconSize: [0, 0], + iconAnchor: [0, 0], + }) + }, [routeCount, variant]) + + return ( + <> + {variant !== 'destination' && !selected && ( + onSelect(entry.key), + }} + title={routeCount > 1 ? `${routeCount} routes` : ''} + /> + )} + {badgeIcon && ( + + )} + + ) + }, +) + +const ActiveRoute = React.memo(({ selection }) => { + const route = useRouteStore( + React.useCallback( + (state) => state.routeCache[selection.routeId], + [selection.routeId], + ), + ) + + if (!route) return null + return +}) + +export function RouteLayer({ routes }) { + const enabled = useStorage((s) => !!s.filters?.routes?.enabled) + const compactView = useStorage( + (s) => s.userSettings.routes?.compactView ?? true, + ) + const syncRoutes = useRouteStore((s) => s.syncRoutes) + const poiIndex = useRouteStore((s) => s.poiIndex) + const routeCache = useRouteStore((s) => s.routeCache) + const activeRoutes = useRouteStore((s) => s.activeRoutes) + const activePoiId = useRouteStore((s) => s.activePoiId) + const selectPoi = useRouteStore((s) => s.selectPoi) + const clearSelection = useRouteStore((s) => s.clearSelection) + + React.useEffect(() => { + syncRoutes(routes || []) + }, [routes, syncRoutes]) + + React.useEffect(() => { + if (!enabled || !compactView) { + clearSelection() + } + }, [enabled, compactView, clearSelection]) + + useMapEvents({ + click: ({ originalEvent }) => { + if (!originalEvent.defaultPrevented) { + clearSelection() + } + }, + }) + + const destinationSummary = React.useMemo(() => { + if (!compactView) + return { keys: new Set(), icons: new Map(), counts: new Map() } + const keys = new Set() + const icons = new Map() + const counts = new Map() + activeRoutes.forEach((selection) => { + const route = routeCache[selection.routeId] + if (!route) return + const isForward = selection.orientation === 'forward' + const lat = isForward ? route.end_lat : route.start_lat + const lon = isForward ? route.end_lon : route.start_lon + const coordKey = getRouteCoordKey(lat, lon) + keys.add(coordKey) + counts.set(coordKey, (counts.get(coordKey) || 0) + 1) + if (!icons.has(coordKey)) { + icons.set(coordKey, routeMarker('end')) + } + }) + return { keys, icons, counts } + }, [activeRoutes, routeCache, compactView]) + + const anchors = React.useMemo(() => { + if (!compactView) return [] + const values = Object.values(poiIndex) + return values.map((entry) => { + const uniqueRoutes = new Set() + values.forEach((candidate) => { + if ( + Math.abs(candidate.lat - entry.lat) <= ROUTE_COORD_EPSILON && + Math.abs(candidate.lon - entry.lon) <= ROUTE_COORD_EPSILON + ) { + candidate.routes.forEach((ref) => { + if (routeCache[ref.routeId]) { + uniqueRoutes.add(ref.routeId) + } + }) + } + }) + return { + entry, + routeCount: + uniqueRoutes.size || new Set(entry.routes.map((r) => r.routeId)).size, + } + }) + }, [compactView, poiIndex, routeCache]) + + if (!enabled) { + return null + } + + if (!compactView) { + return ( + <> + {routes.map((route) => ( + + ))} + + ) + } + + return ( + <> + {anchors.map(({ entry, routeCount }) => { + const entryCoordKey = getRouteCoordKey(entry.lat, entry.lon) + const iconOverride = destinationSummary.icons.get(entryCoordKey) + const destinationCount = destinationSummary.counts.get(entryCoordKey) + if (destinationCount && destinationCount > 1) { + return ( + + ) + } + if ( + destinationSummary.keys.has(entryCoordKey) && + entry.key !== activePoiId + ) { + return null + } + return ( + + ) + })} + {activeRoutes.map((selection) => ( + + ))} + + ) +} diff --git a/src/features/route/RoutePopup.jsx b/src/features/route/RoutePopup.jsx index 5200fb46c..c88732a48 100644 --- a/src/features/route/RoutePopup.jsx +++ b/src/features/route/RoutePopup.jsx @@ -131,7 +131,7 @@ function ExpandableWrapper({ disabled = false, children, expandKey, primary }) { * @param {import("@rm/types").Route & { end?: boolean }} props * @returns */ -export function RoutePopup({ end, ...props }) { +export function RoutePopup({ end, inline = false, ...props }) { const [route, setRoute] = React.useState({ ...props, tags: [] }) const { config } = useMemory.getState() const formatDistance = useFormatDistance() @@ -146,6 +146,12 @@ export function RoutePopup({ end, ...props }) { }) const { t } = useTranslation() + React.useEffect(() => { + if (inline && !called) { + getRoute() + } + }, [inline, called, getRoute]) + React.useEffect(() => { if (data?.route) { setRoute({ @@ -206,14 +212,8 @@ export function RoutePopup({ end, ...props }) { } }, [route.shortcode, t]) - return ( - { - if (ref && ref.isOpen() && !called) { - getRoute() - } - }} - > + const content = ( + <> {notification.message} + + ) + + if (inline) { + return content + } + + return ( + { + if (ref && ref.isOpen() && !called) { + getRoute() + } + }} + > + {content} ) } diff --git a/src/features/route/RouteTile.jsx b/src/features/route/RouteTile.jsx index c54baf6c4..aa1d09350 100644 --- a/src/features/route/RouteTile.jsx +++ b/src/features/route/RouteTile.jsx @@ -1,7 +1,8 @@ /* eslint-disable react/destructuring-assignment */ // @ts-check import * as React from 'react' -import { Marker, Polyline, useMapEvents } from 'react-leaflet' +import { Marker, Polyline, Popup, useMapEvents } from 'react-leaflet' +import 'leaflet-arrowheads' import { darken } from '@mui/material/styles' import { useForcePopup } from '@hooks/useForcePopup' @@ -14,43 +15,83 @@ const POSITIONS = /** @type {const} */ (['start', 'end']) const LINE_OPACITY = 0.33 const MARKER_OPACITY = LINE_OPACITY * 2 -/** - * - * @param {import("@rm/types").Route} route - * @returns - */ -const BaseRouteTile = (route) => { +const BaseRouteTile = ({ route, orientation = 'forward' }) => { const [clicked, setClicked] = React.useState(false) const [hover, setHover] = React.useState('') + const [linePopup, setLinePopup] = React.useState( + /** @type {import('leaflet').LatLngExpression | null} */ (null), + ) /** @type {React.MutableRefObject} */ const lineRef = React.useRef() const [markerRef, setMarkerRef] = React.useState(null) + /** @type {React.MutableRefObject} */ + const arrowheadsRef = React.useRef(null) - const waypoints = React.useMemo( - () => [ + const displayRoute = React.useMemo(() => { + if (orientation === 'forward') return route + const reversedWaypoints = [...(route.waypoints || [])] + .map((waypoint) => ({ ...waypoint })) + .reverse() + return { + ...route, + start_lat: route.end_lat, + start_lon: route.end_lon, + start_image: route.end_image, + start_fort_id: route.end_fort_id, + end_lat: route.start_lat, + end_lon: route.start_lon, + end_image: route.start_image, + end_fort_id: route.start_fort_id, + waypoints: reversedWaypoints, + } + }, [orientation, route]) + + const waypoints = React.useMemo(() => { + const internal = displayRoute.waypoints || [] + return [ { - lat_degrees: route.start_lat, - lng_degrees: route.start_lon, - elevation_in_meters: route.waypoints[0]?.elevation_in_meters || 0, + lat_degrees: displayRoute.start_lat, + lng_degrees: displayRoute.start_lon, + elevation_in_meters: internal[0]?.elevation_in_meters || 0, }, - ...route.waypoints, + ...internal, { - lat_degrees: route.end_lat, - lng_degrees: route.end_lon, + lat_degrees: displayRoute.end_lat, + lng_degrees: displayRoute.end_lon, elevation_in_meters: - route.waypoints[route.waypoints.length - 1]?.elevation_in_meters || 1, + internal[internal.length - 1]?.elevation_in_meters || 1, }, - ], - [route], - ) + ] + }, [displayRoute]) const [color, darkened] = React.useMemo( () => [ - `#${route.image_border_color}`, - darken(`#${route.image_border_color}`, 0.2), + `#${displayRoute.image_border_color}`, + darken(`#${displayRoute.image_border_color}`, 0.2), ], - [route.image_border_color], + [displayRoute.image_border_color], + ) + + const polylinePositions = React.useMemo( + () => + waypoints.map((waypoint) => [waypoint.lat_degrees, waypoint.lng_degrees]), + [waypoints], + ) + + const applyArrowheadStyle = React.useCallback( + (targetColor, targetOpacity) => { + const group = arrowheadsRef.current + if (!group) { + return + } + /** @type {any} */ group.eachLayer((layer) => { + if (layer && typeof layer.setStyle === 'function') { + layer.setStyle({ color: targetColor, opacity: targetOpacity }) + } + }) + }, + [], ) useMapEvents({ @@ -58,10 +99,80 @@ const BaseRouteTile = (route) => { if (!originalEvent.defaultPrevented) { setClicked(false) setHover('') + setLinePopup(null) } }, }) - useForcePopup(route.id, markerRef) + useForcePopup(displayRoute.id, markerRef) + + React.useEffect(() => { + setLinePopup(null) + }, [displayRoute.id, orientation]) + + React.useEffect(() => { + const line = lineRef.current + if (!line) { + arrowheadsRef.current = null + return undefined + } + + const arrowLine = /** @type {any} */ (line) + if (typeof arrowLine.deleteArrowheads === 'function') { + arrowLine.deleteArrowheads() + } + arrowheadsRef.current = null + + if ( + !displayRoute.reversible && + typeof arrowLine.arrowheads === 'function' + ) { + arrowLine.arrowheads({ + size: '10px', + frequency: '24px', + yawn: 32, + fill: false, + offsets: { + start: '10px', + end: '10px', + }, + }) + if (typeof line.redraw === 'function') { + line.redraw() + } + if (typeof arrowLine.getArrowheads === 'function') { + try { + const group = arrowLine.getArrowheads() + arrowheadsRef.current = group || null + } catch (error) { + arrowheadsRef.current = null + } + } + applyArrowheadStyle(color, LINE_OPACITY) + } + + return () => { + if (typeof arrowLine.deleteArrowheads === 'function') { + arrowLine.deleteArrowheads() + } + arrowheadsRef.current = null + } + }, [applyArrowheadStyle, color, displayRoute.reversible, polylinePositions]) + + const isActive = Boolean(clicked || hover) + + React.useEffect(() => { + if (lineRef.current) { + const lineOpacity = isActive ? 1 : LINE_OPACITY + lineRef.current.setStyle({ + color: isActive ? darkened : color, + opacity: lineOpacity, + }) + } + applyArrowheadStyle( + isActive ? darkened : color, + isActive ? 1 : LINE_OPACITY, + ) + }, [applyArrowheadStyle, color, darkened, isActive]) return ( <> @@ -71,27 +182,44 @@ const BaseRouteTile = (route) => { ref={position === 'start' ? setMarkerRef : undefined} opacity={hover || clicked ? 1 : MARKER_OPACITY} zIndexOffset={hover === position ? 2000 : hover || clicked ? 1000 : 0} - position={[route[`${position}_lat`], route[`${position}_lon`]]} - icon={routeMarker(position)} + position={[ + displayRoute[`${position}_lat`], + displayRoute[`${position}_lon`], + ]} + icon={ + displayRoute.reversible + ? routeMarker('start') + : routeMarker(position) + } eventHandlers={{ popupopen: () => setClicked(true), popupclose: () => setClicked(false), mouseover: () => { if (lineRef.current) { - lineRef.current.setStyle({ color: darkened, opacity: 1 }) + lineRef.current.setStyle({ + color: darkened, + opacity: 1, + }) } + applyArrowheadStyle(darkened, 1) setHover(position) }, mouseout: () => { if (lineRef.current && !clicked) { - lineRef.current.setStyle({ color, opacity: MARKER_OPACITY }) + lineRef.current.setStyle({ + color, + opacity: LINE_OPACITY, + }) + } + if (!clicked) { + applyArrowheadStyle(color, LINE_OPACITY) } setHover('') }, }} > @@ -100,37 +228,68 @@ const BaseRouteTile = (route) => { { + click: ({ originalEvent, latlng }) => { originalEvent.preventDefault() - setClicked((prev) => !prev) + setClicked(true) + if (lineRef.current) { + lineRef.current.setStyle({ + color: darkened, + opacity: 1, + }) + } + applyArrowheadStyle(darkened, 1) + if (latlng) { + setLinePopup([latlng.lat, latlng.lng]) + } }, mouseover: ({ target }) => { if (target && !clicked) { - target.setStyle({ color: darkened, opacity: 1 }) + target.setStyle({ + color: darkened, + opacity: 1, + }) + } + if (!clicked) { + applyArrowheadStyle(darkened, 1) } }, mouseout: ({ target }) => { if (target && !clicked) { - target.setStyle({ color, opacity: LINE_OPACITY }) + target.setStyle({ + color, + opacity: LINE_OPACITY, + }) + } + if (!clicked) { + applyArrowheadStyle(color, LINE_OPACITY) } }, }} - dashArray={route.reversible ? undefined : '5, 5'} - positions={waypoints.map((waypoint) => [ - waypoint.lat_degrees, - waypoint.lng_degrees, - ])} + positions={polylinePositions} pathOptions={{ - color: clicked || hover ? darkened : color, - opacity: clicked || hover ? 1 : LINE_OPACITY, + color: isActive ? darkened : color, + opacity: displayRoute.reversible && isActive ? 1 : LINE_OPACITY, weight: 4, }} /> + {linePopup && ( + setLinePopup(null), + close: () => setLinePopup(null), + }} + > + + + )} ) } export const RouteTile = React.memo( BaseRouteTile, - (prev, next) => prev.updated === next.updated, + (prev, next) => + prev.route.updated === next.route.updated && + prev.orientation === next.orientation, ) diff --git a/src/features/route/index.js b/src/features/route/index.js index 9357e658e..13724a8e4 100644 --- a/src/features/route/index.js +++ b/src/features/route/index.js @@ -3,3 +3,5 @@ export * from './routeMarker' export * from './RoutePopup' export * from './RouteTile' +export * from './RouteLayer' +export * from './useRouteStore' diff --git a/src/features/route/useRouteStore.js b/src/features/route/useRouteStore.js new file mode 100644 index 000000000..5d400053e --- /dev/null +++ b/src/features/route/useRouteStore.js @@ -0,0 +1,205 @@ +// @ts-check + +import { create } from 'zustand' + +const PRECISION = 6 + +export const ROUTE_COORD_EPSILON = 1 / 10 ** PRECISION + +/** + * @typedef {{ + * routeId: string, + * orientation: 'forward' | 'reverse', + * }} RouteSelection + */ + +/** + * @typedef {{ + * key: string, + * poiId: string, + * lat: number, + * lon: number, + * isFort: boolean, + * routes: RouteSelection[], + * }} RoutePoiIndex + */ + +/** + * @param {number} lat + * @param {number} lon + * @param {'start' | 'end'} prefix + */ +const formatCoordKey = (lat, lon) => + `${lat.toFixed(PRECISION)}:${lon.toFixed(PRECISION)}` + +const fallbackKey = (lat, lon, prefix) => + `${prefix}:${formatCoordKey(lat, lon)}` + +export const getRouteCoordKey = formatCoordKey + +export const getRoutePoiKey = (route, position) => { + const lat = position === 'start' ? route.start_lat : route.end_lat + const lon = position === 'start' ? route.start_lon : route.end_lon + const fortId = position === 'start' ? route.start_fort_id : route.end_fort_id + return fortId || fallbackKey(lat, lon, position) +} + +/** + * @param {Record} poiIndex + * @param {RoutePoiIndex | null} entry + * @param {Record} routeCache + * @returns {RouteSelection[]} + */ +const collectNearbyRoutes = (poiIndex, entry, routeCache) => { + if (!entry) return [] + const seen = new Set() + /** @type {RouteSelection[]} */ + const combined = [] + Object.values(poiIndex).forEach((candidate) => { + if ( + Math.abs(candidate.lat - entry.lat) <= ROUTE_COORD_EPSILON && + Math.abs(candidate.lon - entry.lon) <= ROUTE_COORD_EPSILON + ) { + candidate.routes.forEach((ref) => { + const id = `${ref.routeId}-${ref.orientation}` + if (!seen.has(id) && routeCache[ref.routeId]) { + seen.add(id) + combined.push(ref) + } + }) + } + }) + return combined +} + +/** + * @param {Record} poiIndex + * @param {import('@rm/types').Route} route + * @param {'forward' | 'reverse'} orientation + */ +const addPoiEntry = (poiIndex, route, orientation) => { + const isForward = orientation === 'forward' + const poiId = isForward ? route.start_fort_id : route.end_fort_id + const lat = isForward ? route.start_lat : route.end_lat + const lon = isForward ? route.start_lon : route.end_lon + const key = poiId || fallbackKey(lat, lon, isForward ? 'start' : 'end') + + const existing = poiIndex[key] || { + key, + poiId: poiId || key, + lat, + lon, + isFort: !!poiId, + routes: [], + } + if ( + !existing.routes.some( + (ref) => ref.routeId === route.id && ref.orientation === orientation, + ) + ) { + existing.routes = [...existing.routes, { routeId: route.id, orientation }] + } + poiIndex[key] = existing +} + +/** + * @typedef {{ + * routeCache: Record, + * poiIndex: Record, + * activePoiId: string, + * activeRoutes: RouteSelection[], + * syncRoutes: (routes: import('@rm/types').Route[]) => void, + * selectPoi: (poiId: string) => void, + * clearSelection: () => void, + * }} RouteStore + */ +export const useRouteStore = create( + /** @returns {RouteStore} */ + (set) => ({ + routeCache: {}, + poiIndex: {}, + activePoiId: '', + activeRoutes: [], + syncRoutes: (routes) => { + set((state) => { + const poiIndex = {} + const incomingIds = new Set() + const nextRouteCache = { ...state.routeCache } + + routes.forEach((route) => { + if (!route?.id) return + incomingIds.add(route.id) + nextRouteCache[route.id] = route + addPoiEntry(poiIndex, route, 'forward') + if (route.reversible) { + addPoiEntry(poiIndex, route, 'reverse') + } + }) + + const activeRouteIds = new Set( + state.activeRoutes.map((ref) => ref.routeId), + ) + Object.keys(nextRouteCache).forEach((routeId) => { + if (!incomingIds.has(routeId) && !activeRouteIds.has(routeId)) { + delete nextRouteCache[routeId] + } + }) + + const { activePoiId } = state + const activeEntry = activePoiId ? poiIndex[activePoiId] : null + const nearbyActiveRoutes = collectNearbyRoutes( + poiIndex, + activeEntry, + nextRouteCache, + ) + const nextActiveRoutes = nearbyActiveRoutes.length + ? nearbyActiveRoutes + : state.activeRoutes.filter((ref) => nextRouteCache[ref.routeId]) + + return { + poiIndex, + routeCache: nextRouteCache, + activeRoutes: nextActiveRoutes, + } + }) + }, + selectPoi: (poiId) => { + set((state) => { + if (!poiId) { + return state + } + if (state.activePoiId === poiId) { + return { + ...state, + activePoiId: '', + activeRoutes: [], + } + } + const entry = state.poiIndex[poiId] + if (!entry) { + return state + } + const routes = collectNearbyRoutes( + state.poiIndex, + entry, + state.routeCache, + ) + return { + ...state, + activePoiId: poiId, + activeRoutes: routes, + } + }) + }, + clearSelection: () => + set((state) => + state.activePoiId + ? { + ...state, + activePoiId: '', + activeRoutes: [], + } + : state, + ), + }), +) diff --git a/src/pages/map/components/QueryData.jsx b/src/pages/map/components/QueryData.jsx index 3ecf01d17..81f8a1817 100644 --- a/src/pages/map/components/QueryData.jsx +++ b/src/pages/map/components/QueryData.jsx @@ -11,6 +11,7 @@ import { RobustTimeout } from '@services/apollo/RobustTimeout' import { FILTER_SKIP_LIST } from '@assets/constants' import { Notification } from '@components/Notification' import { GenerateCells } from '@features/s2cell' +import { RouteLayer } from '@features/route' import { useAnalytics } from '@hooks/useAnalytics' import { useProcessError } from '@hooks/useProcessError' @@ -185,14 +186,17 @@ function QueryData({ category, timeout }) { ) : null } + const filteredData = returnData.filter((each) => !hideList.has(each.id)) + + if (category === 'routes') { + return + } + return ( - {returnData.map((each) => { - if (!hideList.has(each.id)) { - return - } - return null - })} + {filteredData.map((each) => ( + + ))} ) } diff --git a/src/services/queries/route.js b/src/services/queries/route.js index a5c48fd5f..c3dee2894 100644 --- a/src/services/queries/route.js +++ b/src/services/queries/route.js @@ -5,8 +5,10 @@ import { gql } from '@apollo/client' const core = gql` fragment CoreRoute on Route { id + start_fort_id start_lat start_lon + end_fort_id end_lat end_lon } diff --git a/yarn.lock b/yarn.lock index e3655a6ea..ca946632e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6313,12 +6313,27 @@ latest-version@^2.0.0: dependencies: package-json "^2.0.0" +leaflet-arrowheads@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/leaflet-arrowheads/-/leaflet-arrowheads-1.4.0.tgz#2a92711c4ca89e6a63014f552cc0d6c67653a773" + integrity sha512-aIjsmoWe1VJXaGOpKpS6E8EzN2vpx3GGCNP/FxQteLVzAg5xMID7elf9hj/1CWLJo8FuGRjSvKkUQDj7mocrYA== + dependencies: + leaflet "^1.7.1" + leaflet-geometryutil "^0.10.0" + +leaflet-geometryutil@^0.10.0: + version "0.10.3" + resolved "https://registry.yarnpkg.com/leaflet-geometryutil/-/leaflet-geometryutil-0.10.3.tgz#dda78546abc58723a46b47fd6ffb081f5b63cec4" + integrity sha512-Qeas+KsnenE0Km/ydt8km3AqFe7kJhVwuLdbCYM2xe2epsxv5UFEaVJiagvP9fnxS8QvBNbm7DJlDA0tkKo9VA== + dependencies: + leaflet "^1.6.0" + leaflet.locatecontrol@0.81.0: version "0.81.0" resolved "https://registry.yarnpkg.com/leaflet.locatecontrol/-/leaflet.locatecontrol-0.81.0.tgz#75e92d07c19edade910a2b5a177ac24cef7d10e7" integrity sha512-5Dqj6VXVFl1vPquYZW95hQYegvzqSI4eLIpZrBMuHuyoAo5i9y6js3z02TF//XXZByIyTI/XBtlxlZLUM08Pcg== -leaflet@1.9.4: +leaflet@1.9.4, leaflet@^1.6.0, leaflet@^1.7.1: version "1.9.4" resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.4.tgz#23fae724e282fa25745aff82ca4d394748db7d8d" integrity sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA== From 7a832cd65c163351ca351d84688fcf94cda9d27d Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 23 Sep 2025 18:09:38 +0000 Subject: [PATCH 12/22] chore(release): v1.39.0-develop.4 [skip ci] # [1.39.0-develop.4](https://github.com/WatWowMap/ReactMap/compare/v1.39.0-develop.3...v1.39.0-develop.4) (2025-09-23) ### Features * Compact Route View ([#1131](https://github.com/WatWowMap/ReactMap/issues/1131)) ([795976e](https://github.com/WatWowMap/ReactMap/commit/795976e2ea9f68ba54ad5495bbf81af2716b4920)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b77a20b3d..e3a5f46d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.39.0-develop.4](https://github.com/WatWowMap/ReactMap/compare/v1.39.0-develop.3...v1.39.0-develop.4) (2025-09-23) + + +### Features + +* Compact Route View ([#1131](https://github.com/WatWowMap/ReactMap/issues/1131)) ([795976e](https://github.com/WatWowMap/ReactMap/commit/795976e2ea9f68ba54ad5495bbf81af2716b4920)) + # [1.39.0-develop.3](https://github.com/WatWowMap/ReactMap/compare/v1.39.0-develop.2...v1.39.0-develop.3) (2025-09-22) diff --git a/package.json b/package.json index 56269d525..404d90f5c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reactmap", - "version": "1.39.0-develop.3", + "version": "1.39.0-develop.4", "private": true, "description": "React based frontend map.", "license": "MIT", From 8c0ba372db29e53f2b74215494a8b170feab192a Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 23 Sep 2025 11:26:06 -0700 Subject: [PATCH 13/22] fix: refinements to interaction between gyms/stops and routes --- src/features/gym/GymTile.jsx | 11 ++++-- src/features/pokestop/PokestopTile.jsx | 17 +++++++-- src/features/route/RouteLayer.jsx | 36 ++++++++++++++--- src/features/route/RouteTile.jsx | 2 + src/features/route/constants.js | 3 ++ src/features/route/index.js | 1 + src/features/route/useRouteStore.js | 53 +++++++++++++++++++++++--- 7 files changed, 106 insertions(+), 17 deletions(-) create mode 100644 src/features/route/constants.js diff --git a/src/features/gym/GymTile.jsx b/src/features/gym/GymTile.jsx index 6b0e6f032..139731905 100644 --- a/src/features/gym/GymTile.jsx +++ b/src/features/gym/GymTile.jsx @@ -12,7 +12,7 @@ import { useForcePopup } from '@hooks/useForcePopup' import { sendNotification } from '@services/desktopNotification' import { TooltipWrapper } from '@components/ToolTipWrapper' import { getTimeUntil } from '@utils/getTimeUntil' -import { useRouteStore } from '@features/route' +import { useRouteStore, resolveRoutePoiKey } from '@features/route' import { gymMarker } from './gymMarker' import { GymPopup } from './GymPopup' @@ -39,7 +39,12 @@ const getColor = (team) => { const BaseGymTile = (gym) => { const [markerRef, setMarkerRef] = React.useState(null) const [stateChange, setStateChange] = React.useState(false) - const hasRoutes = useRouteStore((s) => !!s.poiIndex[gym.id]) + const hasRoutes = useRouteStore( + React.useCallback( + (state) => !!resolveRoutePoiKey(state.poiIndex, gym.id, gym.lat, gym.lon), + [gym.id, gym.lat, gym.lon], + ), + ) const selectPoi = useRouteStore((s) => s.selectPoi) const [ @@ -185,7 +190,7 @@ const BaseGymTile = (gym) => { eventHandlers={{ click: () => { if (hasRoutes) { - selectPoi(gym.id) + selectPoi(gym.id, gym.lat, gym.lon) } }, }} diff --git a/src/features/pokestop/PokestopTile.jsx b/src/features/pokestop/PokestopTile.jsx index 0a517a425..6db1bc190 100644 --- a/src/features/pokestop/PokestopTile.jsx +++ b/src/features/pokestop/PokestopTile.jsx @@ -6,7 +6,7 @@ import { Marker, Popup, Circle } from 'react-leaflet' import { useMarkerTimer } from '@hooks/useMarkerTimer' import { basicEqualFn, useMemory } from '@store/useMemory' import { useStorage } from '@store/useStorage' -import { useRouteStore } from '@features/route' +import { useRouteStore, resolveRoutePoiKey } from '@features/route' import { useForcePopup } from '@hooks/useForcePopup' import { TooltipWrapper } from '@components/ToolTipWrapper' @@ -21,7 +21,18 @@ import { usePokestopMarker } from './usePokestopMarker' const BasePokestopTile = (pokestop) => { const [stateChange, setStateChange] = React.useState(false) const [markerRef, setMarkerRef] = React.useState(null) - const hasRoutes = useRouteStore((s) => !!s.poiIndex[pokestop.id]) + const hasRoutes = useRouteStore( + React.useCallback( + (state) => + !!resolveRoutePoiKey( + state.poiIndex, + pokestop.id, + pokestop.lat, + pokestop.lon, + ), + [pokestop.id, pokestop.lat, pokestop.lon], + ), + ) const selectPoi = useRouteStore((s) => s.selectPoi) const [ @@ -136,7 +147,7 @@ const BasePokestopTile = (pokestop) => { eventHandlers={{ click: () => { if (hasRoutes) { - selectPoi(pokestop.id) + selectPoi(pokestop.id, pokestop.lat, pokestop.lon) } }, }} diff --git a/src/features/route/RouteLayer.jsx b/src/features/route/RouteLayer.jsx index 09d14f75d..34fc1087e 100644 --- a/src/features/route/RouteLayer.jsx +++ b/src/features/route/RouteLayer.jsx @@ -1,12 +1,13 @@ // @ts-check import * as React from 'react' -import { Marker, useMapEvents } from 'react-leaflet' +import { Marker, useMap, useMapEvents } from 'react-leaflet' import { divIcon } from 'leaflet' import { useStorage } from '@store/useStorage' import { RouteTile } from './RouteTile' import { routeMarker } from './routeMarker' +import { ROUTE_MARKER_PANE } from './constants' import { useRouteStore, ROUTE_COORD_EPSILON, @@ -25,28 +26,31 @@ const RouteAnchor = React.memo( variant = 'start', icon = null, }) => { + const hideMarker = variant !== 'destination' && entry.isFort const baseIcon = React.useMemo( () => icon || routeMarker(variant === 'destination' ? 'end' : 'start'), [icon, variant], ) + const showBadge = routeCount > 1 || (hideMarker && routeCount > 0) const badgeIcon = React.useMemo(() => { - if (routeCount <= 1) return null + if (!showBadge) return null return divIcon({ className: 'route-count-wrapper', html: `${routeCount}`, iconSize: [0, 0], iconAnchor: [0, 0], }) - }, [routeCount, variant]) + }, [routeCount, showBadge, variant]) return ( <> - {variant !== 'destination' && !selected && ( + {variant !== 'destination' && !selected && !hideMarker && ( onSelect(entry.key), }} @@ -57,12 +61,20 @@ const RouteAnchor = React.memo( onSelect(entry.key), + } + : undefined + } /> )} @@ -83,6 +95,7 @@ const ActiveRoute = React.memo(({ selection }) => { }) export function RouteLayer({ routes }) { + const map = useMap() const enabled = useStorage((s) => !!s.filters?.routes?.enabled) const compactView = useStorage( (s) => s.userSettings.routes?.compactView ?? true, @@ -95,6 +108,17 @@ export function RouteLayer({ routes }) { const selectPoi = useRouteStore((s) => s.selectPoi) const clearSelection = useRouteStore((s) => s.clearSelection) + React.useEffect(() => { + if (!map) return + let pane = map.getPane(ROUTE_MARKER_PANE) + if (!pane) { + pane = map.createPane(ROUTE_MARKER_PANE) + } + if (pane) { + pane.style.zIndex = '400' + } + }, [map]) + React.useEffect(() => { syncRoutes(routes || []) }, [routes, syncRoutes]) diff --git a/src/features/route/RouteTile.jsx b/src/features/route/RouteTile.jsx index aa1d09350..dd95c6297 100644 --- a/src/features/route/RouteTile.jsx +++ b/src/features/route/RouteTile.jsx @@ -8,6 +8,7 @@ import { darken } from '@mui/material/styles' import { useForcePopup } from '@hooks/useForcePopup' import { routeMarker } from './routeMarker' +import { ROUTE_MARKER_PANE } from './constants' import { RoutePopup } from './RoutePopup' const POSITIONS = /** @type {const} */ (['start', 'end']) @@ -191,6 +192,7 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { ? routeMarker('start') : routeMarker(position) } + pane={ROUTE_MARKER_PANE} eventHandlers={{ popupopen: () => setClicked(true), popupclose: () => setClicked(false), diff --git a/src/features/route/constants.js b/src/features/route/constants.js new file mode 100644 index 000000000..1f1bf5a2a --- /dev/null +++ b/src/features/route/constants.js @@ -0,0 +1,3 @@ +// @ts-check + +export const ROUTE_MARKER_PANE = 'routeMarkerPane' diff --git a/src/features/route/index.js b/src/features/route/index.js index 13724a8e4..944bb7cac 100644 --- a/src/features/route/index.js +++ b/src/features/route/index.js @@ -5,3 +5,4 @@ export * from './RoutePopup' export * from './RouteTile' export * from './RouteLayer' export * from './useRouteStore' +export * from './constants' diff --git a/src/features/route/useRouteStore.js b/src/features/route/useRouteStore.js index 5d400053e..7c2ca9a29 100644 --- a/src/features/route/useRouteStore.js +++ b/src/features/route/useRouteStore.js @@ -72,6 +72,45 @@ const collectNearbyRoutes = (poiIndex, entry, routeCache) => { return combined } +/** + * Attempts to locate the route POI key that corresponds to a given + * fortress/pokestop id or the provided coordinates. + * + * @param {Record} poiIndex + * @param {string} poiId + * @param {number | undefined} lat + * @param {number | undefined} lon + * @returns {string} + */ +export const resolveRoutePoiKey = (poiIndex, poiId, lat, lon) => { + if (poiId && poiIndex[poiId]) { + return poiId + } + + if (typeof lat === 'number' && typeof lon === 'number') { + const startKey = fallbackKey(lat, lon, 'start') + if (poiIndex[startKey]) { + return startKey + } + + const endKey = fallbackKey(lat, lon, 'end') + if (poiIndex[endKey]) { + return endKey + } + + const nearby = Object.values(poiIndex).find( + (candidate) => + Math.abs(candidate.lat - lat) <= ROUTE_COORD_EPSILON && + Math.abs(candidate.lon - lon) <= ROUTE_COORD_EPSILON, + ) + if (nearby) { + return nearby.key + } + } + + return '' +} + /** * @param {Record} poiIndex * @param {import('@rm/types').Route} route @@ -109,7 +148,7 @@ const addPoiEntry = (poiIndex, route, orientation) => { * activePoiId: string, * activeRoutes: RouteSelection[], * syncRoutes: (routes: import('@rm/types').Route[]) => void, - * selectPoi: (poiId: string) => void, + * selectPoi: (poiId: string, lat?: number, lon?: number) => void, * clearSelection: () => void, * }} RouteStore */ @@ -163,19 +202,23 @@ export const useRouteStore = create( } }) }, - selectPoi: (poiId) => { + selectPoi: (poiId, lat, lon) => { set((state) => { if (!poiId) { return state } - if (state.activePoiId === poiId) { + const key = resolveRoutePoiKey(state.poiIndex, poiId, lat, lon) + if (!key) { + return state + } + if (state.activePoiId === key) { return { ...state, activePoiId: '', activeRoutes: [], } } - const entry = state.poiIndex[poiId] + const entry = state.poiIndex[key] if (!entry) { return state } @@ -186,7 +229,7 @@ export const useRouteStore = create( ) return { ...state, - activePoiId: poiId, + activePoiId: key, activeRoutes: routes, } }) From f02e8cd927d76c9191e7fe6ea909f6eec9a4a729 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 23 Sep 2025 11:42:10 -0700 Subject: [PATCH 14/22] fix: localization --- packages/locales/lib/human/en.json | 2 ++ src/features/route/RouteLayer.jsx | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/locales/lib/human/en.json b/packages/locales/lib/human/en.json index 993e04662..22317b425 100644 --- a/packages/locales/lib/human/en.json +++ b/packages/locales/lib/human/en.json @@ -662,6 +662,8 @@ "routes": "Routes", "route_type": "Route Type", "route_short_code": "Route Share Code: ", + "route_anchor_count_one": "{{count}} Route", + "route_anchor_count_other": "{{count}} Routes", "shortcode_copied_to_clipboard": "Code copied to clipboard!", "copy_failed": "Copy failed", "routes_subtitle": "View in game routes and relevant information about them on the map", diff --git a/src/features/route/RouteLayer.jsx b/src/features/route/RouteLayer.jsx index 34fc1087e..307c0cf96 100644 --- a/src/features/route/RouteLayer.jsx +++ b/src/features/route/RouteLayer.jsx @@ -2,6 +2,7 @@ import * as React from 'react' import { Marker, useMap, useMapEvents } from 'react-leaflet' import { divIcon } from 'leaflet' +import { t } from 'i18next' import { useStorage } from '@store/useStorage' @@ -32,6 +33,8 @@ const RouteAnchor = React.memo( [icon, variant], ) const showBadge = routeCount > 1 || (hideMarker && routeCount > 0) + const routeCountTitle = + routeCount > 0 ? t('route_anchor_count', { count: routeCount }) : '' const badgeIcon = React.useMemo(() => { if (!showBadge) return null return divIcon({ @@ -54,7 +57,7 @@ const RouteAnchor = React.memo( eventHandlers={{ click: () => onSelect(entry.key), }} - title={routeCount > 1 ? `${routeCount} routes` : ''} + title={routeCountTitle} /> )} {badgeIcon && ( @@ -67,7 +70,7 @@ const RouteAnchor = React.memo( zIndexOffset={ selected ? ACTIVE_Z_INDEX + 200 : INACTIVE_Z_INDEX + 200 } - title={`${routeCount} route${routeCount === 1 ? '' : 's'}`} + title={routeCountTitle} eventHandlers={ hideMarker ? { From 7b851bba63602916cbba44de895d4bc4fb09e10f Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 23 Sep 2025 20:23:43 +0000 Subject: [PATCH 15/22] chore(release): v1.39.0-develop.5 [skip ci] # [1.39.0-develop.5](https://github.com/WatWowMap/ReactMap/compare/v1.39.0-develop.4...v1.39.0-develop.5) (2025-09-23) ### Bug Fixes * localization ([f02e8cd](https://github.com/WatWowMap/ReactMap/commit/f02e8cd927d76c9191e7fe6ea909f6eec9a4a729)) * refinements to interaction between gyms/stops and routes ([8c0ba37](https://github.com/WatWowMap/ReactMap/commit/8c0ba372db29e53f2b74215494a8b170feab192a)) --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3a5f46d1..513f91488 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# [1.39.0-develop.5](https://github.com/WatWowMap/ReactMap/compare/v1.39.0-develop.4...v1.39.0-develop.5) (2025-09-23) + + +### Bug Fixes + +* localization ([f02e8cd](https://github.com/WatWowMap/ReactMap/commit/f02e8cd927d76c9191e7fe6ea909f6eec9a4a729)) +* refinements to interaction between gyms/stops and routes ([8c0ba37](https://github.com/WatWowMap/ReactMap/commit/8c0ba372db29e53f2b74215494a8b170feab192a)) + # [1.39.0-develop.4](https://github.com/WatWowMap/ReactMap/compare/v1.39.0-develop.3...v1.39.0-develop.4) (2025-09-23) diff --git a/package.json b/package.json index 404d90f5c..2332a03cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reactmap", - "version": "1.39.0-develop.4", + "version": "1.39.0-develop.5", "private": true, "description": "React based frontend map.", "license": "MIT", From 5a02a6b81ca3c559dd2fea41a0b4c5a382560ed1 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 23 Sep 2025 11:03:51 -0700 Subject: [PATCH 16/22] fix: translation --- AGENTS.md | 1 + packages/locales/lib/human/en.json | 1 + src/features/drawer/Routes.jsx | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 97e67e5be..463d55544 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,4 +36,5 @@ ## Localization Notes - Update English copy only in `packages/locales/lib/human/en.json`; run `yarn locales:generate` to refresh derived languages. +- When adding a new translation key (for example when calling `t('some_key')`), create the English entry in `packages/locales/lib/human/en.json` in the same change. NEVER use fallback strings. - Never edit generated locale files directly—the automation pipeline syncs translations downstream. diff --git a/packages/locales/lib/human/en.json b/packages/locales/lib/human/en.json index 22317b425..91fea7797 100644 --- a/packages/locales/lib/human/en.json +++ b/packages/locales/lib/human/en.json @@ -660,6 +660,7 @@ "version": "Version", "route_tags": "Route Tags", "routes": "Routes", + "compact_route_view": "Compact Route View", "route_type": "Route Type", "route_short_code": "Route Share Code: ", "route_anchor_count_one": "{{count}} Route", diff --git a/src/features/drawer/Routes.jsx b/src/features/drawer/Routes.jsx index b7cb322ae..19b69afb6 100644 --- a/src/features/drawer/Routes.jsx +++ b/src/features/drawer/Routes.jsx @@ -48,7 +48,7 @@ const RouteSlider = () => { /> } > - + Date: Tue, 23 Sep 2025 23:51:22 +0000 Subject: [PATCH 17/22] chore(release): v1.39.0-develop.6 [skip ci] # [1.39.0-develop.6](https://github.com/WatWowMap/ReactMap/compare/v1.39.0-develop.5...v1.39.0-develop.6) (2025-09-23) ### Bug Fixes * translation ([5a02a6b](https://github.com/WatWowMap/ReactMap/commit/5a02a6b81ca3c559dd2fea41a0b4c5a382560ed1)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 513f91488..a253f455d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.39.0-develop.6](https://github.com/WatWowMap/ReactMap/compare/v1.39.0-develop.5...v1.39.0-develop.6) (2025-09-23) + + +### Bug Fixes + +* translation ([5a02a6b](https://github.com/WatWowMap/ReactMap/commit/5a02a6b81ca3c559dd2fea41a0b4c5a382560ed1)) + # [1.39.0-develop.5](https://github.com/WatWowMap/ReactMap/compare/v1.39.0-develop.4...v1.39.0-develop.5) (2025-09-23) diff --git a/package.json b/package.json index 2332a03cd..fe5bfe923 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reactmap", - "version": "1.39.0-develop.5", + "version": "1.39.0-develop.6", "private": true, "description": "React based frontend map.", "license": "MIT", From d44a3df6834e3752a129a2fa5706526076cd9144 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 23 Sep 2025 22:10:20 -0700 Subject: [PATCH 18/22] feat: estimated shiny probability for pokemon (#1133) * feat: estimated shiny probability for pokemon * feat: shiny since date * feat: lazy load shiny rates NB The database holding the spawnpoint table will be used to query pokemon_shiny_stats. --- packages/locales/lib/human/en.json | 3 +- packages/types/lib/scanner.d.ts | 6 + packages/types/lib/server.d.ts | 2 + server/src/graphql/resolvers.js | 26 ++ server/src/graphql/typeDefs/index.graphql | 1 + server/src/graphql/typeDefs/map.graphql | 1 + server/src/graphql/typeDefs/scanner.graphql | 6 + server/src/models/Pokemon.js | 264 +++++++++++++++++++- server/src/services/DbManager.js | 9 + src/features/pokemon/PokemonPopup.jsx | 125 ++++++++- src/features/pokestop/PokestopPopup.jsx | 23 +- src/hooks/useMapData.js | 17 +- src/services/queries/available.js | 1 + src/services/queries/pokemon.js | 10 + src/store/useMemory.js | 3 + src/utils/readableProbability.js | 20 ++ 16 files changed, 485 insertions(+), 32 deletions(-) create mode 100644 src/utils/readableProbability.js diff --git a/packages/locales/lib/human/en.json b/packages/locales/lib/human/en.json index 91fea7797..c2e936cda 100644 --- a/packages/locales/lib/human/en.json +++ b/packages/locales/lib/human/en.json @@ -377,7 +377,8 @@ "with_ar": "With AR", "both": "Both", "without_ar": "Without AR", - "shiny_probability": "Shiny probability: <0/>", + "shiny_probability": "Shiny rate: <0/>", + "shiny_sample": "{{percentage}}%: {{shiny}} shiny/{{checks}} checks since {{date}}", "exclude_quest_multi": "Exclude {{reward}}", "cluster_limit_0": "{{variable_0}} limit ({{variable_1}}) has been hit", "cluster_limit_1": "Please zoom in or narrow your filters", diff --git a/packages/types/lib/scanner.d.ts b/packages/types/lib/scanner.d.ts index e6db2b943..b66bccc2d 100644 --- a/packages/types/lib/scanner.d.ts +++ b/packages/types/lib/scanner.d.ts @@ -41,6 +41,12 @@ export interface PokemonDisplay { location_card: number } +export interface PokemonShinyStats { + shiny_seen: number + encounters_seen: number + since_date?: string +} + export interface Defender extends PokemonDisplay { pokemon_id: number deployed_ms: number diff --git a/packages/types/lib/server.d.ts b/packages/types/lib/server.d.ts index 76659e940..e4211fdd9 100644 --- a/packages/types/lib/server.d.ts +++ b/packages/types/lib/server.d.ts @@ -46,6 +46,8 @@ export interface DbContext { hasShowcaseForm: boolean hasShowcaseType: boolean hasStationedGmax: boolean + hasPokemonShinyStats?: boolean + connection?: number } export interface ExpressUser extends User { diff --git a/server/src/graphql/resolvers.js b/server/src/graphql/resolvers.js index f9333a3c3..a0b4705bf 100644 --- a/server/src/graphql/resolvers.js +++ b/server/src/graphql/resolvers.js @@ -24,6 +24,14 @@ const resolvers = { JSON: GraphQLJSON, Query: { available: (_, _args, { Event, Db, perms }) => { + const supportsShinyStats = Array.isArray(Db.models?.Pokemon) + ? Db.models.Pokemon.some(({ SubModel, ...ctx }) => + typeof SubModel.supportsShinyStats === 'function' + ? SubModel.supportsShinyStats(ctx) + : false, + ) + : false + const data = { questConditions: perms.quests ? Db.questConditions : {}, masterfile: { ...Event.masterfile, invasions: Event.invasions }, @@ -36,6 +44,7 @@ const resolvers = { ...config.getSafe('icons'), styles: Event.uicons, }, + supportsShinyStats, } return data }, @@ -269,6 +278,23 @@ const resolvers = { } return {} }, + pokemonShinyStats: async (_, args, { perms, Db }) => { + if (!perms?.pokemon) { + return null + } + const sources = Db.models?.Pokemon + if (!Array.isArray(sources)) { + return null + } + const results = await Promise.all( + sources.map(({ SubModel, ...ctx }) => + typeof SubModel.getShinyStats === 'function' + ? SubModel.getShinyStats(perms, args, ctx) + : Promise.resolve(null), + ), + ) + return results.find(Boolean) || null + }, portals: (_, args, { perms, Db }) => { if (perms?.portals) { return Db.query('Portal', 'getAll', perms, args) diff --git a/server/src/graphql/typeDefs/index.graphql b/server/src/graphql/typeDefs/index.graphql index eff77b33b..e2bd1aaa2 100644 --- a/server/src/graphql/typeDefs/index.graphql +++ b/server/src/graphql/typeDefs/index.graphql @@ -50,6 +50,7 @@ type Query { filters: JSON ): [Pokemon] pokemonSingle(id: ID, perm: String): Pokemon + pokemonShinyStats(pokemon_id: Int!, form: Int): PokemonShinyStats portals( minLat: Float maxLat: Float diff --git a/server/src/graphql/typeDefs/map.graphql b/server/src/graphql/typeDefs/map.graphql index 86b3a12b8..4621b1c95 100644 --- a/server/src/graphql/typeDefs/map.graphql +++ b/server/src/graphql/typeDefs/map.graphql @@ -4,6 +4,7 @@ type MapData { questConditions: JSON icons: JSON audio: JSON + supportsShinyStats: Boolean } type Badge { diff --git a/server/src/graphql/typeDefs/scanner.graphql b/server/src/graphql/typeDefs/scanner.graphql index 0d324f920..a3affe29b 100644 --- a/server/src/graphql/typeDefs/scanner.graphql +++ b/server/src/graphql/typeDefs/scanner.graphql @@ -148,6 +148,12 @@ type Pokestop { hasShowcase: Boolean } +type PokemonShinyStats { + shiny_seen: Int + encounters_seen: Int + since_date: String +} + type Pokemon { id: ID encounter_id: Int diff --git a/server/src/models/Pokemon.js b/server/src/models/Pokemon.js index e04a12388..8a9784182 100644 --- a/server/src/models/Pokemon.js +++ b/server/src/models/Pokemon.js @@ -380,6 +380,7 @@ class Pokemon extends Model { finalResults.push(result) } } + return finalResults } @@ -436,6 +437,210 @@ class Pokemon extends Model { return results || [] } + /** + * @param {number | null | undefined} preferredConnection + * @returns {import('knex').Knex | null} + */ + static getStatsKnex(preferredConnection = null) { + return this.getStatsHandle(preferredConnection)?.knex ?? null + } + + /** + * @param {number | null | undefined} preferredConnection + * @returns {{ + * knex: import('knex').Knex, + * connection: number, + * spawnSource?: import('@rm/types').DbContext & { connection: number }, + * } | null} + */ + static getStatsHandle(preferredConnection = null) { + const dbManager = state.db + if (!dbManager) return null + const { connections } = dbManager + if (!connections?.length) return null + + const spawnSources = dbManager.models?.Spawnpoint + + const getSpawnByConnection = (connection) => + Array.isArray(spawnSources) + ? spawnSources.find((source) => source.connection === connection) + : undefined + + const hasConnection = (connection) => + typeof connection === 'number' && Boolean(connections?.[connection]) + + if (!Array.isArray(spawnSources) || !spawnSources.length) { + return null + } + + let candidate = null + + if (typeof preferredConnection === 'number') { + candidate = spawnSources.find( + (source) => + source.connection === preferredConnection && + source.hasPokemonShinyStats && + hasConnection(source.connection), + ) + } + + if (!candidate) { + candidate = spawnSources.find( + (source) => + source.hasPokemonShinyStats && hasConnection(source.connection), + ) + } + + if (!candidate) { + return null + } + + const knexInstance = connections?.[candidate.connection] + if (!knexInstance) { + return null + } + + return { + knex: knexInstance, + connection: candidate.connection, + spawnSource: getSpawnByConnection(candidate.connection), + } + } + + /** + * @param {import("@rm/types").DbContext} ctx + * @returns {boolean} + */ + static supportsShinyStats(ctx) { + const statsHandle = this.getStatsHandle(ctx?.connection) + if (!statsHandle?.knex) { + return false + } + const flag = + typeof statsHandle.spawnSource?.hasPokemonShinyStats === 'boolean' + ? statsHandle.spawnSource.hasPokemonShinyStats + : typeof ctx?.hasPokemonShinyStats === 'boolean' + ? ctx.hasPokemonShinyStats + : false + return flag + } + + /** + * @param {string[]} keys + * @param {import('knex').Knex | null | undefined} [statsKnex] + * @param {number | null | undefined} [preferredConnection] + * @returns {Promise>} + */ + static async fetchShinyStats( + keys, + statsKnex = null, + preferredConnection = null, + ) { + if (!keys.length) return new Map() + + let knexInstance = statsKnex || null + if (!knexInstance) { + const statsHandle = this.getStatsHandle(preferredConnection) + knexInstance = statsHandle?.knex ?? null + } + if (!knexInstance) { + try { + knexInstance = this.knex() + } catch (e) { + knexInstance = null + } + } + if (!knexInstance) return new Map() + + const pairs = keys + .map((key) => key.split('-')) + .map(([pokemonId, formId]) => { + const parsedPokemon = Number.parseInt(pokemonId, 10) + if (Number.isNaN(parsedPokemon)) return null + const parsedForm = Number.parseInt(formId, 10) + return [parsedPokemon, Number.isNaN(parsedForm) ? 0 : parsedForm] + }) + .filter(Boolean) + + if (!pairs.length) return new Map() + + const whereClause = pairs + .map(() => '(pokemon_id = ? AND COALESCE(form_id, 0) = ?)') + .join(' OR ') + const bindings = pairs.flatMap(([pokemonId, formId]) => [pokemonId, formId]) + const query = ` + SELECT + pokemon_id, + COALESCE(form_id, 0) AS form_id, + date, + SUM(count) AS shiny, + SUM(total) AS checks + FROM pokemon_shiny_stats + WHERE area = 'world' + AND fence = 'world' + AND (${whereClause}) + AND date >= CURRENT_DATE - INTERVAL 7 DAY + GROUP BY pokemon_id, form_id, date + ORDER BY pokemon_id, form_id, date DESC + ` + + const [rows] = await knexInstance.raw(query, bindings) + + const grouped = new Map() + for (let i = 0; i < rows.length; i += 1) { + const row = rows[i] + const key = `${row.pokemon_id}-${row.form_id ?? 0}` + const entry = grouped.get(key) + const rowDate = + row.date instanceof Date + ? row.date.toISOString().slice(0, 10) + : `${row.date}` + const payload = { + shiny: Number(row.shiny) || 0, + checks: Number(row.checks) || 0, + date: rowDate, + } + if (entry) { + entry.push(payload) + } else { + grouped.set(key, [payload]) + } + } + + const statsMap = new Map() + const today = new Date() + today.setHours(0, 0, 0, 0) + const cutoff = new Date(today) + cutoff.setDate(cutoff.getDate() - 1) + const cutoffStr = cutoff.toISOString().slice(0, 10) + + grouped.forEach((entries, key) => { + let shinySum = 0 + let checkSum = 0 + let sinceDate = null + for (let i = 0; i < entries.length; i += 1) { + const { shiny, checks, date } = entries[i] + const includeRecent = date >= cutoffStr + // 20000 checks would give >99% of distinguishing even 1/512 from 1/256 + if (!includeRecent && checkSum >= 20000) { + break + } + shinySum += shiny + checkSum += checks + if (!sinceDate || date < sinceDate) { + sinceDate = date + } + } + statsMap.set(key, { + shiny_seen: shinySum, + encounters_seen: checkSum, + since_date: sinceDate, + }) + }) + + return statsMap + } + /** * @param {import("@rm/types").Permissions} perms * @param {object} args @@ -512,12 +717,13 @@ class Pokemon extends Model { secret, httpAuth, ) - return results - .filter( - (item) => - !mem || - filterRTree(item, perms.areaRestrictions, args.filters.onlyAreas), - ) + const filtered = results.filter( + (item) => + !mem || + filterRTree(item, perms.areaRestrictions, args.filters.onlyAreas), + ) + + const built = filtered .map((item) => { const filter = filterMap[ @@ -536,6 +742,52 @@ class Pokemon extends Model { ] || globalFilter return filter.valid(pkmn) }) + + return built + } + + /** + * @param {import("@rm/types").Permissions} _perms + * @param {{ pokemon_id: number, form?: number | null }} args + * @param {import("@rm/types").DbContext} ctx + * @returns {Promise} + */ + static async getShinyStats(_perms, args, ctx) { + const statsHandle = this.getStatsHandle(ctx?.connection) + if (!statsHandle?.knex) { + return null + } + const hasStats = + typeof statsHandle.spawnSource?.hasPokemonShinyStats === 'boolean' + ? statsHandle.spawnSource.hasPokemonShinyStats + : typeof ctx?.hasPokemonShinyStats === 'boolean' + ? ctx.hasPokemonShinyStats + : false + if (!hasStats) { + return null + } + const pokemonId = Number.parseInt(`${args.pokemon_id}`, 10) + if (Number.isNaN(pokemonId)) { + return null + } + const formId = Number.parseInt(`${args.form ?? 0}`, 10) + const key = `${pokemonId}-${Number.isNaN(formId) ? 0 : formId}` + try { + const stats = await this.fetchShinyStats( + [key], + statsHandle.knex, + statsHandle.connection, + ) + return stats.get(key) || null + } catch (e) { + log.error(TAGS.pokemon, 'Failed to fetch shiny stats', e) + if (e?.code === 'ER_NO_SUCH_TABLE') { + if (statsHandle.spawnSource) { + statsHandle.spawnSource.hasPokemonShinyStats = false + } + } + return null + } } /** diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index ff7a3ec61..ee1915074 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -185,6 +185,13 @@ class DbManager extends Logger { .columnInfo() .then((columns) => ['shortcode' in columns]) + let hasPokemonShinyStats + try { + hasPokemonShinyStats = await schema.schema.hasTable('pokemon_shiny_stats') + } catch (e) { + hasPokemonShinyStats = false + } + return { isMad, pvpV2, @@ -207,6 +214,7 @@ class DbManager extends Logger { hasShowcaseType, hasStationedGmax, hasShortcode, + hasPokemonShinyStats, } } @@ -227,6 +235,7 @@ class DbManager extends Logger { // Add support for HTTP authentication httpAuth: this.endpoints[i].httpAuth, pvpV2: true, + hasPokemonShinyStats: false, } Object.entries(this.models).forEach(([category, sources]) => { diff --git a/src/features/pokemon/PokemonPopup.jsx b/src/features/pokemon/PokemonPopup.jsx index 5593130c1..246f71115 100644 --- a/src/features/pokemon/PokemonPopup.jsx +++ b/src/features/pokemon/PokemonPopup.jsx @@ -1,5 +1,6 @@ // @ts-check import * as React from 'react' +import { useLazyQuery } from '@apollo/client' import ExpandMore from '@mui/icons-material/ExpandMore' import MoreVert from '@mui/icons-material/MoreVert' import Grid from '@mui/material/Unstable_Grid2' @@ -11,7 +12,7 @@ import Divider from '@mui/material/Divider' import Avatar from '@mui/material/Avatar' import Tooltip from '@mui/material/Tooltip' import Collapse from '@mui/material/Collapse' -import { useTranslation } from 'react-i18next' +import { useTranslation, Trans } from 'react-i18next' import { useMemory } from '@store/useMemory' import { setDeepStore, useStorage } from '@store/useStorage' @@ -26,6 +27,8 @@ import { ExtraInfo } from '@components/popups/ExtraInfo' import { useAnalytics } from '@hooks/useAnalytics' import { getTimeUntil } from '@utils/getTimeUntil' import { StatusIcon } from '@components/StatusIcon' +import { readableProbability } from '@utils/readableProbability' +import { GET_POKEMON_SHINY_STATS } from '@services/queries/pokemon' const rowClass = { width: 30, fontWeight: 'bold' } @@ -73,6 +76,65 @@ export function PokemonPopup({ pokemon, iconUrl, isTutorial = false }) { const hasLeagues = cleanPvp ? Object.keys(cleanPvp) : [] const hasStats = iv || cp + const supportsShinyStats = useMemory((s) => s.featureFlags.supportsShinyStats) + const shinyKey = React.useMemo( + () => `${pokemon.pokemon_id}-${pokemon.form ?? 0}`, + [pokemon.pokemon_id, pokemon.form], + ) + const [shinyStats, setShinyStats] = React.useState(null) + const pendingShinyKey = React.useRef(null) + const [loadShinyStats] = useLazyQuery(GET_POKEMON_SHINY_STATS) + + React.useEffect(() => { + setShinyStats(null) + pendingShinyKey.current = null + }, [shinyKey]) + + React.useEffect(() => { + if (!supportsShinyStats) { + setShinyStats(null) + pendingShinyKey.current = null + } + }, [supportsShinyStats]) + + React.useEffect(() => { + if (!supportsShinyStats) { + pendingShinyKey.current = null + return + } + if (shinyStats || !pokemon.pokemon_id) { + return + } + if (pendingShinyKey.current === shinyKey) { + return + } + let isActive = true + pendingShinyKey.current = shinyKey + loadShinyStats({ + variables: { + pokemonId: pokemon.pokemon_id, + form: pokemon.form ?? 0, + }, + fetchPolicy: 'cache-first', + }) + .then(({ data }) => { + if (!isActive || pendingShinyKey.current !== shinyKey) { + return + } + if (data?.pokemonShinyStats) { + setShinyStats(data.pokemonShinyStats) + } + }) + .catch(() => { + if (isActive && pendingShinyKey.current === shinyKey) { + pendingShinyKey.current = null + } + }) + return () => { + isActive = false + } + }, [supportsShinyStats, shinyStats, shinyKey, loadShinyStats]) + useAnalytics( 'Popup', `ID: ${pokemon.pokemon_id} IV: ${pokemon.iv}% PVP: #${pokemon.bestPvp}`, @@ -123,6 +185,7 @@ export function PokemonPopup({ pokemon, iconUrl, isTutorial = false }) { timeOfDay={timeOfDay} t={t} /> +