-
Notifications
You must be signed in to change notification settings - Fork 17
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
react_maps: add app and all necessary react components
- Loading branch information
Showing
22 changed files
with
740 additions
and
0 deletions.
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 } | ||
|
||
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.constraints) | ||
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.constraints) | ||
if (!isInsideConstraints) { | ||
e.target.setLatLng(this.oldCoords) | ||
} else { | ||
this.oldCoords = 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' | ||
import ZoomControl from './ZoomControl' | ||
|
||
const polygonStyle = { | ||
color: '#0076ae', | ||
weight: 2, | ||
opacity: 1, | ||
fillOpacity: 0.2 | ||
} | ||
|
||
export 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.options.minZoom = map.current.getZoom() | ||
map.current.constraints = polygon | ||
} | ||
|
||
return ( | ||
<MapContainer | ||
style={{ minHeight: 300 }} | ||
zoom={13} | ||
maxZoom={18} | ||
zoomControl={false} | ||
{...rest} | ||
ref={map} | ||
> | ||
{polygon && <GeoJSON style={polygonStyle} data={polygon} ref={refCallback} />} | ||
<MaplibreGlLayer attribution={attribution} baseUrl={baseUrl} omtToken={omtToken} /> | ||
<ZoomControl position="topleft" /> | ||
{children} | ||
</MapContainer> | ||
) | ||
}) |
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> | ||
) | ||
} |
37 changes: 37 additions & 0 deletions
37
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,37 @@ | ||
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({ | ||
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
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 |
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,48 @@ | ||
import { createControlComponent } from '@react-leaflet/core' | ||
import L from 'leaflet' | ||
|
||
// Create a Leaflet Control | ||
const createLeafletElement = (props) => { | ||
const zoomControl = L.control.zoom(props) | ||
|
||
const updateDisabled = () => { | ||
const map = zoomControl._map | ||
if (!map) { | ||
return | ||
} | ||
|
||
const className = 'leaflet-disabled' | ||
const zoomInBtn = zoomControl._zoomInButton | ||
const zoomOutBtn = zoomControl._zoomOutButton | ||
|
||
// disable button when at min/max zoom | ||
if (map._zoom === map.getMinZoom()) { | ||
L.DomUtil.addClass(zoomOutBtn, className) | ||
} else { | ||
L.DomUtil.removeClass(zoomOutBtn, className) | ||
} | ||
|
||
if (map._zoom === map.getMaxZoom() || (map._zoomSnap && Math.abs(map.getZoom() - map.getMaxZoom()) < map._zoomSnap)) { | ||
L.DomUtil.addClass(zoomInBtn, className) | ||
} else { | ||
L.DomUtil.removeClass(zoomInBtn, className) | ||
} | ||
} | ||
|
||
zoomControl.onAdd = (map) => { | ||
const container = L.Control.Zoom.prototype.onAdd.call(zoomControl, map) | ||
map.on('zoom', updateDisabled) | ||
updateDisabled() | ||
return container | ||
} | ||
|
||
zoomControl.onRemove = (map) => { | ||
map.off('zoom', updateDisabled) | ||
L.Control.Zoom.prototype.onRemove.call(zoomControl, map) | ||
} | ||
|
||
return zoomControl | ||
} | ||
|
||
const ZoomControl = createControlComponent(createLeafletElement) | ||
export default ZoomControl |
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=polygon point=point name=name %} | ||
<input id="id_{{ name }}" type="hidden" name="{{ name }}" {% if point %}value="{{ point }}"{% endif %}> |
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,11 @@ | ||
from django import template | ||
|
||
from adhocracy4.maps_react.utils import react_tag_factory | ||
|
||
register = template.Library() | ||
|
||
register.simple_tag( | ||
react_tag_factory("choose-point"), | ||
False, | ||
"react_choose_point", | ||
) |
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,73 @@ | ||
import json | ||
|
||
from django.conf import settings | ||
from django.urls import reverse | ||
from django.utils.html import format_html | ||
|
||
|
||
def get_map_settings(**kwargs): | ||
map_settings = { | ||
"mapboxToken": "", | ||
"omtToken": "", | ||
"attribution": "", | ||
"useVectorMap": 0, | ||
"baseUrl": settings.A4_MAP_BASEURL, | ||
**kwargs, | ||
} | ||
|
||
if hasattr(settings, "A4_MAP_ATTRIBUTION"): | ||
map_settings["attribution"] = settings.A4_MAP_ATTRIBUTION | ||
|
||
if hasattr(settings, "A4_USE_VECTORMAP") and settings.A4_USE_VECTORMAP: | ||
map_settings["useVectorMap"] = 1 | ||
|
||
if hasattr(settings, "A4_MAPBOX_TOKEN"): | ||
map_settings["mapboxToken"] = settings.A4_MAPBOX_TOKEN | ||
|
||
if hasattr(settings, "A4_OPENMAPTILES_TOKEN"): | ||
map_settings["omtToken"] = settings.A4_OPENMAPTILES_TOKEN | ||
|
||
# Filter out the keys that have a value of "" | ||
return {key: val for key, val in map_settings.items() if val != ""} | ||
|
||
|
||
def react_tag_factory(tag_name, api_url_name=None): | ||
""" | ||
:param tag_name: The name of the template tag. | ||
:param api_url_name: The name of the API URL (optional). | ||
:return: A formatted HTML string containing the React tag with required props. | ||
This method creates a function that generates a React tag with the given name and | ||
attributes. It takes the following parameters: | ||
If the `api_url_name` parameter is provided, the `module` parameter must also be | ||
provided. Otherwise, a `ValueError` is raised. | ||
The function generated by this method takes a variable number of keyword arguments, | ||
which are used to populate the attributes of the React tag. If the `module` | ||
parameter is provided and the keyword argument "polygon" is not included, the | ||
function automatically adds the "polygon" attribute using the `polygon` setting | ||
from the `module` object. | ||
""" | ||
|
||
def func(**kwargs): | ||
module = kwargs.pop("module", None) | ||
if module and "polygon" not in kwargs: | ||
kwargs["polygon"] = module.settings_instance.polygon | ||
|
||
attributes = {"map": get_map_settings(**kwargs)} | ||
if api_url_name: | ||
if not module: | ||
raise ValueError("Module must be provided if api_url_name is provided") | ||
attributes["apiUrl"] = reverse( | ||
api_url_name, kwargs={"module_pk": module.pk} | ||
) | ||
|
||
return format_html( | ||
f'<div data-mb-widget="{tag_name}" data-attributes="{{attributes}}"></div>', | ||
attributes=json.dumps(attributes), | ||
) | ||
|
||
# set the correct name on the function | ||
func.__name__ = tag_name | ||
return func |
Oops, something went wrong.