Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#7704] React maps #1513

Merged
merged 4 commits into from
Mar 11, 2024
Merged
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
Empty file.
6 changes: 6 additions & 0 deletions adhocracy4/maps_react/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class Config(AppConfig):
name = "adhocracy4.maps_react"
label = "a4maps_react"
Empty file.
85 changes: 85 additions & 0 deletions adhocracy4/maps_react/static/a4maps_react/AddMarkerControl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import L from 'leaflet'
import { createControlComponent } from '@react-leaflet/core'
import { point, inside } from '@turf/turf'
import { makeIcon } from './GeoJsonMarker'

export function checkPointInsidePolygon (marker, polygons) {
const pointGeoJSON = point([marker.lng, marker.lat])
let isInPolygon = false

polygons.eachLayer((layer) => {
const polygonGeoJSON = layer.toGeoJSON()
if (inside(pointGeoJSON, polygonGeoJSON)) {
isInPolygon = true
}
})

return isInPolygon
}

const markerProps = { icon: makeIcon(), draggable: true }

export class AddMarkerControlClass extends L.Control {
constructor ({ input, point }) {
super()
this.marker = null
this.oldCoords = null
this.map = null
this.input = input

if (point) {
const pointObj = JSON.parse(point)
const latlng = pointObj.geometry.coordinates.reverse()
this.marker = L.marker(latlng, markerProps)
this.oldCoords = latlng
}
}

updateMarker (latlng) {
const isInsideConstraints = checkPointInsidePolygon(latlng, this.map.markerConstraints)
if (isInsideConstraints) {
this.oldCoords = latlng
if (this.marker) {
this.marker.setLatLng(latlng)
} else {
this.marker = L.marker(latlng, markerProps).addTo(this.map)
this.marker.on('dragend', this.onDragend.bind(this))
}
this.input.value = JSON.stringify(this.marker.toGeoJSON())
}
}

onDragend (e) {
const targetPosition = e.target.getLatLng()
const isInsideConstraints = checkPointInsidePolygon(targetPosition, this.map.markerConstraints)
if (!isInsideConstraints) {
e.target.setLatLng(this.oldCoords)
} else {
this.updateMarker(targetPosition)
}
}

addTo (map) {
this.map = map
this.boundClickHandler = (e) => this.updateMarker(e.latlng)
map.on('click', this.boundClickHandler)

if (this.marker) {
this.marker.addTo(this.map)
this.marker.on('dragend', this.onDragend.bind(this))
}
}

onRemove (map) {
map.off('click', this.boundClickHandler)
if (this.marker) {
this.marker.off('dragend', this.onDragend)
this.marker.remove()
this.marker = null
}
}
}
const createControl = (props) => new AddMarkerControlClass(props)

const AddMarkerControl = createControlComponent(createControl)
export default AddMarkerControl
36 changes: 36 additions & 0 deletions adhocracy4/maps_react/static/a4maps_react/GeoJsonMarker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import L from 'leaflet'
import {
createElementObject,
createLayerComponent, extendContext
} from '@react-leaflet/core'

export const makeIcon = (iconUrl) =>
L.icon({
iconUrl: iconUrl || '/static/images/map_pin_default.svg',
shadowUrl: '/static/images/map_shadow_01.svg',
iconSize: [30, 36],
iconAnchor: [15, 36],
shadowSize: [40, 54],
shadowAnchor: [20, 54],
popupAnchor: [0, -10]
})

/**
* Creates a Leaflet marker from a GeoJSON. This is needed to
* be able to add any Tooltip or Popup to the Markers using JSX.
*/
const createGeoJsonMarker = ({ feature, ...props }, context) => {
const coords = [...feature.geometry.coordinates].reverse()
const propsWithIcon = { icon: makeIcon(feature.properties.category_icon), ...props }
const instance = L.marker(coords, propsWithIcon)

return createElementObject(instance, extendContext(context, { overlayContainer: instance }))
}

const updateGeoJsonMarker = (instance, { feature, ...props }, prevProps) => {
const coords = [...feature.geometry.coordinates].reverse()
instance.setLatLng(coords)
}

