diff --git a/package.json b/package.json index a7c3e1f09..da230d2f3 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,6 @@ "knex": "^2.4.2", "leaflet": "1.9.4", "leaflet.locatecontrol": "^0.73.0", - "leaflet.markercluster": "1.5.3", "lodash.debounce": "^4.0.8", "moment-timezone": "^0.5.43", "morgan": "^1.10.0", @@ -111,7 +110,6 @@ "react-ga4": "^1.4.1", "react-i18next": "^11.16.7", "react-leaflet": "4.2.1", - "react-leaflet-cluster": "2.1.0", "react-router-dom": "^6.15.0", "react-virtualized-auto-sizer": "^1.0.20", "react-virtuoso": "^4.5.0", @@ -119,6 +117,7 @@ "rtree": "^1.4.2", "source-map": "^0.7.4", "suncalc": "^1.9.0", + "supercluster": "^8.0.1", "zustand": "^4.4.1" }, "devDependencies": { diff --git a/packages/locales/lib/human/en.json b/packages/locales/lib/human/en.json index 2da6f9438..edfd5aa83 100644 --- a/packages/locales/lib/human/en.json +++ b/packages/locales/lib/human/en.json @@ -671,5 +671,6 @@ "done": "Done", "fast": "Fast", "charged": "Charged", - "offline_mode": "Offline Mode" -} + "offline_mode": "Offline Mode", + "disable": "Disable {{- name}}" +} \ No newline at end of file diff --git a/src/assets/css/main.css b/src/assets/css/main.css index faea8d3ef..eeb6993ad 100644 --- a/src/assets/css/main.css +++ b/src/assets/css/main.css @@ -433,3 +433,47 @@ input[type='time']::-webkit-calendar-picker-indicator { min-width: 25px !important; border-radius: 50% !important; } + +.marker-cluster-small { + background-color: rgba(181, 226, 140, 0.6); +} + +.marker-cluster-small div { + background-color: rgba(110, 204, 57, 0.6); +} + +.marker-cluster-medium { + background-color: rgba(241, 211, 87, 0.6); +} + +.marker-cluster-medium div { + background-color: rgba(240, 194, 12, 0.6); +} + +.marker-cluster-large { + background-color: rgba(253, 156, 115, 0.6); +} + +.marker-cluster-large div { + background-color: rgba(241, 128, 23, 0.6); +} + +.marker-cluster { + background-clip: padding-box; + border-radius: 20px; +} + +.marker-cluster div { + width: 30px; + height: 30px; + margin-left: 5px; + margin-top: 5px; + + text-align: center; + border-radius: 15px; + font: 12px 'Helvetica Neue', Arial, Helvetica, sans-serif; +} + +.marker-cluster span { + line-height: 30px; +} diff --git a/src/components/Clustering.jsx b/src/components/Clustering.jsx index 97e71ee1b..050ce86a4 100644 --- a/src/components/Clustering.jsx +++ b/src/components/Clustering.jsx @@ -1,66 +1,172 @@ +// @ts-check import * as React from 'react' -import MarkerClusterGroup from 'react-leaflet-cluster' +import { useMap, GeoJSON } from 'react-leaflet' +import Supercluster from 'supercluster' +import { marker, divIcon, point } from 'leaflet' import { useStatic, useStore } from '@hooks/useStore' - import Notification from './layout/general/Notification' -const IGNORE_CLUSTERING = ['devices', 'submissionCells', 'scanCells', 'weather'] +const IGNORE_CLUSTERING = new Set([ + 'devices', + 'submissionCells', + 'scanCells', + 'weather', +]) + +/** + * + * @param {import('geojson').Feature} feature + * @param {import('leaflet').LatLng} latlng + * @returns + */ +function createClusterIcon(feature, latlng) { + if (!feature.properties.cluster) return null + + const count = feature.properties.point_count + const size = count < 100 ? 'small' : count < 1000 ? 'medium' : 'large' + const icon = divIcon({ + html: `
${feature.properties.point_count_abbreviated}
`, + className: `marker-cluster marker-cluster-${size}`, + iconSize: point(40, 40), + }) + return marker(latlng, { icon }) +} /** * * @param {{ * category: keyof import('@rm/types').Config['api']['polling'], - * children: React.ReactNode[] - * }} param0 + * children: React.ReactElement<{ lat: number, lon: number }>[] + * }} props * @returns */ -export default function Clustering({ category, children }) { +function Clustering({ category, children }) { + /** @type {ReturnType>} */ + const featureRef = React.useRef(null) + + const map = useMap() + const userCluster = useStore( + (s) => s.userSettings[category]?.clustering || false, + ) const { config: { - clustering: { [category]: clustering }, - general: { minZoom }, + clustering, + general: { minZoom: configMinZoom }, }, } = useStatic.getState() - const clusteringRules = clustering || { - forcedLimit: 10000, - zoomLevel: minZoom, - } - - const userCluster = useStore( - (s) => s.userSettings[category]?.clustering || false, + const [rules] = React.useState( + category in clustering + ? clustering[category] + : { + forcedLimit: 10000, + zoomLevel: configMinZoom, + }, ) + const [markers, setMarkers] = React.useState(new Set()) + const [superCluster, setSuperCluster] = React.useState( + /** @type {InstanceType | null} */ (null), + ) + const [limitHit, setLimitHit] = React.useState( + children.length > rules.forcedLimit && !IGNORE_CLUSTERING.has(category), + ) + + React.useEffect(() => { + setLimitHit( + children.length > rules.forcedLimit && !IGNORE_CLUSTERING.has(category) + ? !!rules.forcedLimit + : false, + ) + }, [category, userCluster, rules.forcedLimit, children.length]) - const limitHit = - children.length > clusteringRules.forcedLimit && - !IGNORE_CLUSTERING.includes(category) + React.useEffect(() => { + if (limitHit || userCluster) { + setSuperCluster( + new Supercluster({ + radius: 60, + extent: 256, + maxZoom: rules.zoomLevel, + minPoints: category === 'pokemon' ? 7 : 5, + }), + ) + } else { + setSuperCluster(null) + } + }, [rules.zoomLevel, limitHit, userCluster, category]) - return limitHit || (clusteringRules.zoomLevel && userCluster) ? ( + React.useEffect(() => { + if (superCluster) { + /** @type {import('geojson').Feature[]} */ + const features = children.filter(Boolean).map((reactEl) => ({ + type: 'Feature', + id: reactEl?.key, + properties: {}, + geometry: { + type: 'Point', + coordinates: [reactEl.props.lon, reactEl.props.lat], + }, + })) + + superCluster.load(features) + + const bounds = map.getBounds() + /** @type {[number, number, number, number]} */ + const bbox = [ + bounds.getWest(), + bounds.getSouth(), + bounds.getEast(), + bounds.getNorth(), + ] + const zoom = map.getZoom() + + const rawClusters = superCluster.getClusters(bbox, zoom) + + const newClusters = [] + const newMarkers = new Set() + for (let i = 0; i < rawClusters.length; i += 1) { + const cluster = rawClusters[i] + if (cluster.properties.cluster) { + newClusters.push(cluster) + } else { + newMarkers.add(cluster.id) + } + } + // @ts-ignore + featureRef?.current?.addData(newClusters) + setMarkers(newMarkers) + } else { + setMarkers(new Set()) + } + return () => { + featureRef.current?.clearLayers() + } + }, [children, featureRef, superCluster]) + + return ( <> - - {children} - - + + {children.length > rules.forcedLimit || userCluster + ? children.filter((x) => x && markers.has(x.key)) + : children} + {limitHit && ( + + )} - ) : ( - children ) } + +export default Clustering diff --git a/src/components/Config.jsx b/src/components/Config.jsx index d3ee65494..b260309c9 100644 --- a/src/components/Config.jsx +++ b/src/components/Config.jsx @@ -8,6 +8,7 @@ import { setLoadingText } from '@services/functions/setLoadingText' import Utility from '@services/Utility' import { deepMerge } from '@services/functions/deepMerge' import { Navigate } from 'react-router-dom' +import { checkHoliday } from '@services/functions/checkHoliday' const rootLoading = document.getElementById('loader') @@ -109,13 +110,15 @@ export default function Config({ children }) { userBackupLimits: data.database.settings.userBackupLimits || 0, }, theme: data.map.theme, - holidayEffects: data.map.holidayEffects || [], ui: data.ui, menus: data.menus, extraUserFields: data.database.settings.extraUserFields, userSettings: data.clientMenus, timeOfDay: Utility.timeCheck(...location), - config: data.map, + config: { + ...data.map, + holidayEffects: (data.map.holidayEffects || []).filter(checkHoliday), + }, polling: data.api.polling, settings, gymValidDataLimit: data.api.gymValidDataLimit, diff --git a/src/components/HolidayEffects.jsx b/src/components/HolidayEffects.jsx index 5c83e1c2d..37f80edc4 100644 --- a/src/components/HolidayEffects.jsx +++ b/src/components/HolidayEffects.jsx @@ -1,73 +1,56 @@ // @ts-check import * as React from 'react' import HolidayAnimations from '@services/HolidayAnimations' -import { useStatic } from '@hooks/useStore' +import { useStatic, useStore } from '@hooks/useStore' /** * * @param {import("@rm/types").Config['map']['holidayEffects'][number]} props * @returns */ -export function HolidayEffect({ - enabled, - endDay, - endMonth, - images, - name, - startDay, - startMonth, - css, - imageScale, -}) { +export function HolidayEffect({ images, name, css, imageScale }) { const [element, setElement] = React.useState( /** @type {React.ReactNode} */ (null), ) + const userDisabled = useStore((s) => s.holidayEffects[name] === true) React.useLayoutEffect(() => { - const date = new Date() - const start = new Date( - date.getFullYear() - (startMonth > endMonth ? 1 : 0), - startMonth - 1, - startDay, - 0, - 0, - 0, - ) - const end = new Date(date.getFullYear(), endMonth - 1, endDay, 23, 59, 59) - if (enabled && date >= start && date <= end) { - switch (css) { - case 'snow': - setElement( -
-
-
-
-
-
-
-
, - ) - return () => setElement(null) - case 'fireworks': - setElement( -
-
-
-
, - ) - return () => setElement(null) - default: { - if (images?.length) { - const animation = new HolidayAnimations(images, imageScale) - animation.initialize() - return () => { - animation.stop() - } + if (userDisabled) { + setElement(null) + return () => {} + } + switch (css) { + case 'snow': + setElement( +
+
+
+
+
+
+
+
, + ) + return () => setElement(null) + case 'fireworks': + setElement( +
+
+
+
, + ) + return () => setElement(null) + default: { + if (images?.length) { + const animation = new HolidayAnimations(images, imageScale) + animation.initialize() + return () => { + animation.stop() } } } } - }, []) + }, [userDisabled]) return element } diff --git a/src/components/layout/drawer/BoolToggle.jsx b/src/components/layout/drawer/BoolToggle.jsx index 4902bf28d..8dc2e9d6c 100644 --- a/src/components/layout/drawer/BoolToggle.jsx +++ b/src/components/layout/drawer/BoolToggle.jsx @@ -7,6 +7,8 @@ import Switch from '@mui/material/Switch' import { useStore } from '@hooks/useStore' import { useTranslation } from 'react-i18next' import { fromSnakeCase } from '@services/functions/fromSnakeCase' +import dlv from 'dlv' +import { setDeep } from '@services/functions/setDeep' /** * @param {{ @@ -23,20 +25,27 @@ export default function BoolToggle({ disabled = false, children, }) { - const value = useStore((s) => s[field]) + const value = useStore((s) => dlv(s, field)) const { t } = useTranslation() + const onChange = React.useCallback( + ( + /** @type {React.ChangeEvent} */ _, + /** @type {boolean} */ checked, + ) => { + useStore.setState((prev) => setDeep(prev, field, checked)) + }, + [field], + ) return ( {children} - + + {t(label, fromSnakeCase(label)) ?? t(field, fromSnakeCase(field))} + useStore.setState({ [field]: v })} + onChange={onChange} checked={!!value} disabled={disabled} /> diff --git a/src/components/layout/drawer/Settings.jsx b/src/components/layout/drawer/Settings.jsx index 23f1eea9f..938b4368b 100644 --- a/src/components/layout/drawer/Settings.jsx +++ b/src/components/layout/drawer/Settings.jsx @@ -15,6 +15,7 @@ import NavIcon from '@mui/icons-material/Navigation' import StyleIcon from '@mui/icons-material/Style' import DevicesOtherIcon from '@mui/icons-material/DevicesOther' import Brightness7Icon from '@mui/icons-material/Brightness7' +import CakeIcon from '@mui/icons-material/Cake' import { useTranslation } from 'react-i18next' @@ -59,6 +60,7 @@ export default function Settings() { const separateDrawerActions = useStatic( (s) => s.config.general.separateDrawerActions, ) + const holidayEffects = useStatic((s) => s.config.holidayEffects) || [] const settings = useStore((s) => s.settings) const icons = useStore((s) => s.icons) @@ -140,6 +142,26 @@ export default function Settings() { + {holidayEffects.map(({ name, images }) => ( + + + {images?.length > 0 ? ( + {name} + ) : ( + + )} + + + ))} {!separateDrawerActions && ( <> diff --git a/src/hooks/useStore.js b/src/hooks/useStore.js index 32c704b09..9191cbeac 100644 --- a/src/hooks/useStore.js +++ b/src/hooks/useStore.js @@ -17,6 +17,7 @@ import { persist } from 'zustand/middleware' * tileServers: string * }, * menus: Record, + * holidayEffects: Record, * motdIndex: number * tutorial: boolean, * searchTab: string, @@ -69,6 +70,7 @@ export const useStore = create( }) } }, + holidayEffects: {}, settings: {}, userSettings: {}, icons: {}, @@ -189,7 +191,6 @@ export const useStatic = create((set) => ({ menuFilters: {}, userSettings: undefined, settings: undefined, - holidayEffects: [], available: { gyms: [], pokemon: [], diff --git a/src/services/functions/checkHoliday.js b/src/services/functions/checkHoliday.js new file mode 100644 index 000000000..30626ff27 --- /dev/null +++ b/src/services/functions/checkHoliday.js @@ -0,0 +1,28 @@ +// @ts-check +/** + * + * @param {import("packages/types/lib").Config['map']['holidayEffects'][number]} holiday + * @returns + */ +export function checkHoliday(holiday) { + if (!holiday.enabled) return false + + const date = new Date() + const start = new Date( + date.getFullYear() - (holiday.startMonth > holiday.endMonth ? 1 : 0), + holiday.startMonth - 1, + holiday.startDay, + 0, + 0, + 0, + ) + const end = new Date( + date.getFullYear(), + holiday.endMonth - 1, + holiday.endDay, + 23, + 59, + 59, + ) + return date >= start && date <= end +} diff --git a/src/services/functions/setDeep.js b/src/services/functions/setDeep.js new file mode 100644 index 000000000..99534911c --- /dev/null +++ b/src/services/functions/setDeep.js @@ -0,0 +1,26 @@ +// @ts-check +/** + * + * @param {object} obj + * @param {string | string[]} path + * @param {any} value + */ +export function setDeep(obj, path, value) { + if (typeof path === 'string') { + path = path.split('.') + } + if (path.length > 1) { + const e = path.shift() + setDeep( + (obj[e] = + Object.prototype.toString.call(obj[e]) === '[object Object]' + ? { ...obj[e] } + : {}), + path, + value, + ) + } else { + obj[path[0]] = value + } + return { ...obj } +} diff --git a/yarn.lock b/yarn.lock index 7014f0fe0..f8af09364 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3993,6 +3993,11 @@ jsx-ast-utils@^3.3.3: object.assign "^4.1.4" object.values "^1.1.6" +kdbush@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/kdbush/-/kdbush-4.0.2.tgz#2f7b7246328b4657dd122b6c7f025fbc2c868e39" + integrity sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA== + knex@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/knex/-/knex-2.4.2.tgz#a34a289d38406dc19a0447a78eeaf2d16ebedd61" @@ -4030,11 +4035,6 @@ leaflet.locatecontrol@^0.73.0: resolved "https://registry.yarnpkg.com/leaflet.locatecontrol/-/leaflet.locatecontrol-0.73.0.tgz#768d9edb0470f86c913ea6c2a70ec62380fd45c5" integrity sha512-e6v6SyDU2nzG5AiH80eH7qhXw5J+EfgmEFHkuzTRC9jqCSbfAm/3HlZDuoa9WYsaZbn5ovvqNeaLW/JSMsgg5g== -leaflet.markercluster@1.5.3, leaflet.markercluster@^1.5.3: - version "1.5.3" - resolved "https://registry.yarnpkg.com/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz#9cdb52a4eab92671832e1ef9899669e80efc4056" - integrity sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA== - leaflet@1.9.4: version "1.9.4" resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.4.tgz#23fae724e282fa25745aff82ca4d394748db7d8d" @@ -5118,13 +5118,6 @@ react-is@^18.2.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== -react-leaflet-cluster@2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/react-leaflet-cluster/-/react-leaflet-cluster-2.1.0.tgz#9e5299efb7b16eff75511a47ed4a5d763dcf55b5" - integrity sha512-16X7XQpRThQFC4PH4OpXHimGg19ouWmjxjtpxOeBKpvERSvIRqTx7fvhTwkEPNMFTQ8zTfddz6fRTUmUEQul7g== - dependencies: - leaflet.markercluster "^1.5.3" - react-leaflet@4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/react-leaflet/-/react-leaflet-4.2.1.tgz#c300e9eccaf15cb40757552e181200aa10b94780" @@ -5720,6 +5713,13 @@ suncalc@^1.9.0: resolved "https://registry.yarnpkg.com/suncalc/-/suncalc-1.9.0.tgz#26212353fae61edb287c2d558fc4932ecf0e1532" integrity sha512-vMJ8Byp1uIPoj+wb9c1AdK4jpkSKVAywgHX0lqY7zt6+EWRRC3Z+0Ucfjy/0yxTVO1hwwchZe4uoFNqrIC24+A== +supercluster@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/supercluster/-/supercluster-8.0.1.tgz#9946ba123538e9e9ab15de472531f604e7372df5" + integrity sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ== + dependencies: + kdbush "^4.0.2" + supports-color@^5.3.0, supports-color@^5.5.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"