From 5101b61d2b0d7c05bb8ef5e28f124fe174589ae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Prod=27homme?= Date: Mon, 18 Nov 2024 14:36:26 +0100 Subject: [PATCH 1/6] refactor(client): Render raster layers with Deck.gl --- .../map/layer-manager/animated-layer.tsx | 8 +-- .../src/components/map/layer-manager/item.tsx | 5 ++ .../map/layer-manager/raster-layer.tsx | 66 +++++++++++++++++++ 3 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 client/src/components/map/layer-manager/raster-layer.tsx diff --git a/client/src/components/map/layer-manager/animated-layer.tsx b/client/src/components/map/layer-manager/animated-layer.tsx index 982ffd3..85805a6 100644 --- a/client/src/components/map/layer-manager/animated-layer.tsx +++ b/client/src/components/map/layer-manager/animated-layer.tsx @@ -1,6 +1,5 @@ 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,10 +78,9 @@ 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, }); diff --git a/client/src/components/map/layer-manager/item.tsx b/client/src/components/map/layer-manager/item.tsx index 17c3483..ba4eac6 100644 --- a/client/src/components/map/layer-manager/item.tsx +++ b/client/src/components/map/layer-manager/item.tsx @@ -2,6 +2,7 @@ import useLayerConfig from "@/hooks/use-layer-config"; import { LayerSettings } from "@/types/layer"; import AnimatedLayer from "./animated-layer"; +import RasterLayer from "./raster-layer"; import StaticLayer from "./static-layer"; interface LayerManagerItemProps { @@ -27,6 +28,10 @@ const LayerManagerItem = ({ id, beforeId, settings }: LayerManagerItemProps) => return ; } + if (config.source.type === "raster") { + return ; + } + return ; }; 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..fc8c566 --- /dev/null +++ b/client/src/components/map/layer-manager/raster-layer.tsx @@ -0,0 +1,66 @@ +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, + }); + }, + }); + + addLayer(layer); + + return () => { + removeLayer(config.styles[0].id); + }; + }, [config, beforeId, addLayer, removeLayer]); + + return null; +}; + +export default RasterLayer; From 1bd061c5a042547988f5faa4d7148cf12e915049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Prod=27homme?= Date: Mon, 18 Nov 2024 14:47:37 +0100 Subject: [PATCH 2/6] refactor(client): Add new `useLocationGeometry` hook --- client/src/hooks/use-apply-map-location.ts | 8 ++--- client/src/hooks/use-location-by-code.ts | 22 ++++++------- client/src/hooks/use-location-geometry.ts | 37 ++++++++++++++++++++++ 3 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 client/src/hooks/use-location-geometry.ts diff --git a/client/src/hooks/use-apply-map-location.ts b/client/src/hooks/use-apply-map-location.ts index 377a996..720d844 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,16 +13,16 @@ 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(() => { 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 }; +} From f0cbff08d2033e384b1daf2440bbfaa084d74813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Prod=27homme?= Date: Mon, 18 Nov 2024 15:53:30 +0100 Subject: [PATCH 3/6] refactor(client): Set the administrative level 0 as default --- client/src/hooks/use-apply-map-location.ts | 2 +- client/src/hooks/use-location.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/hooks/use-apply-map-location.ts b/client/src/hooks/use-apply-map-location.ts index 720d844..f07244d 100644 --- a/client/src/hooks/use-apply-map-location.ts +++ b/client/src/hooks/use-apply-map-location.ts @@ -26,7 +26,7 @@ export default function useApplyMapLocation(map: MapRef | null) { }, [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.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"], }), ); } From 3420d25e90a2d22859ee79bbed0c74e0849eed54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Prod=27homme?= Date: Mon, 18 Nov 2024 16:47:49 +0100 Subject: [PATCH 4/6] feat(client): Mask the raster and animated layers --- client/src/components/map/index.tsx | 7 ++- .../map/layer-manager/animated-layer.tsx | 3 ++ .../components/map/layer-manager/index.tsx | 54 ++++++++++++------- .../map/layer-manager/mask-layer.tsx | 52 ++++++++++++++++++ .../map/layer-manager/raster-layer.tsx | 3 ++ .../src/components/panels/location/index.tsx | 5 +- .../1.0.0/full_documentation.json | 2 +- 7 files changed, 101 insertions(+), 25 deletions(-) create mode 100644 client/src/components/map/layer-manager/mask-layer.tsx 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 85805a6..84db408 100644 --- a/client/src/components/map/layer-manager/animated-layer.tsx +++ b/client/src/components/map/layer-manager/animated-layer.tsx @@ -1,3 +1,4 @@ +import { MaskExtension } from "@deck.gl/extensions"; import { TileLayer } from "@deck.gl/geo-layers"; import { BitmapLayer } from "@deck.gl/layers"; import parseAPNG from "apng-js"; @@ -83,6 +84,8 @@ const AnimatedLayer = ({ config, date, beforeId }: AnimatedLayerProps) => { 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..c722304 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,43 @@ 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, index) => { + const beforeId = index === 0 ? "data-layers" : `layer-position-${layers[index - 1].id}`; + const { id, ...settings } = layer; + return ( + + ); + }), + , + ]; }, [layers]); return ( 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 index fc8c566..1665d36 100644 --- a/client/src/components/map/layer-manager/raster-layer.tsx +++ b/client/src/components/map/layer-manager/raster-layer.tsx @@ -1,3 +1,4 @@ +import { MaskExtension } from "@deck.gl/extensions"; import { TileLayer } from "@deck.gl/geo-layers"; import { BitmapLayer } from "@deck.gl/layers"; import { useContext, useEffect } from "react"; @@ -49,6 +50,8 @@ const RasterLayer = ({ config, beforeId }: RasterLayerProps) => { mipmapFilter: undefined, }, image: subLayer.data, + extensions: [new MaskExtension()], + maskId: "mask", }); }, }); 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/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", From 5b41072340bddaedef0f454c46518ab837d451c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Prod=27homme?= Date: Tue, 19 Nov 2024 10:40:18 +0100 Subject: [PATCH 5/6] fix(client): Fix map rendering issues --- .../components/map/deckgl-mapbox-provider.tsx | 16 ++++++++++++---- .../src/components/map/layer-manager/index.tsx | 11 ++++------- 2 files changed, 16 insertions(+), 11 deletions(-) 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/layer-manager/index.tsx b/client/src/components/map/layer-manager/index.tsx index c722304..bc008f3 100644 --- a/client/src/components/map/layer-manager/index.tsx +++ b/client/src/components/map/layer-manager/index.tsx @@ -32,24 +32,21 @@ const LayerManager = () => { id="layer-position-mask" type="background" layout={{ visibility: "none" }} - beforeId={`layer-position-${layers.length === 0 ? "data-layers" : layers.slice(-1)[0].id}`} + beforeId={layers.length === 0 ? "data-layers" : `layer-position-${layers.slice(-1)[0].id}`} />, ]; }, [layers]); const layerManagerItems = useMemo(() => { return [ - ...layers.map((layer, index) => { - const beforeId = index === 0 ? "data-layers" : `layer-position-${layers[index - 1].id}`; + ...layers.map((layer) => { + const beforeId = `layer-position-${layer.id}`; const { id, ...settings } = layer; return ( ); }), - , + , ]; }, [layers]); From b1c4da9e64a3beecdad9f886edfc82b5aaee47e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Prod=27homme?= Date: Tue, 19 Nov 2024 18:51:40 +0100 Subject: [PATCH 6/6] feat(client): Mask vector layers --- client/package.json | 3 + .../src/components/map/layer-manager/item.tsx | 9 +- .../map/layer-manager/static-layer.tsx | 25 -- .../map/layer-manager/vector-layer.tsx | 97 ++++++ client/src/hooks/use-map-zoom.ts | 26 ++ client/src/types/layer.ts | 17 +- client/src/utils/mapbox-deckgl-bridge.ts | 282 ++++++++++++++++++ client/yarn.lock | 39 +-- 8 files changed, 451 insertions(+), 47 deletions(-) delete mode 100644 client/src/components/map/layer-manager/static-layer.tsx create mode 100644 client/src/components/map/layer-manager/vector-layer.tsx create mode 100644 client/src/hooks/use-map-zoom.ts create mode 100644 client/src/utils/mapbox-deckgl-bridge.ts 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/layer-manager/item.tsx b/client/src/components/map/layer-manager/item.tsx index ba4eac6..ed28c54 100644 --- a/client/src/components/map/layer-manager/item.tsx +++ b/client/src/components/map/layer-manager/item.tsx @@ -3,7 +3,7 @@ import { LayerSettings } from "@/types/layer"; import AnimatedLayer from "./animated-layer"; import RasterLayer from "./raster-layer"; -import StaticLayer from "./static-layer"; +import VectorLayer from "./vector-layer"; interface LayerManagerItemProps { id: number; @@ -32,7 +32,12 @@ const LayerManagerItem = ({ id, beforeId, settings }: LayerManagerItemProps) => return ; } - 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/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 ( - - {config.styles.map((style) => ( - - ))} - - ); -}; - -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/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"