const GeoJsonMarker = createLayerComponent(createGeoJsonMarker, updateGeoJsonMarker)
export default GeoJsonMarker
43 changes: 43 additions & 0 deletions adhocracy4/maps_react/static/a4maps_react/Map.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React, { useRef, useImperativeHandle } from 'react'
import { MapContainer, GeoJSON } from 'react-leaflet'
import MaplibreGlLayer from './MaplibreGlLayer'

const polygonStyle = {
color: '#0076ae',
weight: 2,
opacity: 1,
fillOpacity: 0.2
}

const Map = React.forwardRef(function Map (
{ attribution, baseUrl, polygon, omtToken, children, ...rest }, ref
) {
const map = useRef()
// forwarding our map ref
useImperativeHandle(ref, () => map.current)
const refCallback = (polygon) => {
if (!map.current || !polygon) {
return
}
map.current.fitBounds(polygon.getBounds())
map.current.setMinZoom(map.current.getZoom())
// used in AddMarkerControl to specify where markers can be placed
map.current.markerConstraints = polygon
}

return (
<MapContainer
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we make the map containers all have lazy load?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have a specific solution for that in your mind? Theoretically you could just lazy import any Map code within the projects by using React.lazy. Then we would not have to dictate every a4 project to be using lazy load.

style={{ minHeight: 300 }}
zoom={13}
maxZoom={18}
{...rest}
ref={map}
>
{polygon && <GeoJSON style={polygonStyle} data={polygon} ref={refCallback} />}
<MaplibreGlLayer attribution={attribution} baseUrl={baseUrl} omtToken={omtToken} />
{children}
</MapContainer>
)
})

export default Map
17 changes: 17 additions & 0 deletions adhocracy4/maps_react/static/a4maps_react/MapPopup.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from 'react'
import { Popup } from 'react-leaflet'

export const MapPopup = ({ feature, className, children, ...rest }) => {
const _className = 'maps-popups ' + (className ?? '')
return (
<Popup {...rest} className={_className} closeButton={false}>
<div
style={{ backgroundImage: 'url(' + feature.properties.image + ')' }}
className={'maps-popups-popup-image' + (feature.properties.image ? '' : ' maps-popups-popup-no-image')}
/>
<div className="maps-popups-popup-text-content">
{children}
</div>
</Popup>
)
}
38 changes: 38 additions & 0 deletions adhocracy4/maps_react/static/a4maps_react/MaplibreGlLayer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import L from 'leaflet'
import '@maplibre/maplibre-gl-leaflet'
import {
createElementObject,
createTileLayerComponent, updateGridLayer
} from '@react-leaflet/core'

const createMaplibreGlLayer = (props, context) => {
const instance = L.maplibreGL({
attribution: props.attribution,
style: props.baseUrl,
transformRequest: function (url, resourceType) {
if (resourceType === 'Tile' && url.indexOf('https://') === 0) {
return {
url: url + '?token=' + props.omtToken
}
}
}
})

return createElementObject(instance, context)
}

const updateMaplibreGlLayer = (instance, props, prevProps) => {
updateGridLayer(instance, props, prevProps)

const { baseUrl, attribution } = props
if (baseUrl != null && baseUrl !== prevProps.baseUrl) {
instance.getMaplibreMap().setStyle(baseUrl)
}

if (attribution != null && attribution !== prevProps.attribution) {
instance.options.attribution = attribution
}
}

const MaplibreGlLayer = createTileLayerComponent(createMaplibreGlLayer, updateMaplibreGlLayer)
export default MaplibreGlLayer
19 changes: 19 additions & 0 deletions adhocracy4/maps_react/static/a4maps_react/MarkerClusterLayer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import L from 'leaflet'
import 'leaflet.markercluster'
import {
createElementObject,
createLayerComponent, extendContext, updateGridLayer
} from '@react-leaflet/core'

const createMarkerClusterLayer = (props, context) => {
const instance = L.markerClusterGroup({ showCoverageOnHover: false })

return createElementObject(instance, extendContext(context, { layerContainer: instance }))
}

const updateMarkerClusterLayer = (instance, props, prevProps) => {
updateGridLayer(instance, props, prevProps)
}

