From 468173e24c44a115b59c251fb6d3ab84e3f4691c Mon Sep 17 00:00:00 2001 From: "Vink, S.A. (Sjoerd)" <s.a.vink@uu.nl> Date: Tue, 9 Jul 2024 09:04:47 +0200 Subject: [PATCH] feat(map_nodelink): search and select --- .../vis/visualizations/mapvis/MapSettings.tsx | 40 +----- .../layers/heatmap-layer/HeatLayer.tsx | 19 ++- .../layers/icon-layer/IconLayer.tsx | 21 +++- .../layers/node-layer/NodeLayer.tsx | 21 +++- .../layers/nodelink-layer/NodeLinkLayer.tsx | 26 ++-- .../vis/visualizations/mapvis/graphModel.tsx | 119 ------------------ .../lib/vis/visualizations/mapvis/mapvis.tsx | 115 +++++++++-------- .../vis/visualizations/mapvis/mapvis.types.ts | 3 +- .../lib/vis/visualizations/mapvis/search.tsx | 46 +++++++ 9 files changed, 174 insertions(+), 236 deletions(-) delete mode 100644 libs/shared/lib/vis/visualizations/mapvis/graphModel.tsx create mode 100644 libs/shared/lib/vis/visualizations/mapvis/search.tsx diff --git a/libs/shared/lib/vis/visualizations/mapvis/MapSettings.tsx b/libs/shared/lib/vis/visualizations/mapvis/MapSettings.tsx index 7f4245b4c..8ffe587a1 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/MapSettings.tsx +++ b/libs/shared/lib/vis/visualizations/mapvis/MapSettings.tsx @@ -1,47 +1,9 @@ import React from 'react'; import { SettingsContainer } from '../../components/config'; import { layerSettings, layerTypes } from './components/layers'; -import { EntityPill, Input } from '../../..'; +import { Input } from '../../..'; import { VisualizationSettingsPropTypes } from '../../common'; import { MapProps } from './mapvis'; -import { nodeColorHex } from '../nodelinkvis/components/utils'; - -const DataLayerSettings = ({ - layer, - settings, - graphMetadata, - updateSettings, -}: VisualizationSettingsPropTypes<MapProps> & { layer: keyof typeof layerTypes }) => { - switch (layer) { - case 'nodelink': - return ( - <> - {graphMetadata.nodes.labels.map((item, index) => ( - <div className="flex m-1 items-center" key={item}> - <div className="w-3/4 mr-6"> - <EntityPill title={item} /> - </div> - <div className="w-1/2"> - <div className={`h-5 w-5 border-2 border-sec-300`} style={{ backgroundColor: nodeColorHex(index + 1) }}></div> - </div> - </div> - ))} - - <Input - label="Enable brushing" - type="boolean" - value={settings.enableBrushing} - onChange={(val) => { - console.log('update brush', val); - updateSettings({ enableBrushing: val as boolean }); - }} - /> - </> - ); - default: - return; - } -}; export const MapSettings = ({ settings, graphMetadata, updateSettings }: VisualizationSettingsPropTypes<MapProps>) => { const DataLayerSettings = settings.layer && layerSettings?.[settings.layer]; diff --git a/libs/shared/lib/vis/visualizations/mapvis/components/layers/heatmap-layer/HeatLayer.tsx b/libs/shared/lib/vis/visualizations/mapvis/components/layers/heatmap-layer/HeatLayer.tsx index dfe99c570..c991eb3a9 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/components/layers/heatmap-layer/HeatLayer.tsx +++ b/libs/shared/lib/vis/visualizations/mapvis/components/layers/heatmap-layer/HeatLayer.tsx @@ -44,10 +44,16 @@ export class HeatLayer extends CompositeLayer<LayerProps> { } renderLayers() { - const { graph, config, getNodeLocation } = this.props; + const { graph, config, getNodeLocation, setLayerIds } = this.props; - return graph.metaData.nodes.labels.map( - (label: string) => + const layers: any[] = []; + const layerIds: string[] = []; + + graph.metaData.nodes.labels.forEach((label: string) => { + const layerId = `${label}-nodes-iconlayer`; + layerIds.push(layerId); + + layers.push( new HeatmapLayer({ id: `${label}-nodes-iconlayer`, data: graph.nodes.filter((node: Node) => node.label === label), @@ -56,7 +62,12 @@ export class HeatLayer extends CompositeLayer<LayerProps> { getWeight: (d) => 1, aggregation: 'SUM', }), - ); + ); + }); + + setLayerIds(layerIds); + + return layers; // const layers = []; diff --git a/libs/shared/lib/vis/visualizations/mapvis/components/layers/icon-layer/IconLayer.tsx b/libs/shared/lib/vis/visualizations/mapvis/components/layers/icon-layer/IconLayer.tsx index 6f3a9520f..7db411237 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/components/layers/icon-layer/IconLayer.tsx +++ b/libs/shared/lib/vis/visualizations/mapvis/components/layers/icon-layer/IconLayer.tsx @@ -16,12 +16,18 @@ export class NodeIconLayer extends CompositeLayer<LayerProps> { } renderLayers() { - const { graph, config, getNodeLocation } = this.props; + const { graph, config, getNodeLocation, setLayerIds } = this.props; - return graph.metaData.nodes.labels.map( - (label: string) => + const layers: any[] = []; + const layerIds: string[] = []; + + graph.metaData.nodes.labels.forEach((label: string) => { + const layerId = `${label}-nodes-iconlayer`; + layerIds.push(layerId); + + layers.push( new IconLayer({ - id: `${label}-nodes-iconlayer`, + id: layerId, data: graph.nodes.filter((node: Node) => node.label === label), visible: !config[label].hidden, iconAtlas: 'https://raw.githubusercontent.com/visgl/deck.gl-data/master/website/icon-atlas.png', @@ -33,7 +39,12 @@ export class NodeIconLayer extends CompositeLayer<LayerProps> { getPosition: (d: any) => getNodeLocation(d._id), getSize: (d: any) => 3, }), - ); + ); + }); + + setLayerIds(layerIds); + + return layers; } } diff --git a/libs/shared/lib/vis/visualizations/mapvis/components/layers/node-layer/NodeLayer.tsx b/libs/shared/lib/vis/visualizations/mapvis/components/layers/node-layer/NodeLayer.tsx index af67e9b81..8177d5dd5 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/components/layers/node-layer/NodeLayer.tsx +++ b/libs/shared/lib/vis/visualizations/mapvis/components/layers/node-layer/NodeLayer.tsx @@ -28,12 +28,18 @@ export class NodeLayer extends CompositeLayer<LayerProps> { } renderLayers() { - const { graph, config, getNodeLocation } = this.props; + const { graph, config, getNodeLocation, setLayerIds } = this.props; - return graph.metaData.nodes.labels.map( - (label: string) => + const layers: any[] = []; + const layerIds: any[] = []; + + graph.metaData.nodes.labels.forEach((label: string) => { + const layerId = `${label}-nodes-scatterplot`; + layerIds.push(layerId); + + layers.push( new ScatterplotLayer({ - id: `${label}-nodes-scatterplot`, + id: layerId, visible: !config[label].hidden, data: graph.nodes.filter((node: Node) => node.label === label), pickable: true, @@ -46,6 +52,11 @@ export class NodeLayer extends CompositeLayer<LayerProps> { getFillColor: (d: any) => config[label].color, getRadius: (d: any) => this.getRadius(d, config), }), - ); + ); + }); + + setLayerIds(layerIds); + + return layers; } } diff --git a/libs/shared/lib/vis/visualizations/mapvis/components/layers/nodelink-layer/NodeLinkLayer.tsx b/libs/shared/lib/vis/visualizations/mapvis/components/layers/nodelink-layer/NodeLinkLayer.tsx index 1a8a8626c..b6b436a46 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/components/layers/nodelink-layer/NodeLinkLayer.tsx +++ b/libs/shared/lib/vis/visualizations/mapvis/components/layers/nodelink-layer/NodeLinkLayer.tsx @@ -13,17 +13,21 @@ export class NodeLinkLayer extends CompositeLayer<LayerProps> { } renderLayers() { - const { graph, config, getNodeLocation, selected } = this.props; + const { graph, config, getNodeLocation, selected, setLayerIds } = this.props; const layers = []; + const layerIds = []; const brushingExtension = new BrushingExtension(); const collisionFilter = new CollisionFilterExtension(); - graph.metaData.nodes.labels.map((label: string) => { + graph.metaData.nodes.labels.forEach((label: string) => { + const layerId = `${label}-nodes-scatterplot`; + layerIds.push(layerId); + layers.push( new ScatterplotLayer({ - id: `${label}-nodes-scatterplot`, + id: layerId, visible: !config[label].hidden, data: graph.nodes.filter((node: Node) => node.label === label), pickable: true, @@ -39,13 +43,16 @@ export class NodeLinkLayer extends CompositeLayer<LayerProps> { ); }); - graph.metaData.edges.labels.map((label: string) => { + graph.metaData.edges.labels.forEach((label: string) => { + const layerId = `${label}-edges-line`; + layerIds.push(layerId); + const edgeData = selected.length > 0 ? graph.edges.filter((edge: Edge) => selected.includes(edge.from) || selected.includes(edge.to)) : graph.edges; layers.push( new LineLayer({ - id: `${label}-edges-line`, + id: layerId, data: edgeData, pickable: true, getWidth: (d: any) => config[label].width, @@ -59,9 +66,12 @@ export class NodeLinkLayer extends CompositeLayer<LayerProps> { ); }); + const textLayerId = 'label-target'; + layerIds.push(textLayerId); + layers.push( new TextLayer({ - id: 'label-target', + id: textLayerId, data: graph.nodes, getPosition: (d: any) => getNodeLocation(d._id), getText: (d: any) => d.id, @@ -80,7 +90,9 @@ export class NodeLinkLayer extends CompositeLayer<LayerProps> { }), ); - return [...layers]; + setLayerIds(layerIds); + + return layers; } } diff --git a/libs/shared/lib/vis/visualizations/mapvis/graphModel.tsx b/libs/shared/lib/vis/visualizations/mapvis/graphModel.tsx deleted file mode 100644 index 5cd11593e..000000000 --- a/libs/shared/lib/vis/visualizations/mapvis/graphModel.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { GraphType, Node, Edge, Coordinate } from './mapvis.types'; - -export default class GraphModel implements GraphType { - nodeMap: { [id: string]: Node }; - edgeMap: { [id: string]: Edge }; - graphInfo: { - nodeAttributes: { [attribute: string]: any }; - edgeAttributes: { [attribute: string]: any }; - }; - - constructor() { - this.nodeMap = {}; - this.edgeMap = {}; - - this.graphInfo = { - nodeAttributes: {}, - edgeAttributes: {}, - }; - } - - getNode(id: string): Node | null { - return this.nodeMap[id] ?? null; - } - - getNodes(): Node[] { - return Object.values(this.nodeMap); - } - - getNodeLocation(id: string): Coordinate | null { - const node: Node | null = this.getNode(id); - return node ? [node.attributes.long, node.attributes.lat] : null; - } - - getConnectedEdges(nodeId: string) { - const node = this.getNode(nodeId); - if (node) { - const edgeIds = node.connectedEdges; - if (edgeIds.length > 0) { - const edges = edgeIds.map((id: string) => this.edgeMap[id]); - return edges; - } else { - return []; - } - } else { - return []; - } - } - - getEdge(id: string): Edge | null { - return this.edgeMap[id] ?? null; - } - - getEdges(): Edge[] { - return Object.values(this.edgeMap); - } - - consumeMessageFromBackend(queryResult: { [key: string]: any }) { - queryResult.nodes.map((node: Node) => { - const connectedEdges: string[] = []; - queryResult.edges.map((edge: Edge) => { - this.edgeMap[edge.id] = edge; - this.collectAttributeInfo(this.graphInfo.edgeAttributes, edge); - if (edge.from === node.id || edge.to === node.id) { - connectedEdges.push(edge.id); - } - }); - - this.nodeMap[node.id] = { ...node, connectedEdges }; - this.collectAttributeInfo(this.graphInfo.nodeAttributes, node); - }); - } - - getGraphInfo(): { - nodeAttributes: { [attribute: string]: any }; - edgeAttributes: { [attribute: string]: any }; - } { - return this.graphInfo; - } - - collectAttributeInfo(attributeCollection: { [key: string]: any }, entity: any) { - for (const attribute in entity) { - const value = entity[attribute]; - const dataType = typeof value; - - if (dataType == 'object') { - this.collectAttributeInfo(attributeCollection, value); - } else { - if (attributeCollection[attribute]) { - attributeCollection[attribute].count++; - if (dataType == 'number') { - const currentRange = attributeCollection[attribute].values; - attributeCollection[attribute].values = [Math.min(currentRange[0], value), Math.max(currentRange[1], value)]; - } else if (dataType == 'string') { - attributeCollection[attribute].values.add(value); - } - } else { - if (dataType == 'number') { - attributeCollection[attribute] = { - count: 1, - dataType, - values: [value, value], - }; - } else if (dataType == 'string') { - attributeCollection[attribute] = { - count: 1, - dataType, - values: new Set([value]), - }; - } else if (dataType == 'boolean') { - attributeCollection[attribute] = { - count: 1, - dataType, - }; - } - } - } - } - } -} diff --git a/libs/shared/lib/vis/visualizations/mapvis/mapvis.tsx b/libs/shared/lib/vis/visualizations/mapvis/mapvis.tsx index e122e2b27..2ba0f449b 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/mapvis.tsx +++ b/libs/shared/lib/vis/visualizations/mapvis/mapvis.tsx @@ -8,22 +8,13 @@ import { layerTypes } from './components/layers'; import { createBaseMap } from './components/BaseMap'; import { MapSettings } from './MapSettings'; import { Node } from '@graphpolaris/shared/lib/data-access/store/graphQueryResultSlice'; +import { HighlightAlt, SearchOutlined } from '@mui/icons-material'; +import SearchBar from './search'; +import { addError } from '@graphpolaris/shared/lib/data-access/store/configSlice'; -export type MapProps = { - layer: string; - node: undefined | string; - lat: string; - lon: string; - enableBrushing: boolean; -}; +export type MapProps = { layer: string }; -const settings: MapProps = { - layer: 'node', - node: undefined, - lat: 'gp_latitude', - lon: 'gp_longitude', - enableBrushing: false, -}; +const settings: MapProps = { layer: 'node' }; const INITIAL_VIEW_STATE = { latitude: 52.1006, @@ -37,36 +28,14 @@ const FLY_SPEED = 1000; const baseLayer = createBaseMap(); -export const MapVis = ({ data, settings, updateSettings, graphMetadata, handleSelect }: VisualizationPropTypes<MapProps>) => { +export const MapVis = ({ data, settings, updateSettings, graphMetadata, handleSelect, dispatch }: 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 [isSelecting, setIsSelecting] = useState<boolean>(false); const [selectingRectangle, setSelectingRectangle] = useState<boolean>(false); - - const handleKeyDown = useCallback((event: KeyboardEvent) => { - if (event.ctrlKey && event.key === 'a') { - event.preventDefault(); - setSelectingRectangle(true); - } - }, []); - - const handleKeyUp = useCallback((event: KeyboardEvent) => { - if (event.key === 'Control' || event.key === 'a') { - setSelectingRectangle(false); - } - }, []); - - useEffect(() => { - window.addEventListener('keydown', handleKeyDown); - window.addEventListener('keyup', handleKeyUp); - - return () => { - window.removeEventListener('keydown', handleKeyDown); - window.removeEventListener('keyup', handleKeyUp); - }; - }, [handleKeyDown, handleKeyUp]); + const [layerIds, setLayerIds] = useState<string[]>([]); + const [isSearching, setIsSearching] = useState<boolean>(false); const getFittedViewport = useCallback( (minLat: number, maxLat: number, minLon: number, maxLon: number) => { @@ -99,29 +68,25 @@ export const MapVis = ({ data, settings, updateSettings, graphMetadata, handleSe useEffect(() => { const layerType = settings.layer ? layerTypes?.[settings.layer] : layerTypes.node; + const newLayerId = `layer-${Date.now()}`; setLayer({ - id: Date.now(), - name: 'New layer', + id: newLayerId, type: layerType, config: settings, visible: true, }); - }, [settings.layer]); - useEffect(() => { - if (settings.node != undefined && !graphMetadata.nodes.labels.includes(settings.node)) { - updateSettings({ node: undefined }); - } - }, [graphMetadata.nodes.types, data, settings]); + setLayerIds((prevIds) => [...prevIds, newLayerId]); + }, [settings.layer]); const dataLayer = useMemo(() => { - if (!layer || !settings.node || !settings.lat || !settings.lon) return null; + if (!layer || !settings.layer) return null; const coordinateLookup: { [id: string]: Coordinate } = data.nodes.reduce( (acc, node) => { - const latitude = settings.lat ? (node?.attributes?.[settings[node.label].lat] as string) : undefined; - const longitude = settings.lon ? (node?.attributes?.[settings[node.label].lon] as string) : undefined; + 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 && !!longitude) { acc[node._id] = [parseFloat(longitude), parseFloat(latitude)]; @@ -139,13 +104,13 @@ export const MapVis = ({ data, settings, updateSettings, graphMetadata, handleSe config: settings, selected: selected, hoverObject: hoverObject, - isSelecting: isSelecting, getNodeLocation: (d: string) => { return coordinateLookup[d]; }, flyToBoundingBox: flyToBoundingBox, + setLayerIds: (val: string[]) => setLayerIds(val), }); - }, [layer, data, selected, hoverObject, isSelecting, settings]); + }, [layer, data, selected, hoverObject, settings]); const selectionLayer = useMemo( () => @@ -153,11 +118,29 @@ export const MapVis = ({ data, settings, updateSettings, graphMetadata, handleSe new (SelectionLayer as any)({ id: 'selection', selectionType: 'rectangle', - onSelect: ({ pickingInfos }: any) => { - setSelected(pickingInfos.map((item: any) => item.object)); + 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: [layer?.id ? layer.id : ''], + layerIds: layerIds, getTentativeFillColor: () => [22, 37, 67, 100], }), [selectingRectangle, layer], @@ -165,6 +148,22 @@ export const MapVis = ({ data, settings, updateSettings, graphMetadata, handleSe 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} @@ -198,6 +197,12 @@ export const MapVis = ({ data, settings, updateSettings, graphMetadata, handleSe 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> ); }; diff --git a/libs/shared/lib/vis/visualizations/mapvis/mapvis.types.ts b/libs/shared/lib/vis/visualizations/mapvis/mapvis.types.ts index 241feb6fe..8156ce3b5 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/mapvis.types.ts +++ b/libs/shared/lib/vis/visualizations/mapvis/mapvis.types.ts @@ -10,8 +10,7 @@ export interface GeoJSONData { } export type Layer = { - id: number; - name: string; + id: string; type: any; config: any; visible: boolean; diff --git a/libs/shared/lib/vis/visualizations/mapvis/search.tsx b/libs/shared/lib/vis/visualizations/mapvis/search.tsx new file mode 100644 index 000000000..f781ba798 --- /dev/null +++ b/libs/shared/lib/vis/visualizations/mapvis/search.tsx @@ -0,0 +1,46 @@ +import { Button, Input } from '@graphpolaris/shared/lib/components'; +import { useAppDispatch } from '@graphpolaris/shared/lib/data-access'; +import { addError } from '@graphpolaris/shared/lib/data-access/store/configSlice'; +import React, { useState } from 'react'; + +interface SearchBarProps { + onSearch: (boundingbox: [number, number, number, number]) => void; +} + +const SearchBar: React.FC<SearchBarProps> = ({ onSearch }) => { + const dispatch = useAppDispatch(); + const [query, setQuery] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleSearch = async () => { + setIsLoading(true); + + try { + const response = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json`); + const data = await response.json(); + if (data.length > 0) { + const { boundingbox } = data[0]; + if (boundingbox) { + onSearch(boundingbox.map(parseFloat)); + } + } else { + dispatch(addError('No results found')); + } + } catch (error) { + dispatch(addError('Error fetching coordinates')); + } + + setIsLoading(false); + }; + + return ( + <div className="absolute bottom-0 left-1/2 transform -translate-x-1/2 z-50 m-1 p-2 bg-white shadow-md rounded w-full max-w-xl"> + <div className="flex gap-2 items-center"> + <Input type="text" size="xs" value={query} onChange={(value) => setQuery(value)} /> + <Button label="Search" size="xs" onClick={handleSearch} disabled={isLoading} /> + </div> + </div> + ); +}; + +export default SearchBar; -- GitLab