Map components - Leaflet and OpenLayers (#968)

* [components,maps][l]: implements Leaflet map component -- #963

* [openspending, maps][m]: fix build for leaflet map

* Feature/openlayers map (#967)

* [maps][xl] - working with swc equals to minify

* [maps][xs] - fixing height

* [openspending][xs] - testing

* [openspending][xs] - testing

* [openspending][xs] - change order drd

* [openspending][xs] - add map

* [maps,tests][xs]: add default export to OpenLayers component

* [@portaljs/components][m] - map components

---------

Co-authored-by: João Demenech <joaommdemenech@gmail.com>
This commit is contained in:
Luccas Mateus
2023-07-07 11:22:34 -03:00
committed by GitHub
parent cb0b9b1f14
commit 0fd3ee9912
31 changed files with 7380 additions and 240 deletions

View File

@@ -0,0 +1,139 @@
import { useEffect, useState } from 'react';
import LoadingSpinner from './LoadingSpinner';
import loadData from '../lib/loadData';
import chroma from 'chroma-js';
import {
MapContainer,
TileLayer,
GeoJSON as GeoJSONLayer,
} from 'react-leaflet';
import * as L from 'leaflet';
export type MapProps = {
data: string | GeoJSON.GeoJSON;
title?: string;
colorScale?: {
starting: string;
ending: string;
};
center?: { latitude: number | undefined; longitude: number | undefined };
zoom?: number;
tooltip?: {
prop: string;
};
};
export function Map({
data,
title = '',
colorScale = { starting: 'blue', ending: 'red' },
center = { latitude: 45, longitude: 45 },
zoom = 2,
tooltip = {
prop: '',
},
}: MapProps) {
const [isLoading, setIsLoading] = useState<boolean>(false);
// By default, assumes data is an Array...
const [geoJsonData, setGeoJsonData] = useState<any>(null);
useEffect(() => {
// If data is string, assume it's a URL
if (typeof data === 'string') {
setIsLoading(true);
loadData(data).then((res: any) => {
const geoJsonObject = JSON.parse(res);
const colorScaleAr = chroma
.scale([colorScale.starting, colorScale.ending])
.mode('lch')
.colors(geoJsonObject.features.length);
geoJsonObject.features.forEach((feature, i) => {
if (feature.color === undefined) {
feature.color = colorScaleAr[i];
}
});
setGeoJsonData(geoJsonObject);
setIsLoading(false);
});
} else {
setGeoJsonData(data);
}
}, []);
const onEachFeature = (feature, layer) => {
const geometryType = feature.type;
if (tooltip.prop)
layer.bindTooltip(feature.properties[tooltip.prop], {
direction: 'center',
});
layer.on({
mouseover: (event) => {
if (['Polygon', 'MultiPolygon'].includes(geometryType)) {
event.target.setStyle({
fillColor: '#B85042',
});
}
},
mouseout: (event) => {
if (['Polygon', 'MultiPolygon'].includes(geometryType)) {
event.target.setStyle({
fillColor: '#A7BEAE',
});
}
},
});
};
return isLoading || !geoJsonData ? (
<div className="w-full flex items-center justify-center w-[600px] h-[300px]">
<LoadingSpinner />
</div>
) : (
<MapContainer
center={[center.latitude, center.longitude]}
zoom={zoom}
scrollWheelZoom={false}
className="h-80 w-full"
// @ts-ignore
whenReady={(map: any) => {
map.target.scrollWheelZoom.enable();
var info = new L.Control() as any;
info.onAdd = function () {
this._div = L.DomUtil.create('div', 'info');
this.update();
return this._div;
};
info.update = function () {
this._div.innerHTML = `<h4 style="font-weight: 600; background: #f9f9f9; padding: 5px; border-radius: 5px; color: #464646;">${title}</h4>`;
};
if (title) info.addTo(map.target);
setTimeout(() => map.target.invalidateSize(), 5000);
}}
>
<GeoJSONLayer
data={geoJsonData}
style={(geoJsonFeature: any) => {
return { color: geoJsonFeature?.color };
}}
onEachFeature={onEachFeature}
/>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
</MapContainer>
);
}

View File

@@ -0,0 +1,84 @@
import React, { useContext, useEffect, useState } from 'react';
export const Controls = ({ children }) => {
return <div>{children}</div>;
};
import { FullScreen, Zoom } from 'ol/control';
import { MapContext } from './Map';
export const FullScreenControl = () => {
const { map } = useContext(MapContext);
useEffect(() => {
if (!map) return;
let fullScreenControl = new FullScreen({
className: 'ml-1 flex flex-col w-8 items-center mt-2',
activeClassName:
'w-full inline-flex justify-center items-center rounded-t-md bg-white px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10 text-sm',
inactiveClassName:
'inline-flex w-full justify-center items-center rounded-t-md bg-white px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10 text-sm',
});
let zoomControl = new Zoom({
className: 'ml-1 flex flex-col w-8 items-center',
zoomInClassName:
'inline-flex w-full justify-center items-center bg-white px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10 text-sm',
zoomOutClassName:
'inline-flex w-full justify-center items-center rounded-b-md bg-white px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10 text-sm',
});
map.controls.push(fullScreenControl);
map.controls.push(zoomControl);
return () => {
map.controls.remove(zoomControl);
map.controls.remove(fullScreenControl);
};
}, [map]);
return null;
};
//build a list of checkboxes in react
export const ListOfCheckboxes = ({ layers, shownLayers, setShownLayers }) => {
//layers is an array of url and name
function addLayer(layer) {
setShownLayers([...shownLayers, layer.url]);
}
function removeLayer(layer) {
setShownLayers(shownLayers.filter((l) => l !== layer.url));
}
return (
<div>
<h3 className="mb-4 font-semibold text-gray-900 ">Layers</h3>
<ul className="w-48 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg ">
{layers.map((layer, index) => (
<li
key={index}
className="w-full border-b border-gray-200 rounded-t-lg "
>
<div className="flex items-center pl-3">
<input
id={layer.name}
type="checkbox"
defaultChecked={shownLayers.includes(layer.url)}
onClick={() =>
shownLayers.includes(layer.url)
? removeLayer(layer)
: addLayer(layer)
}
value={true}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2 "
></input>
<label
htmlFor={layer.name}
className="w-full py-3 ml-2 text-sm font-medium text-gray-90"
>
{layer.name}
</label>
</div>
</li>
))}
</ul>
</div>
);
};

View File

@@ -0,0 +1,29 @@
import { useContext, useEffect, useState } from 'react';
import HeatMap from 'ol/layer/Heatmap';
import { MapContext } from './Map';
const HeatMapLayer = ({ source, style, zIndex = 0 }) => {
const { map } = useContext(MapContext);
const [heatMapLayer, setHeatMapLayer] = useState(null);
useEffect(() => {
if (!map) return;
let heatMapLayer = new HeatMap({
source,
style,
blur: parseInt(5, 10),
radius: parseInt(5, 10),
});
map.addLayer(heatMapLayer);
setHeatMapLayer(heatMapLayer);
heatMapLayer.setZIndex(zIndex);
return () => {
if (map) {
map.removeLayer(heatMapLayer);
}
};
}, [map]);
useEffect(() => {
heatMapLayer && heatMapLayer.setZIndex(zIndex);
}, [zIndex]);
return null;
};
export default HeatMapLayer;

View File

@@ -0,0 +1,4 @@
import React from 'react';
export const Layers = ({ children }) => {
return <div>{children}</div>;
};

View File

@@ -0,0 +1,50 @@
import React, { useRef, useState, useEffect } from 'react';
import * as ol from 'ol';
export const MapContext = new React.createContext();
const Map = ({ children, zoom, center, setSelected }) => {
const mapRef = useRef();
const [map, setMap] = useState(null);
// on component mount
useEffect(() => {
let options = {
view: new ol.View({ zoom, center }),
layers: [],
controls: [],
overlays: [],
};
let mapObject = new ol.Map(options);
mapObject.setTarget(mapRef.current);
setMap(mapObject);
return () => mapObject.setTarget(undefined);
}, []);
useEffect(() => {
if (map) {
if (setSelected !== null) {
let selected = null;
map.on('pointermove', function (e) {
map.forEachFeatureAtPixel(e.pixel, function (f) {
selected = f;
return true;
});
if (selected) {
setSelected(selected);
} else {
setSelected(null);
}
});
}
}
}, [map]);
return (
<MapContext.Provider value={{ map }}>
<div ref={mapRef} className="w-full" style={{height: '500px'}}>
{children}
</div>
</MapContext.Provider>
);
};
export default Map;

View File

@@ -0,0 +1,136 @@
import { useEffect, useState } from 'react';
import Map from './Map';
import { Layers } from './Layers';
import { Fill, Icon, Style } from 'ol/style';
import * as olSource from 'ol/source';
import TileLayer from './TileLayer';
import { fromLonLat } from 'ol/proj';
import VectorLayer from './VectorLayer';
import { Vector as VectorSource } from 'ol/source';
import GeoJSON from 'ol/format/GeoJSON';
import KML from 'ol/format/KML';
import { colors } from './colors';
import { FullScreenControl, Controls, ListOfCheckboxes } from './Controls';
import HeatMapLayer from './HeatMapLayer';
function osm() {
return new olSource.OSM();
}
const formats = {
geojson: new GeoJSON(),
kml: new KML(),
};
interface OpenLayersProps {
layers: {
url: string;
name?: string;
format?: string;
heatmap?: boolean;
}[];
center?: [number, number];
zoom?: number;
popup?: (selected: any) => JSX.Element;
}
export default function OpenLayers({
layers,
center = [0, 0],
zoom = 1,
popup,
}: OpenLayersProps) {
const [shownLayers, setShownLayers] = useState(
layers.map((layer) => layer.url)
);
const [selected, setSelected] = useState(null);
const [style, setStyle] = useState(null);
useEffect(() => {
const style = new Style({
fill: new Fill({
color: '#eeeeee',
}),
image: new Icon({
anchor: [0.5, 46],
anchorXUnits: 'fraction',
anchorYUnits: 'pixels',
width: 18,
height: 28,
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/aa/Google_Maps_icon_%282020%29.svg/418px-Google_Maps_icon_%282020%29.svg.png?20200218211225',
}),
});
setStyle(style);
}, []);
return (
<div className="relative">
<Map
center={fromLonLat(center)}
zoom={zoom}
setSelected={popup ? setSelected : null}
>
<Layers>
<TileLayer source={osm()} zIndex={0} />
{layers.map((layer, index) =>
!layer.heatmap ? (
<VectorLayer
key={index}
zIndex={shownLayers.includes(layer.url) ? 1 : -1}
source={
new VectorSource({
url: layer.url,
format: layer.format
? formats[layer.format]
: new GeoJSON(),
})
}
style={function (feature) {
const id = feature.getId();
const color = feature.get('COLOR') || colors[id % 1302].hex;
style.getFill().setColor(color);
return style;
}}
/>
) : (
<HeatMapLayer
key={index}
zIndex={shownLayers.includes(layer.url) ? 1 : -1}
source={
new VectorSource({
url: layer.url,
format: layer.format
? formats[layer.format]
: new GeoJSON(),
})
}
style={function (feature) {
const color =
feature.get('COLOR') || colors[feature.ol_uid % 1302].hex;
style.getFill().setColor(color);
return style;
}}
/>
)
)}
</Layers>
{/* add a floating pane that will output the ListOfCheckboxes component using tailwind*/}
<div className="absolute bottom-0 right-0 m-4 p-4 z-50 bg-white rounded-lg shadow-xl">
<ListOfCheckboxes
layers={layers}
shownLayers={shownLayers}
setShownLayers={setShownLayers}
/>
</div>
{popup && selected && (
<div className="absolute bottom-0 left-0 m-4 p-4 z-50 bg-white rounded-lg shadow-xl">
{popup(selected)}
</div>
)}
<Controls>
<FullScreenControl />
</Controls>
</Map>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { useContext, useEffect } from 'react';
import OLTileLayer from 'ol/layer/Tile';
import { MapContext } from './Map';
const TileLayer = ({ source, zIndex = 0 }) => {
const { map } = useContext(MapContext);
useEffect(() => {
if (!map) return;
let tileLayer = new OLTileLayer({
source,
zIndex,
});
map.addLayer(tileLayer);
tileLayer.setZIndex(zIndex);
return () => {
if (map) {
map.removeLayer(tileLayer);
}
};
}, [map]);
return null;
};
export default TileLayer;

View File

@@ -0,0 +1,33 @@
import { useContext, useEffect, useState } from 'react';
import OLVectorLayer from 'ol/layer/Vector';
import { MapContext } from './Map';
const VectorLayer = ({ source, style, zIndex = 0 }) => {
const { map } = useContext(MapContext);
const [vectorLayer, setVectorLayer] = useState(null);
useEffect(() => {
if (!map) return;
let vectorLayer = new OLVectorLayer({
source,
style,
});
const vectorSource = vectorLayer.getSource();
vectorSource.on('featuresloadend', function () {
vectorSource.getFeatures().forEach((feature, index) => {
feature.setId(index);
});
});
map.addLayer(vectorLayer);
setVectorLayer(vectorLayer);
vectorLayer.setZIndex(zIndex);
return () => {
if (map) {
map.removeLayer(vectorLayer);
}
};
}, [map]);
useEffect(() => {
vectorLayer && vectorLayer.setZIndex(zIndex);
}, [zIndex]);
return null;
};
export default VectorLayer;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
/* Temporary fix for a size issue with FlatUiTable loading indicator on Firefox */
@layer base {
svg[tw^='animate-pulse w-12'] {
max-width: 100px;
}
}

View File

@@ -1,10 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Temporary fix for a size issue with FlatUiTable loading indicator on Firefox */
@layer base {
svg[tw^='animate-pulse w-12'] {
max-width: 100px;
}
}
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "leaflet";
@import "include";
@import "tailwindcss/utilities";

View File

@@ -4,3 +4,5 @@ export * from "./components/LineChart";
export * from "./components/Vega";
export * from "./components/VegaLite";
export * from "./components/FlatUiTable";
export * from './components/OpenLayers/OpenLayers';
export * from "./components/Map";

View File

@@ -0,0 +1,171 @@
/**
* Typescript types for the GeoJSON RFC7946 specification. This is not fully RFC-compliant due to lack of support for
* ranged number data types.
*
* See https://tools.ietf.org/html/rfc7946
*/
export declare namespace GeoJSON {
/**
* Inside this document, the term "geometry type" refers to seven case-sensitive strings: "Point", "MultiPoint",
* "LineString", "MultiLineString", "Polygon", "MultiPolygon", and "GeometryCollection".
*/
export type Geometry = Point | MultiPoint | LineString | MultiLineString | Polygon | MultiPolygon
| GeometryCollection;
export type GeometryType = Geometry["type"];
/**
* ...the term "GeoJSON types" refers to nine case-sensitive strings: "Feature", "FeatureCollection", and the
* geometry types listed above.
*/
export type GeoJson = Geometry | Feature | FeatureCollection;
export type GeoJsonType = GeoJson["type"];
// types
/**
* A position is an array of numbers. There MUST be two or more elements. The first two elements are longitude and
* latitude, or easting and northing, precisely in that order and using decimal numbers. Altitude or elevation MAY
* be included as an optional third element.
*
* Implementations SHOULD NOT extend positions beyond three elements because the semantics of extra elements are
* unspecified and ambiguous.
*/
export type Position = [longitude: number, latitude: number, elevation?: number]
export type Record = { [key in string | number]: unknown };
/**
* Properties inherit to all GeoJSON types
*/
export interface GeometryBase extends Record {
/**
* A GeoJSON object MAY have a member named "bbox" to include information on the coordinate range for its
* Geometries, Features, or FeatureCollections. The value of the bbox member MUST be an array of length 2*n
* where n is the number of dimensions represented in the contained geometries, with all axes of the most
* southwesterly point followed by all axes of the more northeasterly point. The axes order of a bbox follows
* the axes order of geometries.
*/
bbox?: number[];
/**
* A GeoJSON object MAY have other members.
*
* Members not described in this specification ("foreign members") MAY be used in a GeoJSON document. Note that
* support for foreign members can vary across implementations, and no normative processing model for foreign
* members is defined.
*/
}
// geometry types
export interface Point extends GeometryBase {
type: "Point";
/**
* For type "Point", the "coordinates" member is a single position.
*/
coordinates: Position;
}
export interface MultiPoint extends GeometryBase {
type: "MultiPoint";
/**
* For type "MultiPoint", the "coordinates" member is an array of positions.
*/
coordinates: Position[];
}
export interface LineString extends GeometryBase {
type: "LineString";
/**
* For type "LineString", the "coordinates" member is an array of two or more positions.
*/
coordinates: { 0: Position, 1: Position } & Position[]
}
export interface MultiLineString extends GeometryBase {
type: "MultiLineString";
/**
* For type "MultiLineString", the "coordinates" member is an array of LineString coordinate arrays.
*/
coordinates: LineString["coordinates"][];
}
/**
* To specify a constraint specific to Polygons, it is useful to introduce the concept of a linear ring:
* - A linear ring is a closed LineString with four or more positions.
* - The first and last positions are equivalent, and they MUST contain identical values; their representation
* SHOULD also be identical.
* - A linear ring is the boundary of a surface or the boundary of a hole in a surface.
* - A linear ring MUST follow the right-hand rule with respect to the area it bounds, i.e., exterior rings are
* counterclockwise, and holes are clockwise.
*/
export type LinearRing = { 0: Position, 1: Position, 2: Position, 3: Position } & Position[];
export interface Polygon extends GeometryBase {
type: "Polygon";
/**
* For type "Polygon", the "coordinates" member MUST be an array of linear ring coordinate arrays.
*
* For Polygons with more than one of these rings, the first MUST be the exterior ring, and any others MUST be
* interior rings. The exterior ring bounds the surface, and the interior rings (if present) bound holes within
* the surface.
*/
coordinates: LinearRing[];
}
export interface MultiPolygon extends GeometryBase {
type: "MultiPolygon";
/**
* For type "MultiPolygon", the "coordinates" member is an array of Polygon coordinate arrays.
*/
coordinates: Polygon["coordinates"][];
}
export interface GeometryCollection {
/**
* A GeoJSON object with type "GeometryCollection" is a Geometry object.
*/
type: "GeometryCollection";
/**
* A GeometryCollection has a member with the name "geometries". The value of "geometries" is an array. Each
* element of this array is a GeoJSON Geometry object. It is possible for this array to be empty.
*/
geometries: Geometry[];
}
// GeoJSON types
export interface Feature {
/**
* A Feature object has a "type" member with the value "Feature".
*/
type: "Feature";
/**
* If a Feature has a commonly used identifier, that identifier SHOULD be included as a member of the Feature object
* with the name "id", and the value of this member is either a JSON string or number.
*/
id?: string | number;
/**
* A Feature object has a member with the name "geometry". The value of the geometry member SHALL be either a
* Geometry object as defined above or, in the case that the Feature is unlocated, a JSON null value.
*/
geometry: Geometry | null;
/**
* A Feature object has a member with the name "properties". The value of the properties member is an object
* (any JSON object or a JSON null value).
*/
properties: Record | null;
}
export interface FeatureCollection {
/**
* A GeoJSON object with the type "FeatureCollection" is a FeatureCollection object.
*/
type: "FeatureCollection";
/**
* A FeatureCollection object has a member with the name "features". The value of "features" is a JSON array. Each
* element of the array is a Feature object as defined above. It is possible for this array to be empty.
*/
features: Feature[];
}
}