const MarkerClusterLayer = createLayerComponent(createMarkerClusterLayer, updateMarkerClusterLayer)
export default MarkerClusterLayer
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import L from 'leaflet'
import { AddMarkerControlClass } from '../AddMarkerControl'
import { polygonData } from './Map.jest'
import { jest } from '@jest/globals'

describe('AddMarkerControlClass', () => {
const polygonGeoJSON = L.geoJSON(polygonData)
const map = { on: jest.fn(), off: jest.fn(), addLayer: jest.fn(), markerConstraints: polygonGeoJSON }
const point = JSON.stringify({
type: 'Feature',
properties: {},
geometry: {
type: 'Point',
coordinates: [5, 5]
}
})

it('sets a marker', () => {
const input = document.createElement('input')
const instance = new AddMarkerControlClass({ input })
instance.map = map

const latlng = { lat: 10, lng: 5 }

expect(instance.marker).toBe(null)
instance.updateMarker(latlng)
expect(instance.marker).toBeDefined()
expect(input.value).toEqual(expect.stringContaining('5,10'))
instance.updateMarker({ lat: 2, lng: 5 })
expect(input.value).toEqual(expect.stringContaining('5,2'))
})

it('does not set a marker when outside', () => {
const input = document.createElement('input')
const instance = new AddMarkerControlClass({ input })
instance.map = map
const latlng = { lat: 15, lng: 15 }
expect(instance.marker).toBe(null)
instance.updateMarker(latlng)
expect(instance.marker).toBe(null)
expect(input.value).toEqual('')
})

it('updates on drag', () => {
const input = document.createElement('input')
const instance = new AddMarkerControlClass({ input, point })
instance.map = map
expect(instance.oldCoords).toStrictEqual([5, 5])
const newCoords = { lat: 10, lng: 10 }

const e = { target: { getLatLng: () => newCoords, setLatLng: jest.fn() } }
instance.onDragend(e)
expect(instance.oldCoords).toStrictEqual(newCoords)

const e2 = { target: { getLatLng: () => ({ lat: 15, lng: 15 }), setLatLng: jest.fn() } }
instance.onDragend(e2)
expect(e2.target.setLatLng).toHaveBeenCalledWith(newCoords)
expect(instance.oldCoords).toStrictEqual(newCoords)
})
})
66 changes: 66 additions & 0 deletions adhocracy4/maps_react/static/a4maps_react/__tests__/Map.jest.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import Map from '../Map'

export const polygonData = {
type: 'Feature',
properties: {},
geometry: {
type: 'Polygon',
coordinates: [
[
[0, 0],
[10, 0],
[10, 10],
[0, 10]
]
]
}
}

jest.mock('react-leaflet', () => {
const ActualReactLeaflet = jest.requireActual('react-leaflet')
const React = require('react')

const MapContainer = React.forwardRef((props, ref) => (
<div data-testid="map">
<ActualReactLeaflet.MapContainer ref={ref} {...props} />
</div>
))
MapContainer.displayName = 'MapContainer'

const GeoJSON = React.forwardRef((props, ref) => (
<div data-testid="geojson"><ActualReactLeaflet.GeoJSON ref={ref} props={props} /></div>
))
GeoJSON.displayName = 'GeoJSON'

return {
__esModule: true,
...ActualReactLeaflet,
GeoJSON,
MapContainer
}
})

describe('Map component tests', () => {
test('component renders', () => {
render(<Map />)
const mapNode = screen.getByTestId('map')

expect(mapNode).toBeTruthy()
})

test('renders map with GeoJSON when polygon prop is provided', () => {
render(<Map polygon={polygonData} />)
const geoJsonNode = screen.getByTestId('geojson')

expect(geoJsonNode).toBeTruthy()
})

test('does not render GeoJSON when polygon prop is not provided', () => {
render(<Map />)
const geoJsonNode = screen.queryByTestId('geojson')

expect(geoJsonNode).toBeFalsy()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{% load react_maps_tags %}

{% react_choose_point polygon point name %}
<input id="id_{{ name }}" type="hidden" name="{{ name }}" {% if point %}value="{{ point }}"{% endif %}>
Empty file.
Loading