Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 22 additions & 16 deletions app/components/cluster-marker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
]

/**
Expand All @@ -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
Expand All @@ -43,26 +40,35 @@ 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 = `<svg
width="${w}"
height="${w}"
viewBox="0 0 ${w} ${w}"
text-anchor="middle"
style="font: bold ${fontSize}px sans-serif; display: block;"
>
${[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)
Expand All @@ -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}"
/>
`
})
Expand Down
234 changes: 199 additions & 35 deletions app/routes/explore.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { 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,
type MapInstance,
type ViewStateChangeEvent,
} from 'react-map-gl/maplibre'
import {
Outlet,
Expand All @@ -30,12 +31,72 @@ import { getLocale } from '~/middleware/i18next'
import { getUser, getUserSession } from '~/services/session-service.server'
import { getFilteredDevices } from '~/utils'
import maplibregl, {
LngLatLike,
MapLayerMouseEvent,
MapLibreEvent,
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
Expand Down Expand Up @@ -157,8 +218,18 @@ export default function Explore() {
// data from our loader
const { devices, filteredDevices } = useLoaderData<typeof loader>()
const mapRef = useRef<MapRef | null>(null)
// MapLibre markers are imperative DOM nodes, so refs avoid stale React state.
const clusterMarkersRef = useRef<Record<string, ClusterMarkerRecord>>({})
const visibleClusterIdsRef = useRef<Set<string>>(new Set())
const navigate = useNavigate()
// const [showSearch, setShowSearch] = useState<boolean>(false);
const clusterMarkers = useMemo<Record<string, maplibregl.Marker>>(
() => ({}),
[],
)
const [onScreenClusterMarkers, setOnScreenClusterMarkers] = useState<
Record<string, maplibregl.Marker>
>({})
const [selectedPheno, setSelectedPheno] = useState<any | undefined>(undefined)
const [searchParams] = useSearchParams()
const [filteredData, setFilteredData] = useState<
Expand Down Expand Up @@ -459,6 +530,124 @@ export default function Explore() {
])
}

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) => {
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<string>()

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
}

if (!record) {
record = {
signature,
marker: ClusterMarker({
clusterFeature: feature as Feature<Point, any>,
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 (const id of visibleClusterIdsRef.current) {
if (!nextVisibleClusterIds.has(id)) {
clusterMarkersRef.current[id]?.marker.remove()
}
}

visibleClusterIdsRef.current = nextVisibleClusterIds
},
[removeAllClusterMarkers, selectedPheno],
)

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)
},
[updateClusterMarkers],
)

const handleMapMove = useCallback(
(e: ViewStateChangeEvent) => {
// Keep HTML markers aligned while MapLibre reclusters during movement.
updateClusterMarkers(e.target)
},
[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 (
<div className="h-full w-full">
<MapProvider>
Expand All @@ -481,6 +670,9 @@ export default function Explore() {
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onLoad={handleMapLoad}
onData={handleMapData}
onMove={handleMapMove}
onMoveEnd={handleMapMove}
ref={mapRef}
initialViewState={
deviceId
Expand Down Expand Up @@ -524,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',
}}
/>
<Layer
type="symbol"
id="cluster-count-layer"
source="osem-devices"
filter={['has', 'point_count']}
layout={{
'text-field': ['get', 'point_count'],
'text-size': [
'case',
['>=', ['get', 'point_count'], 1000],
14,
['>=', ['get', 'point_count'], 100],
12,
10,
],
}}
paint={{
'text-color': '#000',
'circle-stroke-width': 0,
}}
/>
<Layer
Expand Down