-
Notifications
You must be signed in to change notification settings - Fork 17
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
[#7704] React maps #1513
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
95cb228
react_maps: add app and all necessary react components
vellip bd18f4f
react_maps: write js tests for react leaflet map
vellip c127c90
react_maps: write python tests for react leaflet map
vellip 3f8ddd8
maps_react: add missing attribution to MaplibreGlLayer
goapunk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
85
adhocracy4/maps_react/static/a4maps_react/AddMarkerControl.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
36
adhocracy4/maps_react/static/a4maps_react/GeoJsonMarker.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
38
adhocracy4/maps_react/static/a4maps_react/MaplibreGlLayer.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
goapunk marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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
19
adhocracy4/maps_react/static/a4maps_react/MarkerClusterLayer.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
60 changes: 60 additions & 0 deletions
60
adhocracy4/maps_react/static/a4maps_react/__tests__/AddMarkerControl.jest.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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] | ||
vellip marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
}) | ||
|
||
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
66
adhocracy4/maps_react/static/a4maps_react/__tests__/Map.jest.jsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
}) | ||
}) |
4 changes: 4 additions & 0 deletions
4
adhocracy4/maps_react/templates/a4maps_react/map_choose_point_widget.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.