Skip to content

Commit

Permalink
react_maps: add app and all necessary react components
Browse files Browse the repository at this point in the history
  • Loading branch information
vellip committed Dec 18, 2023
1 parent 41706a3 commit fb70021
Show file tree
Hide file tree
Showing 22 changed files with 740 additions and 0 deletions.
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 }

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 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'
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>
)
})
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>
)
}
37 changes: 37 additions & 0 deletions adhocracy4/maps_react/static/a4maps_react/MaplibreGlLayer.js
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 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
48 changes: 48 additions & 0 deletions adhocracy4/maps_react/static/a4maps_react/ZoomControl.js
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
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.
11 changes: 11 additions & 0 deletions adhocracy4/maps_react/templatetags/react_maps_tags.py
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",
)
73 changes: 73 additions & 0 deletions adhocracy4/maps_react/utils.py
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
Loading

0 comments on commit fb70021

Please sign in to comment.