Skip to content

Commit

Permalink
Merge pull request #863 from WatWowMap/super-cluster
Browse files Browse the repository at this point in the history
feat: super cluster lib
  • Loading branch information
TurtIeSocks authored Nov 2, 2023
2 parents 3f99b48 + 3c8bf8c commit 9ebafb1
Show file tree
Hide file tree
Showing 12 changed files with 343 additions and 121 deletions.
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -111,14 +110,14 @@
"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",
"react-window": "^1.8.9",
"rtree": "^1.4.2",
"source-map": "^0.7.4",
"suncalc": "^1.9.0",
"supercluster": "^8.0.1",
"zustand": "^4.4.1"
},
"devDependencies": {
Expand Down
5 changes: 3 additions & 2 deletions packages/locales/lib/human/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -671,5 +671,6 @@
"done": "Done",
"fast": "Fast",
"charged": "Charged",
"offline_mode": "Offline Mode"
}
"offline_mode": "Offline Mode",
"disable": "Disable {{- name}}"
}
44 changes: 44 additions & 0 deletions src/assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
192 changes: 149 additions & 43 deletions src/components/Clustering.jsx
Original file line number Diff line number Diff line change
@@ -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<import('geojson').Point>} 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: `<div><span>${feature.properties.point_count_abbreviated}</span></div>`,
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<typeof React.useRef<import('leaflet').GeoJSON>>} */
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<typeof Supercluster> | 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<import('geojson').Point>[]} */
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 (
<>
<MarkerClusterGroup
key={`${userCluster}-${limitHit}`}
disableClusteringAtZoom={limitHit ? 20 : clusteringRules.zoomLevel}
chunkedLoading
>
{children}
</MarkerClusterGroup>
<Notification
open={limitHit}
severity="warning"
i18nKey="cluster_limit"
messages={[
{
key: 'limitHit',
variables: [category, clusteringRules.forcedLimit || 0],
},
{
key: 'zoomIn',
variables: [],
},
]}
/>
<GeoJSON ref={featureRef} data={null} pointToLayer={createClusterIcon} />
{children.length > rules.forcedLimit || userCluster
? children.filter((x) => x && markers.has(x.key))
: children}
{limitHit && (
<Notification
open={!!limitHit}
severity="warning"
i18nKey="cluster_limit"
messages={[
{
key: 'limitHit',
variables: [category, rules.forcedLimit.toString()],
},
{
key: 'zoomIn',
variables: [],
},
]}
/>
)}
</>
) : (
children
)
}

export default Clustering
7 changes: 5 additions & 2 deletions src/components/Config.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 9ebafb1

Please sign in to comment.