diff --git a/client/package.json b/client/package.json
index d42722c..5a6ba5b 100644
--- a/client/package.json
+++ b/client/package.json
@@ -23,6 +23,7 @@
"@dnd-kit/core": "6.1.0",
"@dnd-kit/modifiers": "7.0.0",
"@dnd-kit/sortable": "8.0.0",
+ "@loaders.gl/gis": "4.3.2",
"@radix-ui/react-checkbox": "1.1.2",
"@radix-ui/react-collapsible": "1.1.1",
"@radix-ui/react-dialog": "1.1.2",
@@ -39,6 +40,8 @@
"@t3-oss/env-nextjs": "0.11.1",
"@tanstack/react-query": "5.59.16",
"@turf/bbox": "7.1.0",
+ "@turf/helpers": "7.1.0",
+ "@turf/meta": "7.1.0",
"@types/mapbox-gl": "3.4.0",
"apng-js": "1.1.4",
"axios": "1.7.7",
diff --git a/client/src/components/map/deckgl-mapbox-provider.tsx b/client/src/components/map/deckgl-mapbox-provider.tsx
index 181a522..0107d2f 100644
--- a/client/src/components/map/deckgl-mapbox-provider.tsx
+++ b/client/src/components/map/deckgl-mapbox-provider.tsx
@@ -28,16 +28,24 @@ const DeckglMapboxProvider = ({ children }: PropsWithChildren) => {
const addLayer = useCallback(
(layer: Layer) => {
- layersRef.current = [...layersRef.current, layer];
- deckGLMapboxOverlay.setProps({ layers: layersRef.current });
+ // Artificially delay adding the layer to give a chance to React Map Gl to render the
+ // positioning layers first
+ setTimeout(() => {
+ layersRef.current = [...layersRef.current, layer];
+ deckGLMapboxOverlay.setProps({ layers: layersRef.current });
+ }, 0);
},
[deckGLMapboxOverlay],
);
const removeLayer = useCallback(
(layerId: string) => {
- layersRef.current = layersRef.current.filter(({ id }) => id !== layerId);
- deckGLMapboxOverlay.setProps({ layers: layersRef.current });
+ // Artificially delay removing the layer to match the `addLayer` function
+ // Without this, Deck.gl would throw an assertion error
+ setTimeout(() => {
+ layersRef.current = layersRef.current.filter(({ id }) => id !== layerId);
+ deckGLMapboxOverlay.setProps({ layers: layersRef.current });
+ }, 0);
},
[deckGLMapboxOverlay],
);
diff --git a/client/src/components/map/index.tsx b/client/src/components/map/index.tsx
index 068bd22..58361b8 100644
--- a/client/src/components/map/index.tsx
+++ b/client/src/components/map/index.tsx
@@ -59,13 +59,18 @@ const Map = () => {
// The inner map is memoized so that it doesn't rerender when the map is panned due to the bounds
// changing
const innerMap = useMemo(() => {
+ // We make sure to render the layers when the map is ready
+ if (!map) {
+ return null;
+ }
+
return (
);
- }, []);
+ }, [map]);
const onMove = useCallback(() => {
setBounds(map?.getBounds()?.toArray() as [LngLatLike, LngLatLike]);
diff --git a/client/src/components/map/layer-manager/animated-layer.tsx b/client/src/components/map/layer-manager/animated-layer.tsx
index 982ffd3..84db408 100644
--- a/client/src/components/map/layer-manager/animated-layer.tsx
+++ b/client/src/components/map/layer-manager/animated-layer.tsx
@@ -1,6 +1,6 @@
+import { MaskExtension } from "@deck.gl/extensions";
import { TileLayer } from "@deck.gl/geo-layers";
import { BitmapLayer } from "@deck.gl/layers";
-import { GL } from "@luma.gl/constants";
import parseAPNG from "apng-js";
import { getMonth } from "date-fns";
import { useContext, useEffect } from "react";
@@ -79,12 +79,13 @@ const AnimatedLayer = ({ config, date, beforeId }: AnimatedLayerProps) => {
visible: subLayer.visible,
opacity: subLayer.opacity,
textureParameters: {
- [GL.TEXTURE_MIN_FILTER]: GL.NEAREST,
- [GL.TEXTURE_MAG_FILTER]: GL.NEAREST,
- [GL.TEXTURE_WRAP_S]: GL.CLAMP_TO_EDGE,
- [GL.TEXTURE_WRAP_T]: GL.CLAMP_TO_EDGE,
+ minFilter: "nearest",
+ magFilter: "nearest",
+ mipmapFilter: undefined,
},
image: subLayer.data[frameIndex].bitmapData,
+ extensions: [new MaskExtension()],
+ maskId: "mask",
});
},
});
diff --git a/client/src/components/map/layer-manager/index.tsx b/client/src/components/map/layer-manager/index.tsx
index 789ebf1..bc008f3 100644
--- a/client/src/components/map/layer-manager/index.tsx
+++ b/client/src/components/map/layer-manager/index.tsx
@@ -1,6 +1,7 @@
import { useMemo } from "react";
import { Layer } from "react-map-gl";
+import MaskLayer from "@/components/map/layer-manager/mask-layer";
import useMapLayers from "@/hooks/use-map-layers";
import LayerManagerItem from "./item";
@@ -13,28 +14,40 @@ const LayerManager = () => {
* See more: https://github.com/visgl/react-map-gl/issues/939#issuecomment-625290200
*/
const positioningLayers = useMemo(() => {
- return layers.map((layer, index) => {
- const beforeId = index === 0 ? "data-layers" : `layer-position-${layers[index - 1].id}`;
- return (
-
- );
- });
+ return [
+ ...layers.map((layer, index) => {
+ const beforeId = index === 0 ? "data-layers" : `layer-position-${layers[index - 1].id}`;
+ return (
+
+ );
+ }),
+ ,
+ ];
}, [layers]);
const layerManagerItems = useMemo(() => {
- return layers.map((layer, index) => {
- const beforeId = index === 0 ? "data-layers" : `layer-position-${layers[index - 1].id}`;
- const { id, ...settings } = layer;
- return (
-
- );
- });
+ return [
+ ...layers.map((layer) => {
+ const beforeId = `layer-position-${layer.id}`;
+ const { id, ...settings } = layer;
+ return (
+
+ );
+ }),
+ ,
+ ];
}, [layers]);
return (
diff --git a/client/src/components/map/layer-manager/item.tsx b/client/src/components/map/layer-manager/item.tsx
index 17c3483..ed28c54 100644
--- a/client/src/components/map/layer-manager/item.tsx
+++ b/client/src/components/map/layer-manager/item.tsx
@@ -2,7 +2,8 @@ import useLayerConfig from "@/hooks/use-layer-config";
import { LayerSettings } from "@/types/layer";
import AnimatedLayer from "./animated-layer";
-import StaticLayer from "./static-layer";
+import RasterLayer from "./raster-layer";
+import VectorLayer from "./vector-layer";
interface LayerManagerItemProps {
id: number;
@@ -27,7 +28,16 @@ const LayerManagerItem = ({ id, beforeId, settings }: LayerManagerItemProps) =>
return ;
}
- return ;
+ if (config.source.type === "raster") {
+ return ;
+ }
+
+ if (config.source.type === "vector") {
+ return ;
+ }
+
+ console.warn(`Unsupported layer type (${config.source.type})`);
+ return null;
};
export default LayerManagerItem;
diff --git a/client/src/components/map/layer-manager/mask-layer.tsx b/client/src/components/map/layer-manager/mask-layer.tsx
new file mode 100644
index 0000000..0a0ee9b
--- /dev/null
+++ b/client/src/components/map/layer-manager/mask-layer.tsx
@@ -0,0 +1,52 @@
+import { GeoJsonLayer } from "@deck.gl/layers";
+import { useContext, useEffect, useMemo } from "react";
+
+import useLocation from "@/hooks/use-location";
+import { useLocationGeometry } from "@/hooks/use-location-geometry";
+
+import { DeckGLMapboxOverlayContext } from "../deckgl-mapbox-provider";
+
+interface MaskLayerProps {
+ beforeId: string;
+}
+
+const MaskLayer = ({ beforeId }: MaskLayerProps) => {
+ const [location] = useLocation();
+ const { data, isLoading } = useLocationGeometry(location.code.slice(-1)[0]);
+ const geometry = useMemo(() => {
+ if (isLoading || data === undefined || data === null) {
+ // We return an empty feature collection so that while the geometry is loading, we don't show
+ // anything instead of the layers unmasked
+ return {
+ type: "FeatureCollection",
+ features: [],
+ };
+ }
+
+ return data;
+ }, [data, isLoading]);
+
+ const { addLayer, removeLayer } = useContext(DeckGLMapboxOverlayContext);
+
+ useEffect(() => {
+ const layer = new GeoJsonLayer({
+ id: "mask",
+ beforeId,
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ data: geometry,
+ stroked: false,
+ operation: "mask",
+ });
+
+ addLayer(layer);
+
+ return () => {
+ removeLayer("mask");
+ };
+ }, [addLayer, beforeId, geometry, removeLayer]);
+
+ return null;
+};
+
+export default MaskLayer;
diff --git a/client/src/components/map/layer-manager/raster-layer.tsx b/client/src/components/map/layer-manager/raster-layer.tsx
new file mode 100644
index 0000000..1665d36
--- /dev/null
+++ b/client/src/components/map/layer-manager/raster-layer.tsx
@@ -0,0 +1,69 @@
+import { MaskExtension } from "@deck.gl/extensions";
+import { TileLayer } from "@deck.gl/geo-layers";
+import { BitmapLayer } from "@deck.gl/layers";
+import { useContext, useEffect } from "react";
+import { RasterLayer as IRasterLayer, RasterSource as IRasterSource } from "react-map-gl";
+
+import { LayerConfig } from "@/types/layer";
+
+import { DeckGLMapboxOverlayContext } from "../deckgl-mapbox-provider";
+
+interface RasterLayerProps {
+ config: LayerConfig;
+ beforeId: string;
+}
+
+const RasterLayer = ({ config, beforeId }: RasterLayerProps) => {
+ const { addLayer, removeLayer } = useContext(DeckGLMapboxOverlayContext);
+
+ useEffect(() => {
+ const style = config.styles[0] as IRasterLayer;
+ const source = config.source as IRasterSource;
+
+ const layer = new TileLayer({
+ id: style.id,
+ beforeId,
+ data: source.tiles,
+ tileSize: source.tileSize,
+ minZoom: source.minzoom,
+ maxZoom: source.maxzoom,
+ visible: style.layout?.visibility !== "none",
+ opacity: style.paint?.["raster-opacity"] as number,
+ renderSubLayers: (subLayer) => {
+ if (!subLayer || !subLayer.data || !subLayer.tile) {
+ return null;
+ }
+
+ return new BitmapLayer({
+ id: subLayer.id,
+ bounds: [
+ subLayer.tile.boundingBox[0][0],
+ subLayer.tile.boundingBox[0][1],
+ subLayer.tile.boundingBox[1][0],
+ subLayer.tile.boundingBox[1][1],
+ ],
+ visible: subLayer.visible,
+ opacity: subLayer.opacity,
+ textureParameters: {
+ minFilter: "nearest",
+ magFilter: "nearest",
+ mipmapFilter: undefined,
+ },
+ image: subLayer.data,
+ extensions: [new MaskExtension()],
+ maskId: "mask",
+ });
+ },
+ });
+
+ addLayer(layer);
+
+ return () => {
+ removeLayer(config.styles[0].id);
+ };
+ }, [config, beforeId, addLayer, removeLayer]);
+
+ return null;
+};
+
+export default RasterLayer;
diff --git a/client/src/components/map/layer-manager/static-layer.tsx b/client/src/components/map/layer-manager/static-layer.tsx
deleted file mode 100644
index 5c3fb6e..0000000
--- a/client/src/components/map/layer-manager/static-layer.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { Layer, Source } from "react-map-gl";
-
-import { LayerConfig } from "@/types/layer";
-
-interface StaticLayerProps {
- config: LayerConfig;
- beforeId: string;
-}
-
-const StaticLayer = ({ config, beforeId }: StaticLayerProps) => {
- return (
-
- );
-};
-
-export default StaticLayer;
diff --git a/client/src/components/map/layer-manager/vector-layer.tsx b/client/src/components/map/layer-manager/vector-layer.tsx
new file mode 100644
index 0000000..8934f3a
--- /dev/null
+++ b/client/src/components/map/layer-manager/vector-layer.tsx
@@ -0,0 +1,97 @@
+import { MaskExtension } from "@deck.gl/extensions";
+import { MVTLayer, MVTLayerProps } from "@deck.gl/geo-layers";
+import { ScatterplotLayer } from "@deck.gl/layers";
+import { BinaryFeatureCollection } from "@loaders.gl/schema";
+import { useContext, useEffect } from "react";
+import { VectorSourceRaw as IVectorTileSource } from "react-map-gl";
+
+import { env } from "@/env";
+import useMapZoom from "@/hooks/use-map-zoom";
+import { LayerConfig } from "@/types/layer";
+import { convertBinaryToPointGeoJSON, resolveDeckglProperties } from "@/utils/mapbox-deckgl-bridge";
+
+import { DeckGLMapboxOverlayContext } from "../deckgl-mapbox-provider";
+
+interface VectorLayerProps {
+ config: LayerConfig;
+ beforeId: string;
+}
+
+const VectorLayer = ({ config, beforeId }: VectorLayerProps) => {
+ const { addLayer, removeLayer } = useContext(DeckGLMapboxOverlayContext);
+ const zoom = useMapZoom();
+
+ useEffect(() => {
+ const source = config.source as IVectorTileSource;
+ const styles = config.styles;
+
+ const layers: MVTLayer[] = [];
+
+ styles.map((style, index) => {
+ if (!source.url) {
+ return null;
+ }
+
+ const layerProps: MVTLayerProps & {
+ data: string;
+ beforeId: string;
+ maskId: string;
+ zoom: number;
+ } = {
+ // Adding the index just to make sure the ids were not copied and pasted in the layer
+ // definition
+ id: `${style.id}-${index}`,
+ beforeId,
+ data: `https://api.mapbox.com/v4/${source.url.split("//")[1]}/{z}/{x}/{y}.vector.pbf?access_token=${env.NEXT_PUBLIC_MAPBOX_TOKEN}`,
+ // It's important to pass `zoom` as a parameter so that the layer is immediately re-rendered
+ // when the zoom value is changed
+ zoom,
+ minZoom: source.minzoom,
+ maxZoom: source.maxzoom,
+ ...resolveDeckglProperties(style, zoom),
+ extensions: [new MaskExtension()],
+ maskId: "mask",
+ };
+
+ // Here's an edge case: when the vector layer contains polygons and lines, Mapbox allows
+ // anyway to render circles for each of the vertices. By default, Deck.gl will only render
+ // circles if the underlying feature is of type Point.
+ // The code below reproduces Mapbox' behaviour by forcing rendering circles for any type of
+ // geometry.
+ if (style.type === "circle") {
+ layerProps.renderSubLayers = ({ id, data, ...rest }) => {
+ if (data === null) {
+ return null;
+ }
+
+ return new ScatterplotLayer({
+ id: `${id}-circle`,
+ // The MVT data is encoded to binary. It is decoded below to simplify the code though
+ // it comes with a performance penalty.
+ data: convertBinaryToPointGeoJSON(data as BinaryFeatureCollection).features,
+ getPosition: (d) => d.geometry.coordinates,
+ getRadius: layerProps.getPointRadius,
+ radiusUnits: layerProps.pointRadiusUnits,
+ ...rest,
+ });
+ };
+ }
+
+ layers.push(new MVTLayer(layerProps));
+ });
+
+ layers.map((layer) => {
+ addLayer(layer);
+ });
+
+ return () => {
+ layers.map((layer) => {
+ removeLayer(layer.id);
+ });
+ };
+ }, [config, beforeId, addLayer, removeLayer, zoom]);
+
+ return null;
+};
+
+export default VectorLayer;
diff --git a/client/src/components/panels/location/index.tsx b/client/src/components/panels/location/index.tsx
index 059b0ab..3c1ecd5 100644
--- a/client/src/components/panels/location/index.tsx
+++ b/client/src/components/panels/location/index.tsx
@@ -30,10 +30,7 @@ const LocationPanel = ({ onExit }: LocationPanelProps) => {
);
const onClear = useCallback(() => {
- setLocation({
- type: "administrative",
- code: [],
- });
+ setLocation(null);
onExit();
}, [setLocation, onExit]);
diff --git a/client/src/hooks/use-apply-map-location.ts b/client/src/hooks/use-apply-map-location.ts
index 377a996..f07244d 100644
--- a/client/src/hooks/use-apply-map-location.ts
+++ b/client/src/hooks/use-apply-map-location.ts
@@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef } from "react";
import { MapRef } from "react-map-gl";
import useLocation from "@/hooks/use-location";
-import { useLocationByCode } from "@/hooks/use-location-by-code";
+import { useLocationGeometry } from "@/hooks/use-location-geometry";
import usePrevious from "@/hooks/use-previous";
export default function useApplyMapLocation(map: MapRef | null) {
@@ -13,20 +13,20 @@ export default function useApplyMapLocation(map: MapRef | null) {
// This flag indicates when to zoom the map on the location
const triggerFitBoundsRef = useRef(false);
- const { data, isLoading } = useLocationByCode(location.code.slice(-1)[0], ["geometry"]);
+ const { data, isLoading } = useLocationGeometry(location.code.slice(-1)[0]);
const bounds = useMemo(() => {
- if (isLoading || data?.geometry === undefined || data?.geometry === null) {
+ if (isLoading || data === undefined || data === null) {
return undefined;
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
- return bbox(data.geometry) as [number, number, number, number];
+ return bbox(data) as [number, number, number, number];
}, [data, isLoading]);
useEffect(() => {
- const hasChangedLocation = location !== previousLocation;
+ const hasChangedLocation = JSON.stringify(location) !== JSON.stringify(previousLocation);
if (hasChangedLocation) {
triggerFitBoundsRef.current = true;
}
diff --git a/client/src/hooks/use-location-by-code.ts b/client/src/hooks/use-location-by-code.ts
index 457bd31..e8a836f 100644
--- a/client/src/hooks/use-location-by-code.ts
+++ b/client/src/hooks/use-location-by-code.ts
@@ -1,14 +1,19 @@
import { useGetLocations } from "@/types/generated/location";
-import { Location } from "@/types/generated/strapi.schemas";
-export function useLocationByCode(code: string | undefined, fields = ["name", "code"]) {
+type LocationByCode = {
+ id: number;
+ name: string;
+ code: string;
+};
+
+export function useLocationByCode(code: string | undefined) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error
- const { data, isLoading } = useGetLocations(
+ const { data, isLoading } = useGetLocations(
{
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error
- fields,
+ fields: ["name", "code"],
filters: {
code: {
$eq: code,
@@ -28,13 +33,8 @@ export function useLocationByCode(code: string | undefined, fields = ["name", "c
return {
id: data.data[0].id,
- ...fields.reduce(
- (res, field) => ({
- ...res,
- [field]: data.data![0].attributes![field as keyof Location],
- }),
- {},
- ),
+ name: data.data[0].attributes!.name!,
+ code: data.data[0].attributes!.code!,
};
},
},
diff --git a/client/src/hooks/use-location-geometry.ts b/client/src/hooks/use-location-geometry.ts
new file mode 100644
index 0000000..e5309f5
--- /dev/null
+++ b/client/src/hooks/use-location-geometry.ts
@@ -0,0 +1,37 @@
+import { AllGeoJSON } from "@turf/helpers";
+
+import { useGetLocations } from "@/types/generated/location";
+
+export function useLocationGeometry(code: string | undefined) {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore-error
+ const { data, isLoading } = useGetLocations(
+ {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore-error
+ fields: ["geometry"],
+ filters: {
+ code: {
+ $eq: code,
+ },
+ },
+ sort: "name",
+ "pagination[limit]": 1,
+ },
+ {
+ query: {
+ enabled: code !== undefined,
+ placeholderData: { data: undefined },
+ select: (data) => {
+ if (!data?.data || data.data.length === 0) {
+ return undefined;
+ }
+
+ return data.data[0].attributes!.geometry;
+ },
+ },
+ },
+ );
+
+ return { data, isLoading };
+}
diff --git a/client/src/hooks/use-location.ts b/client/src/hooks/use-location.ts
index 35586a2..0b81d43 100644
--- a/client/src/hooks/use-location.ts
+++ b/client/src/hooks/use-location.ts
@@ -11,7 +11,7 @@ export default function useLocation() {
"location",
parseAsJson(schema.parse).withDefault({
type: "administrative",
- code: [],
+ code: ["SS"],
}),
);
}
diff --git a/client/src/hooks/use-map-zoom.ts b/client/src/hooks/use-map-zoom.ts
new file mode 100644
index 0000000..6caa125
--- /dev/null
+++ b/client/src/hooks/use-map-zoom.ts
@@ -0,0 +1,26 @@
+import { useEffect, useState } from "react";
+import { useMap } from "react-map-gl";
+
+export default function useMapZoom() {
+ const { current: map } = useMap();
+ const [zoom, setZoom] = useState(map?.getZoom() ?? 0);
+
+ useEffect(() => {
+ const onZoom = () => {
+ setZoom(map!.getZoom());
+ };
+
+ if (!map) {
+ return;
+ }
+
+ onZoom();
+ map.on("zoom", onZoom);
+
+ return () => {
+ map.off("zoom", onZoom);
+ };
+ }, [map]);
+
+ return zoom;
+}
diff --git a/client/src/types/layer.ts b/client/src/types/layer.ts
index 5747fed..54d3556 100644
--- a/client/src/types/layer.ts
+++ b/client/src/types/layer.ts
@@ -1,4 +1,11 @@
-import { AnyLayer, AnySource, SkyLayer } from "react-map-gl";
+import {
+ CircleLayerSpecification,
+ FillLayerSpecification,
+ LineLayerSpecification,
+ RasterLayerSpecification,
+ SymbolLayerSpecification,
+} from "mapbox-gl";
+import { AnySource } from "react-map-gl";
export interface LayerSettings {
visibility: boolean;
@@ -9,7 +16,13 @@ export interface LayerSettings {
export interface LayerConfig {
source: AnySource;
- styles: Exclude[];
+ styles: (
+ | FillLayerSpecification
+ | CircleLayerSpecification
+ | LineLayerSpecification
+ | SymbolLayerSpecification
+ | RasterLayerSpecification
+ )[];
}
export interface LayerParamsConfigValue {
diff --git a/client/src/utils/mapbox-deckgl-bridge.ts b/client/src/utils/mapbox-deckgl-bridge.ts
new file mode 100644
index 0000000..c72dd1c
--- /dev/null
+++ b/client/src/utils/mapbox-deckgl-bridge.ts
@@ -0,0 +1,282 @@
+import { MVTLayerProps } from "@deck.gl/geo-layers";
+import { binaryToGeojson } from "@loaders.gl/gis";
+import { BinaryFeatureCollection } from "@loaders.gl/schema";
+import { featureCollection, point } from "@turf/helpers";
+import { coordAll } from "@turf/meta";
+import { DataDrivenPropertyValueSpecification } from "mapbox-gl";
+import {
+ expression as mapboxExpression,
+ StylePropertySpecification,
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+} from "mapbox-gl/dist/style-spec/index.cjs";
+
+import { env } from "@/env";
+import { LayerConfig } from "@/types/layer";
+
+const resolveMapboxExpression = (
+ expression: unknown,
+ zoom: number,
+ geometry: Parameters<(typeof mapboxExpression)["StyleExpression"]["prototype"]["evaluate"]>[1],
+ expectedType?: StylePropertySpecification["type"],
+) => {
+ const { result, value } = mapboxExpression.createExpression(
+ expression,
+ expectedType
+ ? ({
+ type: expectedType,
+ } as StylePropertySpecification)
+ : undefined,
+ );
+
+ if (result === "success") {
+ const res = value.evaluate({ zoom }, geometry);
+
+ if (expectedType === "color") {
+ return [res.r * 255, res.g * 255, res.b * 255, res.a * 255];
+ }
+
+ return res;
+ }
+
+ console.warn(`Unsupported expression: ${expression}. Expression not resolved.`);
+
+ return null;
+};
+
+const resolveVisible = (style: LayerConfig["styles"][0]) => {
+ return style.layout?.visibility !== "none";
+};
+
+const resolveOpacity = (style: LayerConfig["styles"][0]) => {
+ if (style.type === "fill") {
+ return style.paint?.["fill-opacity"] as number;
+ }
+
+ if (style.type === "circle") {
+ return style.paint?.["circle-opacity"] as number;
+ }
+
+ if (style.type === "line") {
+ return style.paint?.["line-opacity"] as number;
+ }
+
+ if (style.type === "symbol") {
+ return style.paint?.["icon-opacity"] as number;
+ }
+
+ return 0;
+};
+
+const resolveFillColor = (
+ style: LayerConfig["styles"][0],
+ zoom: number,
+ defaultValue = [0, 0, 0],
+) => {
+ let value: DataDrivenPropertyValueSpecification | undefined;
+
+ if (style.type === "fill") {
+ value = style.paint?.["fill-color"];
+ } else if (style.type === "circle") {
+ value = style.paint?.["circle-color"];
+ } else {
+ return undefined;
+ }
+
+ if (value === undefined) {
+ return defaultValue;
+ }
+
+ return ((feature) => {
+ const resolvedValue = resolveMapboxExpression(value, zoom, feature, "color");
+
+ if (resolvedValue === null || resolvedValue === undefined) {
+ return defaultValue;
+ }
+
+ return resolvedValue;
+ }) as MVTLayerProps["getFillColor"];
+};
+
+const resolvePointRadius = (style: LayerConfig["styles"][0], zoom: number, defaultValue = 5) => {
+ let value: DataDrivenPropertyValueSpecification | undefined;
+
+ if (style.type === "circle") {
+ value = style.paint?.["circle-radius"];
+ } else {
+ return undefined;
+ }
+
+ if (value === undefined) {
+ return defaultValue;
+ }
+
+ return ((feature) => {
+ const resolvedValue = resolveMapboxExpression(value, zoom, feature, "number");
+
+ if (resolvedValue === null || resolvedValue === undefined) {
+ return defaultValue;
+ }
+
+ return resolvedValue;
+ }) as MVTLayerProps["getPointRadius"];
+};
+
+const resolveLineColor = (
+ style: LayerConfig["styles"][0],
+ zoom: number,
+ defaultValue = [0, 0, 0],
+) => {
+ let value: DataDrivenPropertyValueSpecification | undefined;
+
+ if (style.type === "circle") {
+ value = style.paint?.["circle-stroke-color"];
+ } else if (style.type === "line") {
+ value = style.paint?.["line-color"];
+ } else {
+ return undefined;
+ }
+
+ if (value === undefined) {
+ return style.type === "line" ? defaultValue : undefined;
+ }
+
+ return ((feature) => {
+ const resolvedValue = resolveMapboxExpression(value, zoom, feature, "color");
+
+ if (resolvedValue === null || resolvedValue === undefined) {
+ return defaultValue;
+ }
+
+ return resolvedValue;
+ }) as MVTLayerProps["getLineColor"];
+};
+
+const resolveLineWidth = (style: LayerConfig["styles"][0], zoom: number, defaultValue = 2) => {
+ let value: DataDrivenPropertyValueSpecification | undefined;
+
+ if (style.type === "circle") {
+ value = style.paint?.["circle-stroke-width"];
+ } else if (style.type === "line") {
+ value = style.paint?.["line-width"];
+ } else {
+ return undefined;
+ }
+
+ if (value === undefined) {
+ return style.type === "line" ? defaultValue : undefined;
+ }
+
+ return ((feature) => {
+ const resolvedValue = resolveMapboxExpression(value, zoom, feature, "number");
+
+ if (resolvedValue === null || resolvedValue === undefined) {
+ return defaultValue;
+ }
+
+ return resolvedValue;
+ }) as MVTLayerProps["getLineWidth"];
+};
+
+const resolveIcon = (style: LayerConfig["styles"][0], zoom: number, defaultValue = undefined) => {
+ let value: DataDrivenPropertyValueSpecification | undefined;
+
+ if (style.type === "symbol") {
+ value = style.layout?.["icon-image"];
+ } else {
+ return undefined;
+ }
+
+ if (value === undefined) {
+ return defaultValue;
+ }
+
+ return ((feature) => {
+ const resolvedValue = resolveMapboxExpression(value, zoom, feature, "string");
+
+ if (resolvedValue === null || resolvedValue === undefined) {
+ return defaultValue;
+ }
+
+ return resolvedValue;
+ // NOTE: intentionally wrong as `MVTLayerProps["getIcon"]` return type any
+ }) as MVTLayerProps["getFillColor"];
+};
+
+const resolveIconSizeScale = (
+ style: LayerConfig["styles"][0],
+ zoom: number,
+ defaultValue = undefined,
+) => {
+ let value: DataDrivenPropertyValueSpecification | undefined;
+
+ if (style.type === "symbol") {
+ value = style.layout?.["icon-size"];
+ } else {
+ return undefined;
+ }
+
+ if (value === undefined) {
+ return defaultValue;
+ }
+
+ const resolvedValue = resolveMapboxExpression(value, zoom, undefined, "number");
+
+ if (resolvedValue === null || resolvedValue === undefined) {
+ return defaultValue;
+ }
+
+ return resolvedValue;
+};
+
+export const resolveDeckglProperties = (style: LayerConfig["styles"][0], zoom: number) => {
+ const resolvedProperties = {
+ visible: resolveVisible(style),
+ opacity: resolveOpacity(style),
+ getFillColor: resolveFillColor(style, zoom) as MVTLayerProps["getFillColor"],
+ getPointRadius: resolvePointRadius(style, zoom),
+ getLineColor: resolveLineColor(style, zoom) as MVTLayerProps["getLineColor"],
+ getLineWidth: resolveLineWidth(style, zoom),
+ getIcon: resolveIcon(style, zoom),
+ getIconSize: 36,
+ iconSizeScale: resolveIconSizeScale(style, zoom),
+ pointType: style.type === "symbol" ? "icon" : "circle",
+ pointRadiusUnits: "pixels" as const,
+ lineWidthUnits: "pixels" as const,
+ iconSizeUnits: "pixels" as const,
+ iconAtlas: `https://api.mapbox.com/styles/v1/${env.NEXT_PUBLIC_MAPBOX_STYLE.split("mapbox://styles/")[1]}/sprite.png?access_token=${env.NEXT_PUBLIC_MAPBOX_TOKEN}`,
+ iconMapping: `https://api.mapbox.com/styles/v1/${env.NEXT_PUBLIC_MAPBOX_STYLE.split("mapbox://styles/")[1]}/sprite.json?access_token=${env.NEXT_PUBLIC_MAPBOX_TOKEN}`,
+ filled: true,
+ stroked: true,
+ };
+
+ // If the layer doesn't have any fill color, we make sure to not fill with anything
+ if (!resolvedProperties.getFillColor) {
+ resolvedProperties.filled = false;
+ }
+
+ // If the layer doesn't have any stroke width, we make sure to hide the stroke
+ if (!resolvedProperties.getLineWidth) {
+ resolvedProperties.stroked = false;
+ }
+
+ return Object.entries(resolvedProperties).reduce((res, [key, value]) => {
+ if (value === undefined) {
+ return res;
+ }
+
+ return {
+ ...res,
+ [key]: value,
+ };
+ }, {}) as Partial;
+};
+
+export const convertBinaryToPointGeoJSON = (data: BinaryFeatureCollection) => {
+ const featureOrFeatures = binaryToGeojson(data as BinaryFeatureCollection);
+ const features = Array.isArray(featureOrFeatures) ? featureOrFeatures : [featureOrFeatures];
+ const points = features.flatMap((feature) =>
+ coordAll(feature).map((coords) => point(coords, feature.properties)),
+ );
+ return featureCollection(points);
+};
diff --git a/client/yarn.lock b/client/yarn.lock
index 14c9519..b57393d 100644
--- a/client/yarn.lock
+++ b/client/yarn.lock
@@ -4269,14 +4269,7 @@ __metadata:
languageName: node
linkType: hard
-"@turf/helpers@npm:^5.1.5":
- version: 5.1.5
- resolution: "@turf/helpers@npm:5.1.5"
- checksum: 10c0/f5ed19cddef37fb5098e2509e8472df3afe099dcd6db62b7e541cf37c02c6ea1b13f69c29ff493ded7a1374c1a9b185a87fefee368211934364977dffd48b2e9
- languageName: node
- linkType: hard
-
-"@turf/helpers@npm:^7.1.0":
+"@turf/helpers@npm:7.1.0, @turf/helpers@npm:^7.1.0":
version: 7.1.0
resolution: "@turf/helpers@npm:7.1.0"
dependencies:
@@ -4286,25 +4279,23 @@ __metadata:
languageName: node
linkType: hard
-"@turf/invariant@npm:^5.1.5":
- version: 5.2.0
- resolution: "@turf/invariant@npm:5.2.0"
- dependencies:
- "@turf/helpers": "npm:^5.1.5"
- checksum: 10c0/c7d6c81f85d85ce7da5bdbc457a61609a11a54f209f0bb922bcd12c329e9e7855d2b14b2df596c78521193b44c2a92cecf2f50db228546fa1a92beb413a22fbb
+"@turf/helpers@npm:^5.1.5":
+ version: 5.1.5
+ resolution: "@turf/helpers@npm:5.1.5"
+ checksum: 10c0/f5ed19cddef37fb5098e2509e8472df3afe099dcd6db62b7e541cf37c02c6ea1b13f69c29ff493ded7a1374c1a9b185a87fefee368211934364977dffd48b2e9
languageName: node
linkType: hard
-"@turf/meta@npm:^5.1.5":
+"@turf/invariant@npm:^5.1.5":
version: 5.2.0
- resolution: "@turf/meta@npm:5.2.0"
+ resolution: "@turf/invariant@npm:5.2.0"
dependencies:
"@turf/helpers": "npm:^5.1.5"
- checksum: 10c0/fd41fbad84d840bebf75fdf13a4e3dd15b8c600251533073d5f6129a31a42e4f88790ce396492cec69f42ca4365e96d6f7940aeb302daaedcb795dc9414e7adc
+ checksum: 10c0/c7d6c81f85d85ce7da5bdbc457a61609a11a54f209f0bb922bcd12c329e9e7855d2b14b2df596c78521193b44c2a92cecf2f50db228546fa1a92beb413a22fbb
languageName: node
linkType: hard
-"@turf/meta@npm:^7.1.0":
+"@turf/meta@npm:7.1.0, @turf/meta@npm:^7.1.0":
version: 7.1.0
resolution: "@turf/meta@npm:7.1.0"
dependencies:
@@ -4314,6 +4305,15 @@ __metadata:
languageName: node
linkType: hard
+"@turf/meta@npm:^5.1.5":
+ version: 5.2.0
+ resolution: "@turf/meta@npm:5.2.0"
+ dependencies:
+ "@turf/helpers": "npm:^5.1.5"
+ checksum: 10c0/fd41fbad84d840bebf75fdf13a4e3dd15b8c600251533073d5f6129a31a42e4f88790ce396492cec69f42ca4365e96d6f7940aeb302daaedcb795dc9414e7adc
+ languageName: node
+ linkType: hard
+
"@turf/rewind@npm:^5.1.5":
version: 5.1.5
resolution: "@turf/rewind@npm:5.1.5"
@@ -5499,6 +5499,7 @@ __metadata:
"@dnd-kit/core": "npm:6.1.0"
"@dnd-kit/modifiers": "npm:7.0.0"
"@dnd-kit/sortable": "npm:8.0.0"
+ "@loaders.gl/gis": "npm:4.3.2"
"@radix-ui/react-checkbox": "npm:1.1.2"
"@radix-ui/react-collapsible": "npm:1.1.1"
"@radix-ui/react-dialog": "npm:1.1.2"
@@ -5517,6 +5518,8 @@ __metadata:
"@tanstack/eslint-plugin-query": "npm:5.59.7"
"@tanstack/react-query": "npm:5.59.16"
"@turf/bbox": "npm:7.1.0"
+ "@turf/helpers": "npm:7.1.0"
+ "@turf/meta": "npm:7.1.0"
"@types/mapbox-gl": "npm:3.4.0"
"@types/node": "npm:22.7.6"
"@types/react": "npm:18.3.1"
diff --git a/cms/src/extensions/documentation/documentation/1.0.0/full_documentation.json b/cms/src/extensions/documentation/documentation/1.0.0/full_documentation.json
index 113efd2..7a050e1 100644
--- a/cms/src/extensions/documentation/documentation/1.0.0/full_documentation.json
+++ b/cms/src/extensions/documentation/documentation/1.0.0/full_documentation.json
@@ -14,7 +14,7 @@
"name": "Apache 2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
},
- "x-generation-date": "2024-11-18T10:23:49.468Z"
+ "x-generation-date": "2024-11-18T15:05:56.007Z"
},
"x-strapi-config": {
"path": "/documentation",