import React, { useEffect, useMemo, useCallback, useState } from 'react'; import DeckGL from '@deck.gl/react'; import { FlyToInterpolator, WebMercatorViewport } from '@deck.gl/core'; import { SelectionLayer } from '@deck.gl-community/editable-layers'; import { Coordinate, Layer } from './mapvis.types'; import { VISComponentType, VisualizationPropTypes } from '../../common'; import { layerTypes, createBaseMap } from './layers'; import { MapSettings } from './settings'; import { Node } from '@graphpolaris/shared/lib/data-access/store/graphQueryResultSlice'; import { HighlightAlt, SearchOutlined } from '@mui/icons-material'; import SearchBar from './search'; export type MapProps = { layer: string }; const settings: MapProps = { layer: 'nodelink' }; const INITIAL_VIEW_STATE = { latitude: 52.1006, longitude: 5.6464, zoom: 6, bearing: 0, pitch: 0, }; const FLY_SPEED = 1000; const baseLayer = createBaseMap(); export const MapVis = ({ data, settings, handleSelect, graphMetadata }: VisualizationPropTypes<MapProps>) => { const [layer, setLayer] = useState<Layer | undefined>(undefined); const [viewport, setViewport] = useState<Record<string, any>>(INITIAL_VIEW_STATE); const [hoverObject, setHoverObject] = useState<Node | null>(null); const [selected, setSelected] = useState<any[]>([]); const [selectingRectangle, setSelectingRectangle] = useState<boolean>(false); const [layerIds, setLayerIds] = useState<string[]>([]); const [isSearching, setIsSearching] = useState<boolean>(false); const getFittedViewport = useCallback( (minLat: number, maxLat: number, minLon: number, maxLon: number) => { const viewportWebMercator = new WebMercatorViewport(viewport).fitBounds( [ [minLon, minLat], [maxLon, maxLat], ], { padding: 20 }, ); const { zoom, longitude, latitude } = viewportWebMercator; return { zoom, longitude, latitude }; }, [viewport], ); const flyToBoundingBox = useCallback( (minLat: number, maxLat: number, minLon: number, maxLon: number, options = {}) => { const fittedViewport = getFittedViewport(minLat, maxLat, minLon, maxLon); setViewport((prevViewport) => ({ ...prevViewport, ...options, ...fittedViewport, transitionDuration: FLY_SPEED, transitionInterpolator: new FlyToInterpolator(), })); }, [getFittedViewport], ); useEffect(() => { setLayer({ type: settings.layer ? layerTypes?.[settings.layer] : layerTypes.nodelink, config: settings, }); }, [settings.layer]); const dataLayer = useMemo(() => { if (!layer || !settings.layer) return null; const coordinateLookup: { [id: string]: Coordinate } = data.nodes.reduce( (acc, node) => { const latitude = settings?.[node.label]?.lat ? (node?.attributes?.[settings[node.label].lat] as string) : undefined; const longitude = settings?.[node.label]?.lon ? (node?.attributes?.[settings[node.label].lon] as string) : undefined; if (latitude !== undefined && longitude !== undefined) { acc[node._id] = [parseFloat(longitude), parseFloat(latitude)]; } return acc; }, {} as { [id: string]: Coordinate }, ); return new layer.type({ graph: data, metaData: graphMetadata, config: settings, selected: selected, hoverObject: hoverObject, getNodeLocation: (d: string) => coordinateLookup[d], flyToBoundingBox: flyToBoundingBox, setLayerIds: (val: string[]) => setLayerIds(val), }); }, [layer, data, selected, hoverObject, settings]); const selectionLayer = useMemo( () => selectingRectangle && new (SelectionLayer as any)({ id: 'selection', selectionType: 'rectangle', onSelect: ({ pickingInfos }: { pickingInfos: any[] }) => { if (pickingInfos.length > 0) { const nodes = []; const edges = []; for (const selectedItem of pickingInfos) { const { object } = selectedItem; if (object._id) { if (object.from & object.to) { edges.push(object); } else { nodes.push(object); } } } setSelected(nodes.map((node) => node._id)); handleSelect({ nodes, edges }); } else { handleSelect(); } setSelectingRectangle(false); }, layerIds: layerIds, getTentativeFillColor: () => [22, 37, 67, 100], }), [selectingRectangle, layer], ); return ( <div className="w-full h-full flex-grow relative overflow-hidden"> <div className="absolute left-0 top-0 z-50 m-1"> <div className="cursor-pointer p-1 bg-white shadow-md rounded mb-1" onClick={() => setSelectingRectangle(true)}> <HighlightAlt /> </div> <div className="cursor-pointer p-1 bg-white shadow-md rounded" onClick={() => setIsSearching(!isSearching)}> <SearchOutlined /> </div> </div> {isSearching && ( <SearchBar onSearch={(boundingbox: [number, number, number, number]) => { flyToBoundingBox(...boundingbox); setIsSearching(false); }} /> )} <DeckGL layers={[baseLayer, dataLayer, selectionLayer]} controller={true} initialViewState={viewport} onViewStateChange={({ viewState }) => setViewport(viewState)} onClick={({ object }) => { if (data) { if (!object) { handleSelect(); setSelected([]); return; } if (object.hasOwnProperty('attributes') && object.hasOwnProperty('id') && object.hasOwnProperty('label')) { handleSelect({ nodes: [object] }); setSelected([object.id]); } if (object.type === 'Feature') { const ids = object.properties.nodes; if (ids.length > 0) { const nodes = data.nodes.filter((node) => ids.includes((node as unknown as { id: string }).id)); handleSelect({ nodes: [...nodes] }); } else { handleSelect(); setSelected([]); return; } } } }} onHover={({ object }) => { setHoverObject(object !== undefined ? object : null); }} /> <div className="absolute right-0 top-0 p-1 z-50 bg-white bg-opacity-75 text-xs"> {'© '} <a className="underline" href="http://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer"> OpenStreetMap </a> </div> </div> ); }; export const MapComponent: VISComponentType<MapProps> = { displayName: 'MapVis', component: MapVis, settingsComponent: MapSettings, settings: settings, }; export default MapComponent;