From 5d3ca58846d1f537da60661c0cb81c3ddfb352d3 Mon Sep 17 00:00:00 2001 From: Leonardo <leomilho@gmail.com> Date: Wed, 24 Jul 2024 22:47:55 +0200 Subject: [PATCH] chore: refactor mapvis for future dev --- .../colorComponents/colorPicker/index.tsx | 7 +- .../vis/visualizations/mapvis/MapSettings.tsx | 103 +++++ .../mapvis/{search.tsx => SearchBar.tsx} | 6 +- .../choropleth-layer/ChoroplethOptions.tsx | 15 +- .../mapvis/layers/heatmap-layer/HeatLayer.tsx | 95 +---- .../layers/heatmap-layer/HeatLayerOptions.tsx | 135 +++---- .../mapvis/layers/icon-layer/IconLayer.tsx | 14 +- .../mapvis/layers/icon-layer/IconOptions.tsx | 16 +- .../visualizations/mapvis/layers/index.tsx | 17 +- .../mapvis/layers/node-layer/NodeLayer.tsx | 16 +- .../mapvis/layers/node-layer/NodeOptions.tsx | 241 ++++++----- .../layers/nodelink-layer/NodeLinkLayer.tsx | 136 +++---- .../layers/nodelink-layer/NodeLinkOptions.tsx | 380 +++++++++--------- .../lib/vis/visualizations/mapvis/mapvis.tsx | 133 ++++-- .../vis/visualizations/mapvis/mapvis.types.ts | 19 +- .../vis/visualizations/mapvis/settings.tsx | 24 -- 16 files changed, 691 insertions(+), 666 deletions(-) create mode 100644 libs/shared/lib/vis/visualizations/mapvis/MapSettings.tsx rename libs/shared/lib/vis/visualizations/mapvis/{search.tsx => SearchBar.tsx} (90%) delete mode 100644 libs/shared/lib/vis/visualizations/mapvis/settings.tsx diff --git a/libs/shared/lib/components/colorComponents/colorPicker/index.tsx b/libs/shared/lib/components/colorComponents/colorPicker/index.tsx index 0fb827637..bd9a01d16 100644 --- a/libs/shared/lib/components/colorComponents/colorPicker/index.tsx +++ b/libs/shared/lib/components/colorComponents/colorPicker/index.tsx @@ -4,7 +4,7 @@ import { useFloating, autoUpdate, offset, flip, shift, useInteractions, useClick type Props = { value: any; - updateValue: any; + updateValue: (val: [number, number, number]) => void; }; export default function ColorPicker({ value, updateValue }: Props) { @@ -52,9 +52,10 @@ export default function ColorPicker({ value, updateValue }: Props) { <TwitterPicker triangle="top-right" color={{ r: value[0], g: value[1], b: value[2] }} - onChangeComplete={(color: any) => { + onChangeComplete={(color) => { + console.log(color); const rgb = color.rgb; - const newValue = [rgb.r, rgb.g, rgb.b]; + const newValue: [number, number, number] = [rgb.r, rgb.g, rgb.b]; updateValue(newValue); setOpen(false); }} diff --git a/libs/shared/lib/vis/visualizations/mapvis/MapSettings.tsx b/libs/shared/lib/vis/visualizations/mapvis/MapSettings.tsx new file mode 100644 index 000000000..a5682a798 --- /dev/null +++ b/libs/shared/lib/vis/visualizations/mapvis/MapSettings.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useMemo } from 'react'; +import { SettingsContainer } from '../../components/config'; +import { layerSettings, layerTypes } from './layers'; +import { Input } from '../../..'; +import { VisualizationSettingsPropTypes } from '../../common'; +import { MapEdgeData, MapNodeData, MapNodeOrEdgeData, MapProps } from './mapvis'; + +export const MapSettings = ({ settings, graphMetadata, updateSettings }: VisualizationSettingsPropTypes<MapProps>) => { + const DataLayerSettings = settings.layer && layerSettings?.[settings.layer]; + + const spatialAttributes = useMemo(() => { + return Object.fromEntries( + graphMetadata.nodes.labels.map((node) => [ + node, + Object.entries(graphMetadata.nodes.types[node].attributes) + .filter(([, value]) => value.dimension === 'numerical') + .map(([key]) => key), + ]), + ); + }, [graphMetadata]); + + useEffect(() => { + const nodes = Object.fromEntries( + graphMetadata.nodes.labels.map( + (node) => + [ + node, + { + color: [252, 185, 0], + hidden: false, + fixed: true, + min: 0, + max: 10, + radius: 1, + lat: undefined, + lon: undefined, + collapsed: false, + shape: 'circle', + size: 10, + ...(settings?.nodes && node in settings.nodes ? settings.nodes[node] : {}), + }, + ] as [string, MapNodeData], + ), + ); + + const edges = Object.fromEntries( + graphMetadata.edges.labels.map( + (edge) => + [ + edge, + { + color: [171, 184, 195], + hidden: false, + fixed: true, + min: 0, + max: 10, + radius: 1, + sizeAttribute: undefined, + collapsed: false, + size: 10, + ...(settings?.edges && edge in settings.edges ? settings.edges[edge] : {}), + }, + ] as [string, MapEdgeData], + ), + ); + + updateSettings({ nodes: nodes, edges: edges }); + }, [graphMetadata]); + + useEffect(() => { + // Autodetect a lat or lon attribute if not already set + Object.keys(settings.nodes).forEach((node) => { + if ((!settings.nodes[node].lat || !settings.nodes[node].lon) && node in spatialAttributes) { + const lat = spatialAttributes[node].find((attr) => attr.includes('latitude')); + const lon = spatialAttributes[node].find((attr) => attr.includes('longitude')); + if (lat && lon) { + updateSettings({ nodes: { ...settings.nodes, [node]: { ...settings.nodes[node], lat, lon } } }); + } + } + }); + }, [spatialAttributes, settings]); + + return ( + <SettingsContainer> + <Input + label="Data layer" + type="dropdown" + inline + value={settings.layer} + options={Object.keys(layerTypes)} + onChange={(val) => updateSettings({ layer: val as string })} + /> + {DataLayerSettings && !!spatialAttributes && ( + <DataLayerSettings + settings={settings} + graphMetadata={graphMetadata} + updateSettings={updateSettings} + spatialAttributes={spatialAttributes} + /> + )} + </SettingsContainer> + ); +}; diff --git a/libs/shared/lib/vis/visualizations/mapvis/search.tsx b/libs/shared/lib/vis/visualizations/mapvis/SearchBar.tsx similarity index 90% rename from libs/shared/lib/vis/visualizations/mapvis/search.tsx rename to libs/shared/lib/vis/visualizations/mapvis/SearchBar.tsx index f781ba798..d6d37127e 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/search.tsx +++ b/libs/shared/lib/vis/visualizations/mapvis/SearchBar.tsx @@ -4,10 +4,10 @@ import { addError } from '@graphpolaris/shared/lib/data-access/store/configSlice import React, { useState } from 'react'; interface SearchBarProps { - onSearch: (boundingbox: [number, number, number, number]) => void; + onSearch: (boundingBox: [number, number, number, number]) => void; } -const SearchBar: React.FC<SearchBarProps> = ({ onSearch }) => { +export const SearchBar: React.FC<SearchBarProps> = ({ onSearch }) => { const dispatch = useAppDispatch(); const [query, setQuery] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -42,5 +42,3 @@ const SearchBar: React.FC<SearchBarProps> = ({ onSearch }) => { </div> ); }; - -export default SearchBar; diff --git a/libs/shared/lib/vis/visualizations/mapvis/layers/choropleth-layer/ChoroplethOptions.tsx b/libs/shared/lib/vis/visualizations/mapvis/layers/choropleth-layer/ChoroplethOptions.tsx index fea160aa1..a05d7e7bb 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/layers/choropleth-layer/ChoroplethOptions.tsx +++ b/libs/shared/lib/vis/visualizations/mapvis/layers/choropleth-layer/ChoroplethOptions.tsx @@ -4,7 +4,7 @@ import { VisualizationSettingsPropTypes } from '@graphpolaris/shared/lib/vis/com import { MapProps } from '../../mapvis'; import { EntityPill, Icon, Input, RelationPill } from '@graphpolaris/shared/lib/components'; -export default function ChoroplethOptions({ settings, graphMetadata, updateSettings }: VisualizationSettingsPropTypes<MapProps>) { +export function ChoroplethOptions({ settings, graphMetadata, updateSettings }: VisualizationSettingsPropTypes<MapProps>) { const [collapsed, setCollapsed] = useState<Record<string, boolean>>({}); useEffect(() => { @@ -59,22 +59,21 @@ export default function ChoroplethOptions({ settings, graphMetadata, updateSetti <div> <Input inline - label="Longitude" + label="Latitude" type="dropdown" - value={settings?.[nodeType]?.lon} + value={settings?.[nodeType]?.lat} options={[...spatialAttributes[nodeType]]} disabled={!settings.node || spatialAttributes[nodeType].length < 1} - onChange={(val) => updateSettings({ [nodeType]: { ...settings?.[nodeType], lon: val as string } })} + onChange={(val) => updateSettings({ [nodeType]: { ...settings?.[nodeType], lat: val as string } })} /> - <Input inline - label="Latitude" + label="Longitude" type="dropdown" - value={settings?.[nodeType]?.lat} + value={settings?.[nodeType]?.lon} options={[...spatialAttributes[nodeType]]} disabled={!settings.node || spatialAttributes[nodeType].length < 1} - onChange={(val) => updateSettings({ [nodeType]: { ...settings?.[nodeType], lat: val as string } })} + onChange={(val) => updateSettings({ [nodeType]: { ...settings?.[nodeType], lon: val as string } })} /> </div> )} diff --git a/libs/shared/lib/vis/visualizations/mapvis/layers/heatmap-layer/HeatLayer.tsx b/libs/shared/lib/vis/visualizations/mapvis/layers/heatmap-layer/HeatLayer.tsx index 5ad4670fb..4453d6dbc 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/layers/heatmap-layer/HeatLayer.tsx +++ b/libs/shared/lib/vis/visualizations/mapvis/layers/heatmap-layer/HeatLayer.tsx @@ -2,10 +2,10 @@ import React from 'react'; import { CompositeLayer, HeatmapLayer } from 'deck.gl'; import * as d3 from 'd3'; import { getDistance } from '../../utlis'; -import { Edge, LayerProps } from '../../mapvis.types'; +import { CompositeLayerType, Edge, LayerProps } from '../../mapvis.types'; import { Node } from '@graphpolaris/shared/lib/data-access'; -export class HeatLayer extends CompositeLayer<LayerProps> { +export class HeatLayer extends CompositeLayer<CompositeLayerType> { static type = 'Heatmap'; updateState({ changeFlags }: { changeFlags: any }) { @@ -16,7 +16,7 @@ export class HeatLayer extends CompositeLayer<LayerProps> { // Generates a path between source and target nodes return edges.map((edge: Edge, index) => { const length = getDistance(edge.path[0], edge.path[1]); - const nSegments = length * this.props.config.nSegments; + const nSegments = length * this.props.settings.nSegments; let xscale = d3 .scaleLinear() @@ -44,22 +44,24 @@ export class HeatLayer extends CompositeLayer<LayerProps> { } renderLayers() { - const { graph, config, getNodeLocation, setLayerIds, metaData } = this.props; + const { data, settings, getNodeLocation, selected, setLayerIds, graphMetadata } = this.props; const layers: any[] = []; const layerIds: string[] = []; - metaData.nodes.labels.forEach((label: string) => { - const layerId = `${label}-nodes-iconlayer`; + graphMetadata.nodes.labels.forEach((label: string) => { + const layerId = `${label}-nodes-heatmaplayer`; layerIds.push(layerId); + console.log(settings.nodes[label].size); layers.push( - new HeatmapLayer({ - id: `${label}-nodes-iconlayer`, - data: graph.nodes.filter((node: Node) => node.label === label), - visible: !config[label]?.hidden, + new HeatmapLayer<Node>({ + id: layerId, + data: data.nodes.filter((node: Node) => node.label === label), + visible: !settings.nodes[label].hidden, getPosition: (d: any) => getNodeLocation(d.id), - getWeight: (d) => 1, + getWeight: (d: any) => settings.nodes[label].size, + radiusPixels: settings.nodes[label].size, aggregation: 'SUM', }), ); @@ -68,77 +70,6 @@ export class HeatLayer extends CompositeLayer<LayerProps> { setLayerIds(layerIds); return layers; - - // const layers = []; - - // if (config.type === 'location') { - // layers.push( - // new HeatmapLayer( - // this.getSubLayerProps({ - // data: - // config.location === 'source' - // ? graph.getEdges().map((edge: Edge) => graph.getNode(edge.from)) - // : graph.getEdges().map((edge: Edge) => graph.getNode(edge.to)), - // getPosition: (d: any) => [d.attributes.long, d.attributes.lat], - // aggregation: 'SUM', - // }), - // ), - // ); - // } else if (config.type === 'distance') { - // layers.push( - // new HeatmapLayer( - // this.getSubLayerProps({ - // data: graph.getEdges().map((edge: Edge) => { - // const from = graph.getNode(edge.from); - // const from_coords: [number, number] = [from.attributes.long, from.attributes.lat]; - // const to = graph.getNode(edge.to); - // const to_coords: [number, number] = [to.attributes.long, to.attributes.lat]; - // const dist = getDistance(from_coords, to_coords); - // const node = config.location === 'source' ? from : to; - // return { ...node, distance: dist }; - // }), - // threshold: 0.5, - // getPosition: (d: any) => [d.attributes.long, d.attributes.lat], - // getWeight: (d: any) => d.distance, - // aggregation: 'MEAN', - // }), - // ), - // ); - // } else if (config.type === 'attribute') { - // console.log('attribute'); - // layers.push( - // new HeatmapLayer( - // this.getSubLayerProps({ - // data: graph.getEdges().map((edge: Edge) => graph.getNode(edge.from)), - // getPosition: (d: any) => [d.attributes.long, d.attributes.lat], - // getWeight: (d: any) => { - // console.log(d, d.attributes[config.attribute]); - // return 1; - // }, - // aggregation: 'SUM', - // }), - // ), - // ); - // } else if (config.type === 'path') { - // layers.push( - // new HeatmapLayer( - // this.getSubLayerProps({ - // data: this.createSegments( - // graph.getEdges().map((edge: Edge) => { - // return { - // ...edge, - // path: [this.props.graph.getNodeLocation(edge.from), this.props.graph.getNodeLocation(edge.to)], - // }; - // }), - // ).flatMap((edge) => edge.path), - // getPosition: (d: any) => d, - // aggregation: 'SUM', - // }), - // ), - // ); - // } - - // return [...layers]; } } diff --git a/libs/shared/lib/vis/visualizations/mapvis/layers/heatmap-layer/HeatLayerOptions.tsx b/libs/shared/lib/vis/visualizations/mapvis/layers/heatmap-layer/HeatLayerOptions.tsx index cae0eb34a..8b0ce65c2 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/layers/heatmap-layer/HeatLayerOptions.tsx +++ b/libs/shared/lib/vis/visualizations/mapvis/layers/heatmap-layer/HeatLayerOptions.tsx @@ -2,87 +2,76 @@ import React, { useState, useMemo, useEffect } from 'react'; import { VisualizationSettingsPropTypes } from '@graphpolaris/shared/lib/vis/common'; import { MapProps } from '../../mapvis'; import { Button, EntityPill, Input } from '@graphpolaris/shared/lib/components'; +import { MapLayerSettingsPropTypes } from '..'; -export default function HeatLayerOptions({ settings, graphMetadata, updateSettings }: VisualizationSettingsPropTypes<MapProps>) { - const [collapsed, setCollapsed] = useState<Record<string, boolean>>({}); - - useEffect(() => { - graphMetadata.nodes.labels.forEach((node) => { - updateSettings({ - [node]: { - color: [0, 0, 0], - hidden: false, - fixed: true, - min: 0, - max: 10, - radius: 1, - sizeAttribute: '', - lon: '', - lat: '', - ...settings?.[node], - }, - }); - }); - }, [graphMetadata]); - - const spatialAttributes: { [id: string]: string[] } = {}; - graphMetadata.nodes.labels.forEach((node) => { - spatialAttributes[node] = Object.entries(graphMetadata.nodes.types[node].attributes) - .filter(([, value]) => value.dimension === 'numerical') - .map(([key]) => key); - }); - +export function HeatLayerOptions({ settings, graphMetadata, updateSettings, spatialAttributes }: MapLayerSettingsPropTypes) { const handleCollapseToggle = (nodeType: string) => { - setCollapsed((prevCollapsed) => ({ - ...prevCollapsed, - [nodeType]: !prevCollapsed[nodeType], - })); + settings.nodes[nodeType].collapsed = !settings.nodes[nodeType].collapsed; + updateSettings({ nodes: settings.nodes }); }; return ( <div> - {graphMetadata.nodes.labels.map((nodeType) => ( - <div className="mt-2" key={nodeType}> - <div className="flex items-center"> - <div className="w-3/4 mr-6 cursor-pointer" onClick={() => handleCollapseToggle(nodeType)}> - <EntityPill title={nodeType} /> - </div> - <div className="w-1/2"> - <Button - iconComponent={settings?.[nodeType].hidden ? 'icon-[ic--baseline-visibility-off]' : 'icon-[ic--baseline-visibility]'} - variant="ghost" - onClick={() => { - updateSettings({ [nodeType]: { ...settings?.[nodeType], hidden: !settings?.[nodeType].hidden as boolean } }); - }} - /> - </div> - </div> - - {!collapsed[nodeType] && ( - <div> - <Input - inline - label="Longitude" - type="dropdown" - value={settings?.[nodeType]?.lon} - options={[...spatialAttributes[nodeType]]} - disabled={!settings.node || spatialAttributes[nodeType].length < 1} - onChange={(val) => updateSettings({ [nodeType]: { ...settings?.[nodeType], lon: val as string } })} - /> + {settings?.nodes && + Object.keys(settings.nodes).map((nodeType) => { + const nodeSettings = settings.nodes[nodeType]; + return ( + <div className="mt-2" key={nodeType}> + <div className="flex items-center"> + <div className="w-3/4 mr-6 cursor-pointer" onClick={() => handleCollapseToggle(nodeType)}> + <EntityPill title={nodeType} /> + </div> + <div className="w-1/2"> + <Button + iconComponent={nodeSettings.hidden ? 'icon-[ic--baseline-visibility-off]' : 'icon-[ic--baseline-visibility]'} + variant="ghost" + onClick={() => + updateSettings({ nodes: { ...settings.nodes, [nodeType]: { ...nodeSettings, hidden: !nodeSettings.hidden } } }) + } + /> + </div> + </div> - <Input - inline - label="Latitude" - type="dropdown" - value={settings?.[nodeType]?.lat} - options={[...spatialAttributes[nodeType]]} - disabled={!settings.node || spatialAttributes[nodeType].length < 1} - onChange={(val) => updateSettings({ [nodeType]: { ...settings?.[nodeType], lat: val as string } })} - /> + {!nodeSettings.collapsed && ( + <div> + <Input + inline + label="Latitude" + type="dropdown" + value={nodeSettings.lat} + options={[...(spatialAttributes[nodeType] || [])]} + disabled={!settings.node || spatialAttributes[nodeType].length < 1} + onChange={(val) => { + updateSettings({ nodes: { ...settings.nodes, [nodeType]: { ...nodeSettings, lat: String(val) } } }); + }} + /> + <Input + inline + label="Longitude" + type="dropdown" + value={nodeSettings.lon} + options={[...(spatialAttributes[nodeType] || [])]} + disabled={!settings.node || spatialAttributes[nodeType].length < 1} + onChange={(val) => { + updateSettings({ nodes: { ...settings.nodes, [nodeType]: { ...nodeSettings, lon: String(val) } } }); + }} + /> + <Input + label="Size" + type="slider" + min={0} + max={40} + step={1} + value={nodeSettings.size} + onChange={(val) => { + updateSettings({ nodes: { ...settings.nodes, [nodeType]: { ...nodeSettings, size: Number(val) } } }); + }} + /> + </div> + )} </div> - )} - </div> - ))} + ); + })} </div> ); } diff --git a/libs/shared/lib/vis/visualizations/mapvis/layers/icon-layer/IconLayer.tsx b/libs/shared/lib/vis/visualizations/mapvis/layers/icon-layer/IconLayer.tsx index aea4bf5b2..e4a3e1e09 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/layers/icon-layer/IconLayer.tsx +++ b/libs/shared/lib/vis/visualizations/mapvis/layers/icon-layer/IconLayer.tsx @@ -1,14 +1,14 @@ import React from 'react'; import { CompositeLayer } from 'deck.gl'; import { IconLayer } from '@deck.gl/layers'; -import { LayerProps } from '../../mapvis.types'; +import { CompositeLayerType, LayerProps } from '../../mapvis.types'; import { Node } from '@graphpolaris/shared/lib/data-access'; const ICON_MAPPING = { marker: { x: 0, y: 0, width: 128, height: 128, mask: false }, }; -export class NodeIconLayer extends CompositeLayer<LayerProps> { +export class NodeIconLayer extends CompositeLayer<CompositeLayerType> { static type = 'Icon'; updateState({ changeFlags }: { changeFlags: any }) { @@ -16,26 +16,26 @@ export class NodeIconLayer extends CompositeLayer<LayerProps> { } renderLayers() { - const { graph, config, getNodeLocation, setLayerIds, metaData } = this.props; + const { data, settings, getNodeLocation, setLayerIds, graphMetadata } = this.props; const layers: any[] = []; const layerIds: string[] = []; - metaData.nodes.labels.forEach((label: string) => { + graphMetadata.nodes.labels.forEach((label: string) => { const layerId = `${label}-nodes-iconlayer`; layerIds.push(layerId); layers.push( new IconLayer({ id: layerId, - data: graph.nodes.filter((node: Node) => node.label === label), - visible: !config[label].hidden, + data: data.nodes.filter((node: Node) => node.label === label), + visible: !settings.nodes[label].hidden, iconAtlas: 'https://raw.githubusercontent.com/visgl/deck.gl-data/master/website/icon-atlas.png', iconMapping: ICON_MAPPING, sizeScale: 10, pickable: true, getIcon: (d: any) => 'marker', - getColor: (d: any) => config[label].color, + getColor: (d: any) => settings.nodes[label].color, getPosition: (d: any) => getNodeLocation(d._id), getSize: (d: any) => 3, }), diff --git a/libs/shared/lib/vis/visualizations/mapvis/layers/icon-layer/IconOptions.tsx b/libs/shared/lib/vis/visualizations/mapvis/layers/icon-layer/IconOptions.tsx index 878dfd787..3643d74b3 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/layers/icon-layer/IconOptions.tsx +++ b/libs/shared/lib/vis/visualizations/mapvis/layers/icon-layer/IconOptions.tsx @@ -4,7 +4,7 @@ import { VisualizationSettingsPropTypes } from '@graphpolaris/shared/lib/vis/com import { MapProps } from '../../mapvis'; import { EntityPill, Icon, Input, RelationPill } from '@graphpolaris/shared/lib/components'; -export default function IconOptions({ settings, graphMetadata, updateSettings }: VisualizationSettingsPropTypes<MapProps>) { +export function IconOptions({ settings, graphMetadata, updateSettings }: VisualizationSettingsPropTypes<MapProps>) { const [collapsed, setCollapsed] = useState<Record<string, boolean>>({}); useEffect(() => { @@ -59,23 +59,23 @@ export default function IconOptions({ settings, graphMetadata, updateSettings }: <div> <Input inline - label="Longitude" + label="Latitude" type="dropdown" - value={settings?.[nodeType]?.lon} + value={settings?.[nodeType]?.lat} options={[...spatialAttributes[nodeType]]} disabled={!settings.node || spatialAttributes[nodeType].length < 1} - onChange={(val) => updateSettings({ [nodeType]: { ...settings?.[nodeType], lon: val as string } })} + onChange={(val) => updateSettings({ [nodeType]: { ...settings?.[nodeType], lat: val as string } })} /> - <Input inline - label="Latitude" + label="Longitude" type="dropdown" - value={settings?.[nodeType]?.lat} + value={settings?.[nodeType]?.lon} options={[...spatialAttributes[nodeType]]} disabled={!settings.node || spatialAttributes[nodeType].length < 1} - onChange={(val) => updateSettings({ [nodeType]: { ...settings?.[nodeType], lat: val as string } })} + onChange={(val) => updateSettings({ [nodeType]: { ...settings?.[nodeType], lon: val as string } })} /> + <div className="flex items-center gap-1"> <Icon component="icon-[ic--baseline-subdirectory-arrow-right]" size={16} color="text-secondary-300" /> <Input diff --git a/libs/shared/lib/vis/visualizations/mapvis/layers/index.tsx b/libs/shared/lib/vis/visualizations/mapvis/layers/index.tsx index fc09070cc..cd7d9fe0d 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/layers/index.tsx +++ b/libs/shared/lib/vis/visualizations/mapvis/layers/index.tsx @@ -3,12 +3,13 @@ import { HeatLayer } from './heatmap-layer/HeatLayer'; import { NodeLinkLayer } from './nodelink-layer/NodeLinkLayer'; import { NodeLayer } from './node-layer/NodeLayer'; import { NodeIconLayer } from './icon-layer/IconLayer'; -import NodeOptions from './node-layer/NodeOptions'; -import NodeLinkOptions from './nodelink-layer/NodeLinkOptions'; -import IconOptions from './icon-layer/IconOptions'; -import HeatLayerOptions from './heatmap-layer/HeatLayerOptions'; -import ChoroplethOptions from './choropleth-layer/ChoroplethOptions'; +import { NodeLinkOptions } from './nodelink-layer/NodeLinkOptions'; +import { IconOptions } from './icon-layer/IconOptions'; +import { HeatLayerOptions } from './heatmap-layer/HeatLayerOptions'; +import { ChoroplethOptions } from './choropleth-layer/ChoroplethOptions'; import { TileLayer, BitmapLayer } from 'deck.gl'; +import { VisualizationSettingsPropTypes } from '../../../common'; +import { MapProps } from '../mapvis'; export const layerTypes: Record<string, any> = { // node: NodeLayer, @@ -18,7 +19,11 @@ export const layerTypes: Record<string, any> = { // choropleth: ChoroplethLayer, }; -export const layerSettings: Record<string, any> = { +export type MapLayerSettingsPropTypes = VisualizationSettingsPropTypes<MapProps> & { + spatialAttributes: { [id: string]: string[] }; +}; + +export const layerSettings: Record<string, React.FC<MapLayerSettingsPropTypes>> = { nodelink: NodeLinkOptions, heatmap: HeatLayerOptions, // node: NodeOptions, diff --git a/libs/shared/lib/vis/visualizations/mapvis/layers/node-layer/NodeLayer.tsx b/libs/shared/lib/vis/visualizations/mapvis/layers/node-layer/NodeLayer.tsx index a3e027bb9..82f4a3a47 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/layers/node-layer/NodeLayer.tsx +++ b/libs/shared/lib/vis/visualizations/mapvis/layers/node-layer/NodeLayer.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { CompositeLayer } from 'deck.gl'; import { ScatterplotLayer } from '@deck.gl/layers'; -import { LayerProps } from '../../mapvis.types'; +import { CompositeLayerType, LayerProps } from '../../mapvis.types'; import { Node } from '@graphpolaris/shared/lib/data-access'; -export class NodeLayer extends CompositeLayer<LayerProps> { +export class NodeLayer extends CompositeLayer<CompositeLayerType> { static type = 'Node'; updateState({ changeFlags }: { changeFlags: any }) { @@ -28,20 +28,20 @@ export class NodeLayer extends CompositeLayer<LayerProps> { } renderLayers() { - const { graph, config, getNodeLocation, setLayerIds, metaData } = this.props; + const { data, settings, getNodeLocation, selected, setLayerIds, graphMetadata } = this.props; const layers: any[] = []; const layerIds: any[] = []; - metaData.nodes.labels.forEach((label: string) => { + graphMetadata.nodes.labels.forEach((label: string) => { const layerId = `${label}-nodes-scatterplot`; layerIds.push(layerId); layers.push( new ScatterplotLayer({ id: layerId, - visible: !config[label].hidden, - data: graph.nodes.filter((node: Node) => node.label === label), + visible: !settings.nodes[label].hidden, + data: data.nodes.filter((node: Node) => node.label === label), pickable: true, filled: true, radiusScale: 6, @@ -49,8 +49,8 @@ export class NodeLayer extends CompositeLayer<LayerProps> { radiusMaxPixels: 100, lineWidthMinPixels: 1, getPosition: (d: any) => getNodeLocation(d._id), - getFillColor: (d: any) => config[label].color, - getRadius: (d: any) => this.getRadius(d, config), + getFillColor: (d: any) => settings.nodes[label].color, + getRadius: (d: any) => settings.nodes[label].radius, }), ); }); diff --git a/libs/shared/lib/vis/visualizations/mapvis/layers/node-layer/NodeOptions.tsx b/libs/shared/lib/vis/visualizations/mapvis/layers/node-layer/NodeOptions.tsx index 0054a2f7d..2210c29f6 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/layers/node-layer/NodeOptions.tsx +++ b/libs/shared/lib/vis/visualizations/mapvis/layers/node-layer/NodeOptions.tsx @@ -1,153 +1,144 @@ import React, { useEffect, useMemo, useState } from 'react'; import ColorPicker from '@graphpolaris/shared/lib/components/colorComponents/colorPicker'; -import { MapProps } from '../../mapvis'; +import { MapNodeOrEdgeData, MapProps } from '../../mapvis'; import { VisualizationSettingsPropTypes } from '@graphpolaris/shared/lib/vis/common'; import { EntityPill, Icon, Input } from '@graphpolaris/shared/lib/components'; +import { MapLayerSettingsPropTypes } from '..'; -export default function NodeOptions({ settings, graphMetadata, updateSettings }: VisualizationSettingsPropTypes<MapProps>) { - const [collapsed, setCollapsed] = useState<Record<string, boolean>>({}); - - useEffect(() => { - graphMetadata.nodes.labels.forEach((node) => { - updateSettings({ - [node]: { - color: [0, 0, 0], - hidden: false, - fixed: true, - min: 0, - max: 10, - radius: 1, - sizeAttribute: '', - lon: '', - lat: '', - ...settings?.[node], - }, - }); - }); - }, [graphMetadata]); - - const spatialAttributes: { [id: string]: string[] } = {}; - graphMetadata.nodes.labels.forEach((node) => { - spatialAttributes[node] = Object.entries(graphMetadata.nodes.types[node].attributes) - .filter(([, value]) => value.dimension === 'numerical') - .map(([key]) => key); - }); - +export default function NodeOptions({ settings, graphMetadata, updateSettings, spatialAttributes }: MapLayerSettingsPropTypes) { const handleCollapseToggle = (nodeType: string) => { - setCollapsed((prevCollapsed) => ({ - ...prevCollapsed, - [nodeType]: !prevCollapsed[nodeType], - })); + settings.nodes[nodeType].collapsed = !settings.nodes[nodeType].collapsed; + updateSettings({ nodes: settings.nodes }); }; return ( <div> - {graphMetadata.nodes.labels.map((nodeType) => ( - <div className="mt-2" key={nodeType}> - <div className="flex items-center"> - <div className="w-3/4 mr-6 cursor-pointer" onClick={() => handleCollapseToggle(nodeType)}> - <EntityPill title={nodeType} /> - </div> - <div className="w-1/2"> - <ColorPicker - value={settings?.[nodeType]?.['color'] ? settings?.[nodeType]?.['color'] : [0, 0, 0]} - updateValue={(val: number[]) => updateSettings({ [nodeType]: { ...settings?.[nodeType], color: val } })} - /> - </div> - </div> - - {!collapsed[nodeType] && ( - <div> - <Input - inline - label="Longitude" - type="dropdown" - value={settings?.[nodeType]?.lon} - options={[...spatialAttributes[nodeType]]} - disabled={!settings.node || spatialAttributes[nodeType].length < 1} - onChange={(val) => updateSettings({ [nodeType]: { ...settings?.[nodeType], lon: val as string } })} - /> - - <Input - inline - label="Latitude" - type="dropdown" - value={settings?.[nodeType]?.lat} - options={[...spatialAttributes[nodeType]]} - disabled={!settings.node || spatialAttributes[nodeType].length < 1} - onChange={(val) => updateSettings({ [nodeType]: { ...settings?.[nodeType], lat: val as string } })} - /> - - <div className="ml-2"> - <div className="flex items-center gap-1"> - <Icon component="icon-[ic--baseline-subdirectory-arrow-right]" size={16} color="text-secondary-300" /> - <Input - label="Hidden" - type="boolean" - value={settings?.[nodeType]?.hidden ?? false} - onChange={(val: boolean) => updateSettings({ [nodeType]: { ...settings?.[nodeType], hidden: val } })} + {settings?.nodes && + Object.keys(settings.nodes).map((nodeType) => { + const nodeSettings = settings.nodes[nodeType]; + return ( + <div className="mt-2" key={nodeType}> + <div className="flex items-center"> + <div className="w-3/4 mr-6 cursor-pointer" onClick={() => handleCollapseToggle(nodeType)}> + <EntityPill title={nodeType} /> + </div> + <div className="w-1/2"> + <ColorPicker + value={nodeSettings.color} + updateValue={(val) => { + updateSettings({ nodes: { ...settings.nodes, [nodeType]: { ...nodeSettings, color: val } } }); + }} /> </div> + </div> + {!nodeSettings.collapsed && ( <div> - <div className="flex items-center gap-1"> - <Icon component="icon-[ic--baseline-subdirectory-arrow-right]" size={16} color="text-secondary-300" /> - <span>Radius</span> - </div> <Input - label="Fixed" - type="boolean" - value={settings?.[nodeType]?.fixed ?? false} - onChange={(val: boolean) => updateSettings({ [nodeType]: { ...settings?.[nodeType], fixed: val } })} + inline + label="Latitude" + type="dropdown" + value={nodeSettings.lat} + options={[...(spatialAttributes[nodeType] || [])]} + disabled={!settings.node || spatialAttributes[nodeType].length < 1} + onChange={(val) => { + updateSettings({ nodes: { ...settings.nodes, [nodeType]: { ...nodeSettings, lat: String(val) } } }); + }} /> - {!settings?.[nodeType]?.fixed ? ( - <div> + <Input + inline + label="Longitude" + type="dropdown" + value={nodeSettings.lon} + options={[...(spatialAttributes[nodeType] || [])]} + disabled={!settings.node || spatialAttributes[nodeType].length < 1} + onChange={(val) => { + updateSettings({ nodes: { ...settings.nodes, [nodeType]: { ...nodeSettings, lon: String(val) } } }); + }} + /> + + <div className="ml-2"> + <div className="flex items-center gap-1"> + <Icon component="icon-[ic--baseline-subdirectory-arrow-right]" size={16} color="text-secondary-300" /> + <Input - label="Based on" - type="dropdown" - size="xs" - options={Object.keys(graphMetadata.nodes.types[nodeType].attributes).filter( - (key) => graphMetadata.nodes.types[nodeType].attributes[key].dimension === 'numerical', - )} - value={settings?.[nodeType]?.sizeAttribute ?? ''} - onChange={(val: string | number) => updateSettings({ [nodeType]: { ...settings?.[nodeType], sizeAttribute: val } })} + label="Hidden" + type="boolean" + value={nodeSettings.hidden} + onChange={(val) => { + updateSettings({ nodes: { ...settings.nodes, [nodeType]: { ...nodeSettings, hidden: val } } }); + }} /> - <div className="flex"> - <Input - type="number" - label="min" - size="xs" - value={settings?.[nodeType]?.min ?? 0} - onChange={(val: number) => updateSettings({ [nodeType]: { ...settings?.[nodeType], min: val } })} - /> - <Input - type="number" - label="max" - size="xs" - value={settings?.[nodeType]?.max ?? 10} - onChange={(val: number) => updateSettings({ [nodeType]: { ...settings?.[nodeType], max: val } })} - /> - </div> </div> - ) : ( + <div> + <div className="flex items-center gap-1"> + <Icon component="icon-[ic--baseline-subdirectory-arrow-right]" size={16} color="text-secondary-300" /> + <span>Radius</span> + </div> <Input - type="slider" - label="Width" - min={0} - max={10} - step={0.5} - value={settings?.[nodeType]?.radius ?? 1} - onChange={(val: number) => updateSettings({ [nodeType]: { ...settings?.[nodeType], radius: val } })} + label="Fixed" + type="boolean" + value={nodeSettings.fixed} + onChange={(val) => { + updateSettings({ nodes: { ...settings.nodes, [nodeType]: { ...nodeSettings, fixed: val } } }); + }} /> + {!settings?.[nodeType]?.fixed ? ( + <div> + <Input + label="Based on" + type="dropdown" + size="xs" + options={spatialAttributes[nodeType]} + value={nodeSettings.sizeAttribute} + onChange={(val) => { + updateSettings({ nodes: { ...settings.nodes, [nodeType]: { ...nodeSettings, sizeAttribute: String(val) } } }); + }} + /> + <div className="flex"> + <Input + type="number" + label="min" + size="xs" + value={nodeSettings.min} + onChange={(val) => { + updateSettings({ nodes: { ...settings.nodes, [nodeType]: { ...nodeSettings, min: Number(val) } } }); + }} + /> + <Input + type="number" + label="max" + size="xs" + value={nodeSettings.max} + onChange={(val) => { + updateSettings({ nodes: { ...settings.nodes, [nodeType]: { ...nodeSettings, max: Number(val) } } }); + }} + /> + </div> + </div> + ) : ( + <div> + <Input + type="slider" + label="Width" + min={0} + max={10} + step={0.5} + value={nodeSettings.radius} + onChange={(val) => { + updateSettings({ nodes: { ...settings.nodes, [nodeType]: { ...nodeSettings, radius: Number(val) } } }); + }} + /> + </div> + )} </div> - )} + </div> </div> - </div> + )} </div> - )} - </div> - ))} + ); + })} </div> ); } diff --git a/libs/shared/lib/vis/visualizations/mapvis/layers/nodelink-layer/NodeLinkLayer.tsx b/libs/shared/lib/vis/visualizations/mapvis/layers/nodelink-layer/NodeLinkLayer.tsx index bb7e24aca..56004039f 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/layers/nodelink-layer/NodeLinkLayer.tsx +++ b/libs/shared/lib/vis/visualizations/mapvis/layers/nodelink-layer/NodeLinkLayer.tsx @@ -1,103 +1,89 @@ import React from 'react'; -import { CompositeLayer } from 'deck.gl'; +import { CompositeLayer, Layer } from 'deck.gl'; import { IconLayer, LineLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers'; -import { LayerProps } from '../../mapvis.types'; +import { CompositeLayerType, LayerProps } from '../../mapvis.types'; import { BrushingExtension, CollisionFilterExtension } from '@deck.gl/extensions'; import { Edge, Node } from '@graphpolaris/shared/lib/data-access'; import { createIcon } from './shapeFactory'; -export class NodeLinkLayer extends CompositeLayer<LayerProps> { +export class NodeLinkLayer extends CompositeLayer<CompositeLayerType> { static type = 'NodeLink'; + private _layers: Record<string, Layer> = {}; + + constructor(props: LayerProps) { + super(props); + } updateState({ changeFlags }: { changeFlags: any }) { return changeFlags.propsOrDataChanged || changeFlags.somethingChanged; } renderLayers() { - const { graph, config, getNodeLocation, selected, setLayerIds, metaData } = this.props; - - const layers = []; - const layerIds = []; + const { data, settings, getNodeLocation, selected, setLayerIds, graphMetadata } = this.props; const brushingExtension = new BrushingExtension(); const collisionFilter = new CollisionFilterExtension(); - metaData.nodes.labels.forEach((label: string) => { - const layerId = `${label}-nodes-scatterplot`; - layerIds.push(layerId); - - layers.push( - new IconLayer({ - id: layerId, - visible: !config[label].hidden, - data: graph.nodes.filter((node: Node) => node.label === label), - pickable: true, - getColor: (d) => [200, 140, 0], - getSize: (d: any) => config[label].size, - getPosition: (d: any) => getNodeLocation(d._id), - getIcon: (d: any) => { - return { - url: createIcon(config[label].shape ?? 'circle', config[label].color), - width: 24, - height: 24, - }; - }, - mask: true, - }), - ); - }); - - metaData.edges.labels.forEach((label: string) => { + graphMetadata.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; + selected.length > 0 ? data.edges.filter((edge: Edge) => selected.includes(edge.from) || selected.includes(edge.to)) : data.edges; - layers.push( - new LineLayer({ - id: layerId, - data: edgeData, - visible: !config[label]?.hidden, - pickable: true, - getWidth: (d: any) => config[label].width, - getSourcePosition: (d: any) => getNodeLocation(d.from), - getTargetPosition: (d: any) => getNodeLocation(d.to), - getColor: (d: any) => config[d.label].color, - radiusScale: 3000, - brushingEnabled: config.enableBrushing, - extensions: [brushingExtension], - }), - ); + this._layers[layerId] = new LineLayer<Edge>({ + id: layerId, + data: edgeData, + visible: !settings.edges[label].hidden, + pickable: true, + getWidth: settings.edges[label].width, + getSourcePosition: (d) => getNodeLocation(d.from), + getTargetPosition: (d) => getNodeLocation(d.to), + getColor: (d) => settings.edges[d.label].color, + extensions: [brushingExtension], + }); }); + graphMetadata.nodes.labels.forEach((label: string) => { + const layerId = `${label}-nodes-scatterplot`; - const textLayerId = 'label-target'; - layerIds.push(textLayerId); + this._layers[layerId] = new IconLayer<Node>({ + id: layerId, + visible: !settings.nodes[label].hidden, + data: data.nodes.filter((node: Node) => node.label === label), + pickable: true, + getColor: (d) => [200, 140, 0], + getSize: (d) => settings.nodes[label].size, + getPosition: (d) => getNodeLocation(d._id), + getIcon: (d: any) => { + return { + url: createIcon(settings.nodes[label].shape, settings.nodes[label].color), + width: 24, + height: 24, + }; + }, + }); + }); - layers.push( - new TextLayer({ - id: textLayerId, - data: graph.nodes, - getPosition: (d: any) => getNodeLocation(d._id), - getText: (d: any) => d.id, - getSize: 15, - visible: true, - getAlignmentBaseline: 'top', - background: true, - getPixelOffset: [10, 10], - extensions: [collisionFilter], - collisionEnabled: true, - getCollisionPriority: (d: any) => d.id, - collisionTestProps: { sizeScale: 10 }, - getRadius: 10, - radiusUnits: 'pixels', - collisionGroup: 'text', - }), - ); + const textLayerId = 'label-target'; - setLayerIds(layerIds); + this._layers[textLayerId] = new TextLayer({ + id: textLayerId, + data: data.nodes, + getPosition: (d: any) => getNodeLocation(d._id), + getText: (d: any) => d.id, + getSize: 15, + visible: true, + getAlignmentBaseline: 'top', + background: true, + getPixelOffset: [10, 10], + extensions: [collisionFilter], + collisionEnabled: true, + getCollisionPriority: (d: any) => d.id, + collisionTestProps: { sizeScale: 10 }, + getRadius: 10, + radiusUnits: 'pixels', + collisionGroup: 'text', + }); - return layers; + return Object.values(this._layers); } } diff --git a/libs/shared/lib/vis/visualizations/mapvis/layers/nodelink-layer/NodeLinkOptions.tsx b/libs/shared/lib/vis/visualizations/mapvis/layers/nodelink-layer/NodeLinkOptions.tsx index 1de263ca5..6c530cf37 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/layers/nodelink-layer/NodeLinkOptions.tsx +++ b/libs/shared/lib/vis/visualizations/mapvis/layers/nodelink-layer/NodeLinkOptions.tsx @@ -1,222 +1,206 @@ import React, { useState, useEffect } from 'react'; import ColorPicker from '@graphpolaris/shared/lib/components/colorComponents/colorPicker'; -import { VisualizationSettingsPropTypes } from '@graphpolaris/shared/lib/vis/common'; -import { MapProps } from '../../mapvis'; import { Button, EntityPill, Icon, Input, RelationPill } from '@graphpolaris/shared/lib/components'; +import { MapLayerSettingsPropTypes } from '..'; -export default function NodeLinkOptions({ settings, graphMetadata, updateSettings }: VisualizationSettingsPropTypes<MapProps>) { - const [collapsed, setCollapsed] = useState<Record<string, boolean>>({}); +export function NodeLinkOptions({ settings, graphMetadata, updateSettings, spatialAttributes }: MapLayerSettingsPropTypes) { + const handleCollapseToggle = (nodeType: string) => { + settings.nodes[nodeType].collapsed = !settings.nodes[nodeType].collapsed; + updateSettings({ nodes: settings.nodes }); + }; useEffect(() => { - graphMetadata.nodes.labels.forEach((node) => { - updateSettings({ - [node]: { - color: [0, 0, 0], - hidden: false, - shape: '', - size: 10, - fixed: true, - min: 0, - max: 10, - sizeAttribute: '', - lon: '', - lat: '', - ...settings?.[node], - }, - }); - }); - - graphMetadata.edges.labels.forEach((edge) => { - updateSettings({ - [edge]: { - color: [0, 0, 0], - hidden: false, - fixed: true, - min: 0, - max: 10, - width: 1, - widthAttribute: '', - ...settings?.[edge], - }, - }); + graphMetadata.nodes.labels.map((nodeType) => { + if (settings?.[nodeType]?.lat) { + } }); - }, [graphMetadata]); - - const spatialAttributes: { [id: string]: string[] } = {}; - graphMetadata.nodes.labels.forEach((node) => { - spatialAttributes[node] = Object.entries(graphMetadata.nodes.types[node].attributes) - .filter(([, value]) => value.dimension === 'numerical') - .map(([key]) => key); - }); - - const handleCollapseToggle = (nodeType: string) => { - setCollapsed((prevCollapsed) => ({ - ...prevCollapsed, - [nodeType]: !prevCollapsed[nodeType], - })); - }; + }, [settings.node, graphMetadata]); return ( <div> - {graphMetadata.nodes.labels.map((nodeType) => ( - <div className="mt-2" key={nodeType}> - <div className="flex items-center"> - <div className="flex flex-grow mr-2 cursor-pointer" onClick={() => handleCollapseToggle(nodeType)}> - <EntityPill title={nodeType} /> - </div> - <div className="flex items-center space-x-2"> - <ColorPicker - value={settings?.[nodeType]?.['color'] ? settings?.[nodeType]?.['color'] : [0, 0, 0]} - updateValue={(val: number[]) => updateSettings({ [nodeType]: { ...settings?.[nodeType], color: val } })} - /> - <Button - iconComponent={settings?.[nodeType]?.hidden ? 'icon-[ic--baseline-visibility-off]' : 'icon-[ic--baseline-visibility]'} - variant="ghost" - onClick={() => { - updateSettings({ [nodeType]: { ...settings?.[nodeType], hidden: !settings?.[nodeType]?.hidden as boolean } }); - }} - /> - </div> - </div> - - {!collapsed[nodeType] && ( - <div> - <Input - inline - label="Longitude" - type="dropdown" - value={settings?.[nodeType]?.lon} - options={[...spatialAttributes[nodeType]]} - disabled={!settings.node || spatialAttributes[nodeType].length < 1} - onChange={(val) => updateSettings({ [nodeType]: { ...settings?.[nodeType], lon: val as string } })} - /> - - <Input - inline - label="Latitude" - type="dropdown" - value={settings?.[nodeType]?.lat} - options={[...spatialAttributes[nodeType]]} - disabled={!settings.node || spatialAttributes[nodeType].length < 1} - onChange={(val) => updateSettings({ [nodeType]: { ...settings?.[nodeType], lat: val as string } })} - /> - <Input - inline - label="Shape" - type="dropdown" - value={settings?.[nodeType]?.shape} - options={['circle', 'square', 'triangle', 'diamond', 'location', 'star']} - disabled={!settings.shape} - onChange={(val) => updateSettings({ [nodeType]: { ...settings?.[nodeType], shape: val as string } })} - /> - <Input - label="Size" - type="slider" - min={0} - max={20} - step={1} - value={settings?.[nodeType]?.size} - onChange={(val) => updateSettings({ [nodeType]: { ...settings?.[nodeType], size: val as number } })} - /> - </div> - )} - </div> - ))} + {settings?.nodes && + Object.keys(settings.nodes).map((nodeType) => { + const nodeSettings = settings.nodes[nodeType]; + return ( + <div className="mt-2" key={nodeType}> + <div className="flex items-center"> + <div className="flex flex-grow mr-2 cursor-pointer" onClick={() => handleCollapseToggle(nodeType)}> + <EntityPill title={nodeType} /> + </div> + <div className="flex items-center space-x-2"> + <ColorPicker + value={nodeSettings.color} + updateValue={(val) => { + updateSettings({ nodes: { ...settings.nodes, [nodeType]: { ...nodeSettings, color: val } } }); + }} + /> + <Button + iconComponent={nodeSettings.hidden ? 'icon-[ic--baseline-visibility-off]' : 'icon-[ic--baseline-visibility]'} + variant="ghost" + onClick={() => { + updateSettings({ nodes: { ...settings.nodes, [nodeType]: { ...nodeSettings, hidden: !nodeSettings.hidden } } }); + }} + /> + </div> + </div> - {graphMetadata.edges.labels.map((edgeType) => ( - <div className="mt-2" key={edgeType}> - <div className="flex items-center"> - <div className="w-3/4 mr-6 cursor-pointer" onClick={() => handleCollapseToggle(edgeType)}> - <RelationPill title={edgeType} /> - </div> - <div className="w-1/2 flex"> - <ColorPicker - value={settings?.[edgeType]?.['color'] ? settings?.[edgeType]?.['color'] : [0, 0, 0]} - updateValue={(val: number[]) => updateSettings({ [edgeType]: { ...settings?.[edgeType], color: val } })} - /> - <Button - iconComponent={settings?.[edgeType]?.hidden ? 'icon-[ic--baseline-visibility-off]' : 'icon-[ic--baseline-visibility]'} - variant="ghost" - onClick={() => { - updateSettings({ [edgeType]: { ...settings?.[edgeType], hidden: !settings?.[edgeType]?.hidden as boolean } }); - }} - /> + {!nodeSettings.collapsed && ( + <div> + <Input + inline + label="Latitude" + type="dropdown" + value={nodeSettings.lat} + options={[...(spatialAttributes[nodeType] || [])]} + disabled={!settings.node || spatialAttributes[nodeType].length < 1} + onChange={(val) => { + updateSettings({ nodes: { ...settings.nodes, [nodeType]: { ...nodeSettings, lat: String(val) } } }); + }} + /> + <Input + inline + label="Longitude" + type="dropdown" + value={nodeSettings.lon} + options={[...(spatialAttributes[nodeType] || [])]} + disabled={!settings.node || spatialAttributes[nodeType].length < 1} + onChange={(val) => { + updateSettings({ nodes: { ...settings.nodes, [nodeType]: { ...nodeSettings, lon: String(val) } } }); + }} + /> + <Input + inline + label="Shape" + type="dropdown" + value={nodeSettings.shape} + options={['circle', 'square', 'triangle', 'diamond', 'location', 'star']} + disabled={!settings.shape} + onChange={(val) => { + updateSettings({ nodes: { ...settings.nodes, [nodeType]: { ...nodeSettings, shape: String(val) as any } } }); + }} + /> + <Input + label="Size" + type="slider" + min={0} + max={40} + step={1} + value={nodeSettings.size} + onChange={(val) => { + updateSettings({ nodes: { ...settings.nodes, [nodeType]: { ...nodeSettings, size: Number(val) } } }); + }} + /> + </div> + )} </div> - </div> + ); + })} - {!collapsed[edgeType] && ( - <div> - <Input - label="Enable brushing" - type="boolean" - value={settings.enableBrushing} - onChange={(val) => { - updateSettings({ enableBrushing: val as boolean }); - }} - /> + {settings?.edges && + Object.keys(settings.edges).map((edgeType) => { + const edgeSettings = settings.edges[edgeType]; - <div> - <div className="flex items-center gap-1"> - <Icon component="icon-[ic--baseline-subdirectory-arrow-right]" size={16} color="text-secondary-300" /> - <span>Width</span> + return ( + <div className="mt-2" key={edgeType}> + <div className="flex items-center"> + <div className="w-3/4 mr-6 cursor-pointer" onClick={() => handleCollapseToggle(edgeType)}> + <RelationPill title={edgeType} /> </div> - <Input - label="Fixed" - type="boolean" - value={settings?.[edgeType]?.fixed ?? false} - onChange={(val: boolean) => updateSettings({ [edgeType]: { ...settings?.[edgeType], fixed: val } })} - /> - {!settings?.[edgeType]?.fixed ? ( + <div className="w-1/2 flex"> + <ColorPicker + value={settings.edges[edgeType].color} + updateValue={(val) => updateSettings({ edges: { ...settings.edges, [edgeType]: { ...edgeSettings, color: val } } })} + /> + <Button + iconComponent={ + settings.edges[edgeType].hidden ? 'icon-[ic--baseline-visibility-off]' : 'icon-[ic--baseline-visibility]' + } + variant="ghost" + onClick={() => + updateSettings({ edges: { ...settings.edges, [edgeType]: { ...edgeSettings, hidden: !edgeSettings.hidden } } }) + } + /> + </div> + </div> + + {!edgeSettings.collapsed && ( + <div> + <Input + label="Enable brushing" + type="boolean" + value={settings.enableBrushing} + onChange={(val) => { + updateSettings({ enableBrushing: val as boolean }); + }} + /> + <div> - <Input - label="Based on" - type="dropdown" - size="xs" - options={ - graphMetadata.nodes.types[edgeType]?.attributes - ? Object.keys(graphMetadata.nodes.types[edgeType].attributes).filter( - (key) => graphMetadata.nodes.types[edgeType].attributes[key].dimension === 'numerical', - ) - : [] - } - value={settings?.[edgeType]?.sizeAttribute ?? ''} - onChange={(val: string | number) => updateSettings({ [edgeType]: { ...settings?.[edgeType], sizeAttribute: val } })} - /> - <div className="flex"> - <Input - type="number" - label="min" - size="xs" - value={settings?.[edgeType]?.min ?? 0} - onChange={(val: number) => updateSettings({ [edgeType]: { ...settings?.[edgeType], min: val } })} - /> - <Input - type="number" - label="max" - size="xs" - value={settings?.[edgeType]?.max ?? 10} - onChange={(val: number) => updateSettings({ [edgeType]: { ...settings?.[edgeType], max: val } })} - /> + <div className="flex items-center gap-1"> + <Icon component="icon-[ic--baseline-subdirectory-arrow-right]" size={16} color="text-secondary-300" /> + <span>Width</span> </div> - </div> - ) : ( - <div> <Input - type="slider" - label="Width" - min={0} - max={10} - step={0.5} - value={settings?.[edgeType]?.width ?? 1} - onChange={(val: number) => updateSettings({ [edgeType]: { ...settings?.[edgeType], width: val } })} + label="Fixed" + type="boolean" + value={edgeSettings.fixed} + onChange={(val) => updateSettings({ edges: { ...settings.edges, [edgeType]: { ...edgeSettings, fixed: val } } })} /> + {!edgeSettings.fixed ? ( + <div> + <Input + label="Based on" + type="dropdown" + size="xs" + options={ + graphMetadata.nodes.types[edgeType]?.attributes + ? Object.keys(graphMetadata.nodes.types[edgeType].attributes).filter( + (key) => graphMetadata.nodes.types[edgeType].attributes[key].dimension === 'numerical', + ) + : [] + } + value={edgeSettings.sizeAttribute ?? ''} + onChange={(val) => + updateSettings({ edges: { ...settings.edges, [edgeType]: { ...edgeSettings, sizeAttribute: String(val) } } }) + } + /> + <div className="flex"> + <Input + type="number" + label="min" + size="xs" + value={edgeSettings.min} + onChange={(val) => updateSettings({ edges: { ...settings.edges, [edgeType]: { ...edgeSettings, min: val } } })} + /> + <Input + type="number" + label="max" + size="xs" + value={edgeSettings.max} + onChange={(val) => updateSettings({ edges: { ...settings.edges, [edgeType]: { ...edgeSettings, max: val } } })} + /> + </div> + </div> + ) : ( + <div> + <Input + type="slider" + label="Width" + min={0} + max={10} + step={0.2} + value={settings.edges[edgeType].width} + onChange={(val) => + updateSettings({ edges: { ...settings.edges, [edgeType]: { ...edgeSettings, width: Number(val) } } }) + } + /> + </div> + )} </div> - )} - </div> + </div> + )} </div> - )} - </div> - ))} + ); + })} </div> ); } diff --git a/libs/shared/lib/vis/visualizations/mapvis/mapvis.tsx b/libs/shared/lib/vis/visualizations/mapvis/mapvis.tsx index e6d90b7e2..43a4646a6 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/mapvis.tsx +++ b/libs/shared/lib/vis/visualizations/mapvis/mapvis.tsx @@ -1,18 +1,53 @@ -import React, { useEffect, useMemo, useCallback, useState } from 'react'; +import React, { useEffect, useMemo, useCallback, useState, useRef } from 'react'; import DeckGL from '@deck.gl/react'; -import { FlyToInterpolator, WebMercatorViewport } from '@deck.gl/core'; +import { CompositeLayer, FlyToInterpolator, Position, WebMercatorViewport } from '@deck.gl/core'; import { SelectionLayer } from '@deck.gl-community/editable-layers'; -import { Coordinate, Layer } from './mapvis.types'; +import { CompositeLayerType, Coordinate, Layer } from './mapvis.types'; import { VISComponentType, VisualizationPropTypes } from '../../common'; -import { layerTypes, createBaseMap } from './layers'; -import { MapSettings } from './settings'; +import { layerTypes, createBaseMap, MapLayerSettingsPropTypes } from './layers'; +import { MapSettings } from './MapSettings'; import { Node } from '@graphpolaris/shared/lib/data-access/store/graphQueryResultSlice'; -import SearchBar from './search'; +import { SearchBar } from './SearchBar'; import { Icon } from '@graphpolaris/shared/lib/components'; -export type MapProps = { layer: string; enableBrushing: boolean }; +export type MapNodeOrEdgeData = MapNodeData | MapEdgeData; + +export type MapNodeData = { + color: [number, number, number]; + hidden: boolean; + fixed: boolean; + min: number; + max: number; + radius: number; + collapsed: boolean; + lat?: string; + lon?: string; + shape: 'circle' | 'square' | 'triangle' | 'diamond' | 'location' | 'star'; + size: number; + sizeAttribute?: string; +}; + +export type MapEdgeData = { + color: [number, number, number]; + hidden: boolean; + fixed: boolean; + min: number; + max: number; + radius: number; + collapsed: boolean; + size: number; + width: number; + sizeAttribute?: string; +}; + +export type MapProps = { + layer: string; + enableBrushing: boolean; + nodes: Record<string, MapNodeData>; + edges: Record<string, MapEdgeData>; +}; -const settings: MapProps = { layer: 'nodelink', enableBrushing: false }; +const settings: MapProps = { layer: 'nodelink', enableBrushing: false, nodes: {}, edges: {} }; const INITIAL_VIEW_STATE = { latitude: 52.1006, @@ -24,10 +59,8 @@ const INITIAL_VIEW_STATE = { const FLY_SPEED = 1000; -const baseLayer = createBaseMap(); - -export const MapVis = ({ data, settings, handleSelect, graphMetadata }: VisualizationPropTypes<MapProps>) => { - const [layer, setLayer] = useState<Layer | undefined>(undefined); +export const MapVis = (props: VisualizationPropTypes<MapProps>) => { + const baseLayer = useRef(createBaseMap()); const [viewport, setViewport] = useState<Record<string, any>>(INITIAL_VIEW_STATE); const [hoverObject, setHoverObject] = useState<Node | null>(null); const [selected, setSelected] = useState<any[]>([]); @@ -64,20 +97,28 @@ export const MapVis = ({ data, settings, handleSelect, graphMetadata }: Visualiz [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 [dataLayer, setDataLayer] = useState<{ component: CompositeLayer<CompositeLayerType>; id: string } | null>(null); - const coordinateLookup: { [id: string]: Coordinate } = data.nodes.reduce( + useEffect(() => { + if (!props.settings.layer) { + setDataLayer(null); + return; + } + + const layer = { + component: props.settings.layer ? layerTypes?.[props.settings.layer] : layerTypes.nodelink, + settings: props.settings, + id: props.settings.layer, + }; + + const coordinateLookup: { [id: string]: Position } = props.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; + const latitude = props.settings.nodes?.[node.label]?.lat + ? (node?.attributes?.[props.settings.nodes[node.label].lat as any] as string) + : undefined; + const longitude = props.settings.nodes?.[node.label]?.lon + ? (node?.attributes?.[props.settings.nodes[node.label].lon as any] as string) + : undefined; if (latitude !== undefined && longitude !== undefined) { acc[node._id] = [parseFloat(longitude), parseFloat(latitude)]; @@ -85,20 +126,26 @@ export const MapVis = ({ data, settings, handleSelect, graphMetadata }: Visualiz return acc; }, - {} as { [id: string]: Coordinate }, + {} as { [id: string]: Position }, ); - return new layer.type({ - graph: data, - metaData: graphMetadata, - config: settings, + const layerProps: CompositeLayerType = { + ...props, selected: selected, hoverObject: hoverObject, getNodeLocation: (d: string) => coordinateLookup[d], flyToBoundingBox: flyToBoundingBox, setLayerIds: (val: string[]) => setLayerIds(val), - }); - }, [layer, data, selected, hoverObject, settings]); + }; + + if (dataLayer && dataLayer.id === layer.id) { + // dataLayer.updateState; + setDataLayer({ component: dataLayer.component.clone(layerProps), id: props.settings.layer }); + } else { + // @ts-ignore + setDataLayer({ component: new layer.component(layerProps), id: props.settings.layer }); + } + }, [props.settings.layer, props.data, selected, hoverObject, props.settings]); const selectionLayer = useMemo( () => @@ -122,16 +169,16 @@ export const MapVis = ({ data, settings, handleSelect, graphMetadata }: Visualiz } } setSelected(nodes.map((node) => node._id)); - handleSelect({ nodes, edges }); + props.handleSelect({ nodes, edges }); } else { - handleSelect(); + props.handleSelect(); } setSelectingRectangle(false); }, layerIds: layerIds, getTentativeFillColor: () => [22, 37, 67, 100], }), - [selectingRectangle, layer], + [selectingRectangle], ); return ( @@ -146,35 +193,35 @@ export const MapVis = ({ data, settings, handleSelect, graphMetadata }: Visualiz </div> {isSearching && ( <SearchBar - onSearch={(boundingbox: [number, number, number, number]) => { - flyToBoundingBox(...boundingbox); + onSearch={(boundingBox: [number, number, number, number]) => { + flyToBoundingBox(...boundingBox); setIsSearching(false); }} /> )} <DeckGL - layers={[baseLayer, dataLayer, selectionLayer]} + layers={[baseLayer.current, dataLayer?.component, selectionLayer]} controller={true} initialViewState={viewport} onViewStateChange={({ viewState }) => setViewport(viewState)} onClick={({ object }) => { - if (data) { + if (props.data) { if (!object) { - handleSelect(); + props.handleSelect(); setSelected([]); return; } if (object.hasOwnProperty('attributes') && object.hasOwnProperty('id') && object.hasOwnProperty('label')) { - handleSelect({ nodes: [object] }); + props.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] }); + const nodes = props.data.nodes.filter((node) => ids.includes((node as unknown as { id: string }).id)); + props.handleSelect({ nodes: [...nodes] }); } else { - handleSelect(); + props.handleSelect(); setSelected([]); return; } diff --git a/libs/shared/lib/vis/visualizations/mapvis/mapvis.types.ts b/libs/shared/lib/vis/visualizations/mapvis/mapvis.types.ts index 8293fbbfc..3dcad5c67 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/mapvis.types.ts +++ b/libs/shared/lib/vis/visualizations/mapvis/mapvis.types.ts @@ -1,12 +1,27 @@ +import { CompositeLayer, Position } from 'deck.gl'; +import { MapLayerSettingsPropTypes } from './layers'; +import { VisualizationPropTypes, VisualizationSettingsType } from '../../common'; +import { MapProps } from './mapvis'; +import { Node as QueryNode } from '@graphpolaris/shared/lib/data-access/store/graphQueryResultSlice'; + export type Coordinate = [number, number] | []; export interface LayerProps { [key: string]: any; } +export type CompositeLayerType = VisualizationPropTypes<MapProps> & { + selected: any[]; + hoverObject: QueryNode | null; + getNodeLocation: (d: string) => Position; + flyToBoundingBox: (minLat: number, maxLat: number, minLon: number, maxLon: number, options?: { [key: string]: any }) => void; + setLayerIds: (val: string[]) => void; +}; + export type Layer = { - type: any; - config: any; + id: string; + component: CompositeLayer<CompositeLayerType>; + settings: MapProps & VisualizationSettingsType; }; export type Node = { diff --git a/libs/shared/lib/vis/visualizations/mapvis/settings.tsx b/libs/shared/lib/vis/visualizations/mapvis/settings.tsx deleted file mode 100644 index 0d893868b..000000000 --- a/libs/shared/lib/vis/visualizations/mapvis/settings.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import { SettingsContainer } from '../../components/config'; -import { layerSettings, layerTypes } from './layers'; -import { Input } from '../../..'; -import { VisualizationSettingsPropTypes } from '../../common'; -import { MapProps } from './mapvis'; - -export const MapSettings = ({ settings, graphMetadata, updateSettings }: VisualizationSettingsPropTypes<MapProps>) => { - const DataLayerSettings = settings.layer && layerSettings?.[settings.layer]; - - return ( - <SettingsContainer> - <Input - label="Data layer" - type="dropdown" - inline - value={settings.layer} - options={Object.keys(layerTypes)} - onChange={(val) => updateSettings({ layer: val as string })} - /> - {DataLayerSettings && <DataLayerSettings settings={settings} graphMetadata={graphMetadata} updateSettings={updateSettings} />} - </SettingsContainer> - ); -}; -- GitLab