From b4a68ce1736b75d886ba36c770ad04fc4a173175 Mon Sep 17 00:00:00 2001 From: David Scheidt Date: Mon, 4 May 2026 13:36:25 +0200 Subject: [PATCH 1/4] readd the html clusters with the donut chart in it --- app/routes/explore.tsx | 50 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/app/routes/explore.tsx b/app/routes/explore.tsx index c6970e94..9e582405 100644 --- a/app/routes/explore.tsx +++ b/app/routes/explore.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { type FeatureCollection, type Point } from 'geojson' +import { Feature, type FeatureCollection, type Point } from 'geojson' import { useState, useRef, useCallback, useMemo } from 'react' import { type MapRef, @@ -7,6 +7,7 @@ import { Layer, Source, MapInstance, + ViewStateChangeEvent, } from 'react-map-gl/maplibre' import { Outlet, @@ -33,9 +34,12 @@ import maplibregl, { LngLatLike, MapLayerMouseEvent, MapLibreEvent, + MapSourceDataEvent, + MapStyleDataEvent, type FilterSpecification, } from 'maplibre-gl' import BoxMarker from '~/components/map/layers/cluster/box-marker' +import { ClusterMarker } from '~/components/cluster-marker' export async function action({ request }: { request: Request }) { const deviceLimit = 50 @@ -153,6 +157,9 @@ if (process.env.NODE_ENV === 'production') { currentDate = new Date(Date.now() - 1000 * 600) } +const clusterMarkers: Record = {} +let onScreenClusterMarkers: Record = {} + export default function Explore() { // data from our loader const { devices, filteredDevices } = useLoaderData() @@ -459,6 +466,45 @@ export default function Explore() { ]) } + const updateMarkers = (map: MapInstance) => { + const newMarkers: Record = {} + const features = map.querySourceFeatures('osem-devices') + for (let i = 0; i < features.length; i++) { + const coords = (features[i].geometry as Point)?.coordinates as LngLatLike + if (!coords) continue + const props = features[i].properties + if (!props.cluster) continue + const id = props.cluster_id + let marker = clusterMarkers[id] + if (!marker) { + marker = clusterMarkers[id] = ClusterMarker({ + clusterFeature: features[i] as Feature, + map, + }) + } + newMarkers[id] = marker + if (!onScreenClusterMarkers[id]) marker.addTo(map) + } + // for every marker we've added previously, remove those that are no longer visible + for (const id in onScreenClusterMarkers) { + if (!newMarkers[id]) { + onScreenClusterMarkers[id].remove() + } + } + onScreenClusterMarkers = newMarkers + } + + const handleOnData = (e: MapStyleDataEvent | MapSourceDataEvent) => { + if (e.dataType === 'style') return + const ev = e as MapSourceDataEvent + if (ev.sourceId !== 'osem-devices' || !ev.isSourceLoaded) return + updateMarkers(e.target) + } + + const handleMove = (e: ViewStateChangeEvent) => { + updateMarkers(e.target) + } + return (
@@ -481,6 +527,8 @@ export default function Explore() { onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} onLoad={handleMapLoad} + onData={handleOnData} + onMove={handleMove} ref={mapRef} initialViewState={ deviceId From 4add405e00c5bcecf75565ed2c89d0a788fd4dbb Mon Sep 17 00:00:00 2001 From: David Scheidt Date: Mon, 4 May 2026 13:47:32 +0200 Subject: [PATCH 2/4] use useCallback and useMemo to remove file wide vars --- app/routes/explore.tsx | 88 ++++++++++++++++++++++++------------------ 1 file changed, 51 insertions(+), 37 deletions(-) diff --git a/app/routes/explore.tsx b/app/routes/explore.tsx index 9e582405..b67cbb52 100644 --- a/app/routes/explore.tsx +++ b/app/routes/explore.tsx @@ -157,15 +157,19 @@ if (process.env.NODE_ENV === 'production') { currentDate = new Date(Date.now() - 1000 * 600) } -const clusterMarkers: Record = {} -let onScreenClusterMarkers: Record = {} - export default function Explore() { // data from our loader const { devices, filteredDevices } = useLoaderData() const mapRef = useRef(null) const navigate = useNavigate() // const [showSearch, setShowSearch] = useState(false); + const clusterMarkers = useMemo>( + () => ({}), + [], + ) + const [onScreenClusterMarkers, setOnScreenClusterMarkers] = useState< + Record + >({}) const [selectedPheno, setSelectedPheno] = useState(undefined) const [searchParams] = useSearchParams() const [filteredData, setFilteredData] = useState< @@ -466,44 +470,54 @@ export default function Explore() { ]) } - const updateMarkers = (map: MapInstance) => { - const newMarkers: Record = {} - const features = map.querySourceFeatures('osem-devices') - for (let i = 0; i < features.length; i++) { - const coords = (features[i].geometry as Point)?.coordinates as LngLatLike - if (!coords) continue - const props = features[i].properties - if (!props.cluster) continue - const id = props.cluster_id - let marker = clusterMarkers[id] - if (!marker) { - marker = clusterMarkers[id] = ClusterMarker({ - clusterFeature: features[i] as Feature, - map, - }) + const updateMarkers = useCallback( + (map: MapInstance) => { + const newMarkers: Record = {} + const features = map.querySourceFeatures('osem-devices') + for (let i = 0; i < features.length; i++) { + const coords = (features[i].geometry as Point) + ?.coordinates as LngLatLike + if (!coords) continue + const props = features[i].properties + if (!props.cluster) continue + const id = props.cluster_id + let marker = clusterMarkers[id] + if (!marker) { + marker = clusterMarkers[id] = ClusterMarker({ + clusterFeature: features[i] as Feature, + map, + }) + } + newMarkers[id] = marker + if (!onScreenClusterMarkers[id]) marker.addTo(map) } - newMarkers[id] = marker - if (!onScreenClusterMarkers[id]) marker.addTo(map) - } - // for every marker we've added previously, remove those that are no longer visible - for (const id in onScreenClusterMarkers) { - if (!newMarkers[id]) { - onScreenClusterMarkers[id].remove() + // for every marker we've added previously, remove those that are no longer visible + for (const id in onScreenClusterMarkers) { + if (!newMarkers[id]) { + onScreenClusterMarkers[id].remove() + } } - } - onScreenClusterMarkers = newMarkers - } + setOnScreenClusterMarkers(newMarkers) + }, + [onScreenClusterMarkers, clusterMarkers], + ) - const handleOnData = (e: MapStyleDataEvent | MapSourceDataEvent) => { - if (e.dataType === 'style') return - const ev = e as MapSourceDataEvent - if (ev.sourceId !== 'osem-devices' || !ev.isSourceLoaded) return - updateMarkers(e.target) - } + const handleOnData = useCallback( + (e: MapStyleDataEvent | MapSourceDataEvent) => { + if (e.dataType === 'style') return + const ev = e as MapSourceDataEvent + if (ev.sourceId !== 'osem-devices' || !ev.isSourceLoaded) return + updateMarkers(e.target) + }, + [updateMarkers], + ) - const handleMove = (e: ViewStateChangeEvent) => { - updateMarkers(e.target) - } + const handleMove = useCallback( + (e: ViewStateChangeEvent) => { + updateMarkers(e.target) + }, + [updateMarkers], + ) return (
From 7abc341f153ec1c04fc9902d08f495cc1c7aa88b Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 17 Jun 2026 16:31:30 +0200 Subject: [PATCH 3/4] feat: render active and inactive slices in donut cluster marker --- app/components/cluster-marker.ts | 38 ++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/app/components/cluster-marker.ts b/app/components/cluster-marker.ts index 31a3fe1c..b08257c1 100644 --- a/app/components/cluster-marker.ts +++ b/app/components/cluster-marker.ts @@ -4,8 +4,6 @@ import maplibregl from 'maplibre-gl' const colors = [ { color: '#4EAF47', opacity: 1 }, { color: '#575757', opacity: 0.65 }, - { color: '#575757', opacity: 0.65 }, - { color: '#38AADD', opacity: 1 }, ] /** @@ -20,10 +18,9 @@ export const ClusterMarker = (props: { const coords = clusterFeature.geometry.coordinates const longitude = coords[0] const latitude = coords[1] - const pointCount = clusterFeature.properties?.point_count ?? 0 - const active = clusterFeature.properties?.active ?? 0 - const inactive = clusterFeature.properties?.inactive ?? 0 - const old = clusterFeature.properties?.old ?? 0 + const pointCount = Number(clusterFeature.properties?.point_count ?? 0) + const active = Number(clusterFeature.properties?.active ?? 0) + const inactive = Math.max(pointCount - active, 0) const fontSize = pointCount >= 1000 ? 14 @@ -43,15 +40,23 @@ export const ClusterMarker = (props: { const r0 = Math.round(r * 0.7) const w = r * 2 - const arcOffsets: number[] = [] + const segments = [active, inactive].map((count, i) => ({ + count, + color: colors[i], + offset: 0, + })) let total = 0 - for (const c of [active, inactive, old]) { - arcOffsets.push(total) - total += c + for (const segment of segments) { + segment.offset = total + total += segment.count } const e = document.createElement('div') + e.setAttribute( + 'aria-label', + `${pointCount} devices, ${active} active, ${inactive} inactive`, + ) e.innerHTML = ` - ${[active, inactive, old] - .map((count, i) => { - const start = arcOffsets[i] / total - let end = (arcOffsets[i] + count) / total + ${segments + .filter((segment) => segment.count > 0) + .map((segment) => { + const start = segment.offset / total + let end = (segment.offset + segment.count) / total if (end - start === 1) end -= 0.00001 const a0 = 2 * Math.PI * (start - 0.25) @@ -82,8 +88,8 @@ export const ClusterMarker = (props: { } ${r + r0 * y1} A ${r0} ${r0} 0 ${largeArc} 0 ${r + r0 * x0} ${ r + r0 * y0 }" - fill="${colors[i].color}" - fill-opacity="${colors[i].opacity}" + fill="${segment.color.color}" + fill-opacity="${segment.color.opacity}" /> ` }) From 599b1e5a637dcc58f079966879ec6cd9fbc38f32 Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 17 Jun 2026 16:32:01 +0200 Subject: [PATCH 4/4] feat: marker lifecycle, remove stale HTML markers --- app/routes/explore.tsx | 250 +++++++++++++++++++++++++++++------------ 1 file changed, 176 insertions(+), 74 deletions(-) diff --git a/app/routes/explore.tsx b/app/routes/explore.tsx index b67cbb52..6fd171ba 100644 --- a/app/routes/explore.tsx +++ b/app/routes/explore.tsx @@ -1,13 +1,13 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { Feature, type FeatureCollection, type Point } from 'geojson' -import { useState, useRef, useCallback, useMemo } from 'react' +import { type Feature, type FeatureCollection, type Point } from 'geojson' +import { useState, useRef, useCallback, useMemo, useEffect } from 'react' import { type MapRef, MapProvider, Layer, Source, - MapInstance, - ViewStateChangeEvent, + type MapInstance, + type ViewStateChangeEvent, } from 'react-map-gl/maplibre' import { Outlet, @@ -31,15 +31,72 @@ import { getLocale } from '~/middleware/i18next' import { getUser, getUserSession } from '~/services/session-service.server' import { getFilteredDevices } from '~/utils' import maplibregl, { - LngLatLike, - MapLayerMouseEvent, - MapLibreEvent, - MapSourceDataEvent, - MapStyleDataEvent, + type LngLatLike, + type MapLayerMouseEvent, + type MapLibreEvent, + type MapSourceDataEvent, + type MapStyleDataEvent, + type MapLibreMap, + type StyleImageMetadata, type FilterSpecification, } from 'maplibre-gl' import BoxMarker from '~/components/map/layers/cluster/box-marker' import { ClusterMarker } from '~/components/cluster-marker' +// import MapHeader from '~/components/map/topbar' +// import { getMeasurementsCount } from '~/db/models/measurement.server' +import { getTags } from '~/services/device-service.server' +import { getPhenomena } from '~/db/models/phenomena.server' +// import { DOWNLOAD_FILTER_KEYS } from '~/components/header/download' + +const INITIAL_VIEW_STATE = { + zoom: 2, + latitude: 7, + longitude: 52, +} as const + +type ClusterMarkerRecord = { + marker: maplibregl.Marker + signature: string +} + +function parseMapHash(hash: string) { + const match = hash.match( + /^#?(-?\d+(?:\.\d+)?)\/(-?\d+(?:\.\d+)?)\/(-?\d+(?:\.\d+)?)$/, + ) + + if (!match) return null + + const [, zoom, latitude, longitude] = match + + return { + zoom: Number(zoom), + latitude: Number(latitude), + longitude: Number(longitude), + } +} + +// function parseCsv(value: FormDataEntryValue | null): string[] { +// if (typeof value !== 'string') return [] + +// return value +// .split(',') +// .map((item) => item.trim()) +// .filter(Boolean) +// } + +// function getDownloadFilterParams(formData: FormData) { +// const filterParams = new URLSearchParams() + +// for (const key of DOWNLOAD_FILTER_KEYS) { +// const value = formData.get(key) + +// if (typeof value === 'string' && value.length > 0) { +// filterParams.set(key, value) +// } +// } + +// return filterParams +// } export async function action({ request }: { request: Request }) { const deviceLimit = 50 @@ -161,6 +218,9 @@ export default function Explore() { // data from our loader const { devices, filteredDevices } = useLoaderData() const mapRef = useRef(null) + // MapLibre markers are imperative DOM nodes, so refs avoid stale React state. + const clusterMarkersRef = useRef>({}) + const visibleClusterIdsRef = useRef>(new Set()) const navigate = useNavigate() // const [showSearch, setShowSearch] = useState(false); const clusterMarkers = useMemo>( @@ -470,55 +530,124 @@ export default function Explore() { ]) } - const updateMarkers = useCallback( + const removeAllClusterMarkers = useCallback(() => { + // Used when the cluster layer is hidden or the explore map unmounts. + for (const { marker } of Object.values(clusterMarkersRef.current)) { + marker.remove() + } + + clusterMarkersRef.current = {} + visibleClusterIdsRef.current = new Set() + }, []) + + const updateClusterMarkers = useCallback( (map: MapInstance) => { - const newMarkers: Record = {} - const features = map.querySourceFeatures('osem-devices') - for (let i = 0; i < features.length; i++) { - const coords = (features[i].geometry as Point) - ?.coordinates as LngLatLike - if (!coords) continue - const props = features[i].properties - if (!props.cluster) continue - const id = props.cluster_id - let marker = clusterMarkers[id] - if (!marker) { - marker = clusterMarkers[id] = ClusterMarker({ - clusterFeature: features[i] as Feature, - map, - }) + if (selectedPheno || !map.getLayer('devices-clusters-layer')) { + removeAllClusterMarkers() + return + } + + // Sync against rendered features so removed layer clusters lose their HTML marker. + const renderedClusters = map.queryRenderedFeatures({ + layers: ['devices-clusters-layer'], + }) + const nextVisibleClusterIds = new Set() + + for (const feature of renderedClusters) { + const props = feature.properties + if (!props?.cluster) continue + + const id = String(props.cluster_id) + if (nextVisibleClusterIds.has(id)) continue + + const coordinates = (feature.geometry as Point).coordinates + // Count or position changes require a fresh SVG donut. + const signature = [ + props.point_count, + props.active, + props.inactive, + props.old, + coordinates[0], + coordinates[1], + ].join(':') + let record: ClusterMarkerRecord | undefined = + clusterMarkersRef.current[id] + + if (record && record.signature !== signature) { + record.marker.remove() + delete clusterMarkersRef.current[id] + record = undefined } - newMarkers[id] = marker - if (!onScreenClusterMarkers[id]) marker.addTo(map) + + if (!record) { + record = { + signature, + marker: ClusterMarker({ + clusterFeature: feature as Feature, + map, + }), + } + clusterMarkersRef.current[id] = record + } + + record.marker.setLngLat([coordinates[0], coordinates[1]]) + + if (!visibleClusterIdsRef.current.has(id)) { + record.marker.addTo(map) + } + + nextVisibleClusterIds.add(id) } - // for every marker we've added previously, remove those that are no longer visible - for (const id in onScreenClusterMarkers) { - if (!newMarkers[id]) { - onScreenClusterMarkers[id].remove() + + for (const id of visibleClusterIdsRef.current) { + if (!nextVisibleClusterIds.has(id)) { + clusterMarkersRef.current[id]?.marker.remove() } } - setOnScreenClusterMarkers(newMarkers) + + visibleClusterIdsRef.current = nextVisibleClusterIds }, - [onScreenClusterMarkers, clusterMarkers], + [removeAllClusterMarkers, selectedPheno], ) - const handleOnData = useCallback( - (e: MapStyleDataEvent | MapSourceDataEvent) => { - if (e.dataType === 'style') return - const ev = e as MapSourceDataEvent - if (ev.sourceId !== 'osem-devices' || !ev.isSourceLoaded) return - updateMarkers(e.target) + const handleMapData = useCallback( + (e: MapSourceDataEvent | MapStyleDataEvent) => { + if (e.dataType === 'source' && e.sourceId !== 'osem-devices') return + + // Source updates can create or remove clusters without a user move. + updateClusterMarkers(e.target as MapInstance) }, - [updateMarkers], + [updateClusterMarkers], ) - const handleMove = useCallback( + const handleMapMove = useCallback( (e: ViewStateChangeEvent) => { - updateMarkers(e.target) + // Keep HTML markers aligned while MapLibre reclusters during movement. + updateClusterMarkers(e.target) }, - [updateMarkers], + [updateClusterMarkers], ) + useEffect(() => { + if (selectedPheno) { + removeAllClusterMarkers() + return + } + + const map = mapRef.current?.getMap() + if (!map) return + + // Filters swap source data, so resync the rendered cluster markers. + updateClusterMarkers(map as MapInstance) + }, [ + filteredDevices, + removeAllClusterMarkers, + selectedPheno, + updateClusterMarkers, + ]) + + useEffect(() => removeAllClusterMarkers, [removeAllClusterMarkers]) + return (
@@ -541,8 +670,9 @@ export default function Explore() { onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} onLoad={handleMapLoad} - onData={handleOnData} - onMove={handleMove} + onData={handleMapData} + onMove={handleMapMove} + onMoveEnd={handleMapMove} ref={mapRef} initialViewState={ deviceId @@ -586,35 +716,7 @@ export default function Explore() { 10, ], 'circle-color': 'transparent', - 'circle-stroke-width': [ - 'case', - ['>=', ['get', 'point_count'], 1000], - 12, - ['>=', ['get', 'point_count'], 100], - 6, - 4, - ], - 'circle-stroke-color': '#4EAF47', - }} - /> - =', ['get', 'point_count'], 1000], - 14, - ['>=', ['get', 'point_count'], 100], - 12, - 10, - ], - }} - paint={{ - 'text-color': '#000', + 'circle-stroke-width': 0, }} />