Skip to content

Commit

Permalink
feat(map): add location map
Browse files Browse the repository at this point in the history
* feat(map): add location map

* fix: create highCareSite for allPatient Location

* fix: change name of variables

---------

Co-authored-by: Salah-BOUYAHIA <salah.bouyahia-ext@aphp.fr>
  • Loading branch information
pl-buiquang and Mehdi-BOUYAHIA authored Jun 20, 2024
1 parent a606c8d commit 4daa155
Show file tree
Hide file tree
Showing 15 changed files with 332 additions and 11 deletions.
1 change: 1 addition & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ sed -i "s@{VITE_ODD_IMAGING}@$VITE_ODD_IMAGING@g" /app/build/assets/*.js
sed -i "s@{VITE_ODD_FEASABILITY_REPORT}@$VITE_ODD_FEASABILITY_REPORT@g" /app/build/assets/*.js
sed -i "s@{VITE_ODD_QUESTIONNAIRE}@$VITE_ODD_QUESTIONNAIRE@g" /app/build/assets/*.js
sed -i "s@{VITE_JTOOL_USERS}@$VITE_JTOOL_USERS@g" /app/build/assets/*.js
sed -i "s@{VITE_ODD_MAP}@$VITE_ODD_MAP@g" /app/build/assets/*.js

# Restart nginx to apply changes
service nginx restart
Expand Down
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
/>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
Expand Down
51 changes: 51 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 7 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"dompurify": "^3.0.9",
"graphql": "^15.6.1",
"html-react-parser": "^4.0.0",
"leaflet": "^1.9.4",
"localforage": "^1.10.0",
"moment": "^2.29.3",
"prop-types": "^15.8.1",
Expand All @@ -36,6 +37,7 @@
"react-dom": "^18.2.0",
"react-idle-timer": "^5.5.3",
"react-infinite-scroll-component": "^6.1.0",
"react-leaflet": "^4.2.1",
"react-pdf": "^8.0.2",
"react-redux": "^8.0.5",
"react-router": "^6.9.0",
Expand Down Expand Up @@ -84,11 +86,13 @@
"@types/dompurify": "^3.0.5",
"@types/fhir": "^0.0.37",
"@types/jest": "^28.1.8",
"@types/leaflet": "^1.9.12",
"@types/lodash": "^4.14.191",
"@types/node": "^18.15.7",
"@types/react": "^18.2.6",
"@types/react-dom": "^18.2.4",
"@types/react-html-parser": "^2.0.2",
"@types/react-leaflet": "^3.0.0",
"@types/react-pdf": "^7.0.0",
"@types/react-redux": "^7.1.25",
"@types/react-router": "^5.1.20",
Expand All @@ -111,11 +115,11 @@
"prettier": "^2.8.7",
"typescript": "^4.9.5",
"vite": "^5.0.0",
"vite-plugin-static-copy": "1.0.0",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^4.2.0",
"vitest": "^0.34.4",
"vite-plugin-top-level-await": "^1.4.1",
"vite-plugin-static-copy": "1.0.0"
"vite-tsconfig-paths": "^4.2.0",
"vitest": "^0.34.4"
},
"jest": {
"clearMocks": true
Expand Down
131 changes: 131 additions & 0 deletions src/components/Dashboard/Preview/LocationMap/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import React, { useCallback, useEffect, useState } from 'react'
import L, { LatLngTuple } from 'leaflet'
// https://github.com/PaulLeCam/react-leaflet/issues/1077
//@ts-ignore
import { Polygon, Tooltip } from 'react-leaflet'
//@ts-ignore
import { MapContainer } from 'react-leaflet/MapContainer'
//@ts-ignore
import { TileLayer } from 'react-leaflet/TileLayer'
import { fetchLocation } from 'services/aphp/callApi'
import { getAllResults } from 'utils/apiHelpers'
import { parseShape } from './utils'
import { cancelPendingRequest } from 'utils/abortController'
import * as d3 from 'd3'
import { CircularProgress } from '@mui/material'

const LOCATION_FETCH_BATCH_SIZE = 1000
const MAX_COUNT_QUANTILE = 0.96
const ZONE_COLOR_OPACITY = 0.75
const DEFAULT_MAP_CENTER = [48.8575, 2.3514]
const TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
const MAP_COPYRIGHTS = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
const LOCATION_SHAPE_EXTENSION_URL = 'https://terminology.eds.aphp.fr/fhir/profile/location/extension/position'
const LOCATION_COUNT_EXTENSION_URL = 'https://terminology.eds.aphp.fr/fhir/profile/location/extension/count'

type LocationMapProps = {
cohortId: string
center?: LatLngTuple
}

const LocationMap = (props: LocationMapProps) => {
const { cohortId, center = DEFAULT_MAP_CENTER } = props
const [dataLoading, setDataLoading] = useState(true)
const [zones, setZones] = useState<Array<{ shape: LatLngTuple[]; meta: { count: number; name: string } }>>([])
const [maxCount, setMaxCount] = useState(1)

const colorPalette = useCallback(
(count: number) => {
const colors = [
'#ff0000',
'#ff4000',
'#fe5c00',
'#fd7300',
'#fc8613',
'#fb9725',
'#faa837',
'#fab749',
'#fac55c',
'#fad370'
]
const step = maxCount / colors.length
const colorIndex = Math.floor(count / step)
if (colorIndex > colors.length) {
return colors[0]
}
return colors.reverse()[colorIndex]
},
[maxCount]
)

useEffect(() => {
const abortController = new AbortController()
setDataLoading(true)
;(async () => {
const locations = await getAllResults(fetchLocation, {
_list: [cohortId],
size: LOCATION_FETCH_BATCH_SIZE,
signal: abortController.signal
})
if (locations) {
const newZones = locations.map((ft) => ({
shape: parseShape(ft.extension?.find((ext) => ext.url === LOCATION_SHAPE_EXTENSION_URL)?.valueString) || [],
meta: {
count: ft.extension?.find((ext) => ext.url === LOCATION_COUNT_EXTENSION_URL)?.valueInteger || 0,
name: ft.name || ''
}
}))
const maxCount =
d3.quantile(
newZones.map((zone) => zone.meta.count),
MAX_COUNT_QUANTILE
) || Math.max(...newZones.map((zone) => zone.meta.count))
setZones(newZones)
setMaxCount(maxCount)
setDataLoading(false)
}
})()
return () => {
cancelPendingRequest(abortController)
}
}, [cohortId])

return (
<div style={{ width: '100%', padding: '10px' }}>
{dataLoading ? (
<div
style={{ height: '500px', width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<CircularProgress />
</div>
) : (
<MapContainer
preferCanvas={true}
renderer={L.canvas()}
center={center}
zoom={10}
scrollWheelZoom={true}
style={{ height: '500px', width: '100%' }}
>
<TileLayer attribution={MAP_COPYRIGHTS} url={TILE_URL} />
{zones.map((zone, i) => (
<Polygon
key={i}
pathOptions={{ color: colorPalette(zone.meta.count), fillOpacity: ZONE_COLOR_OPACITY }}
positions={zone.shape}
>
<Tooltip sticky>
<div>
<b>{zone.meta.name}</b>
</div>
<div>Total: {zone.meta.count}</div>
</Tooltip>
</Polygon>
))}
</MapContainer>
)}
</div>
)
}

export default LocationMap
16 changes: 16 additions & 0 deletions src/components/Dashboard/Preview/LocationMap/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { parseShape } from "./utils";


describe('Shape parser should work.', function () {
it('Should return null on bad expression', function () {
expect(parseShape('POLYLOT((2.3514 48.8575, 2.3514 48.8575, 2.3514 48.8575))')).toBeNull()
expect(parseShape('POLYGON(2.3514 48.8575, 2.3514 48.8575, 2.3514 48.8575))')).toBeNull()
expect(parseShape('POLYGON(2.3514 48.8575 2.3514 48.8575, 2.3514 48.8575))')).toBeNull()
});

it('Should return null on bad expression', function () {
const expression = parseShape('POLYGON((2.3514 48.8575, 2.3514 48.8575, 2.3514 48.8575))');
const result = [[48.8575, 2.3514], [48.8575, 2.3514], [48.8575, 2.3514]]
expect(expression).toEqual(result);
});
});
24 changes: 24 additions & 0 deletions src/components/Dashboard/Preview/LocationMap/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { LatLngTuple } from 'leaflet'

export const parseShape = (polygons?: string): LatLngTuple[] | null => {
if (!polygons) return null
const shapes: LatLngTuple[][] = polygons
.split('|')
.map((polygon) => {
const m = polygon.match(/POLYGON\(\((.*)\)\)/)
if (m) {
return m[1].split(',').map((latlng) => {
const [lng, lat] = latlng.trim().split(' ')
return [parseFloat(lat), parseFloat(lng)]
})
}
return null
})
.filter((polygon) => polygon !== null)
.map((polygon) => polygon as LatLngTuple[])
if (shapes.length === 0) {
return null
} else {
return shapes[0]
}
}
Loading

0 comments on commit 4daa155

Please sign in to comment.