diff --git a/libs/shared/lib/components/colorComponents/colorDropdown/index.tsx b/libs/shared/lib/components/colorComponents/colorDropdown/index.tsx index 4dcea6e51a452087b9a09b1b86ef08c9506c5d09..f16a9a350127f4b81f6cbdd63d827ffecf8a31d5 100644 --- a/libs/shared/lib/components/colorComponents/colorDropdown/index.tsx +++ b/libs/shared/lib/components/colorComponents/colorDropdown/index.tsx @@ -1,7 +1,6 @@ import React, { useState } from 'react'; import { DropdownTrigger, DropdownContainer, DropdownItemContainer } from '@graphpolaris/shared/lib/components/dropdowns'; import ColorLegend from '../colorLegend/index.js'; -import { DimensionType } from '@graphpolaris/shared/lib/schema/index.js'; import { dataColors } from 'config'; type TailwindColor = { @@ -37,25 +36,21 @@ function generateTailwindColors(dataColors: any) { type DropdownColorLegendProps = { value: any; onChange: (val: any) => void; - dimension?: DimensionType; - distribution?: any; }; -export const DropdownColorLegend = ({ value, onChange, dimension, distribution }: DropdownColorLegendProps) => { +export const DropdownColorLegend = ({ value, onChange }: DropdownColorLegendProps) => { const colorStructure = generateTailwindColors(dataColors); - const [selectedColorLegend, setSelectedColorLegend] = useState<any>(null); - + const [selectedColorLegend, setSelectedColorLegend] = useState<TailwindColor | null>(null); const [menuOpen, setMenuOpen] = useState<boolean>(false); - const [selectedOption, setSelectedOption] = useState<any>('Select colormap'); const handleOptionClick = (option: string) => { - setSelectedOption(option); + onChange(option); setSelectedColorLegend(colorStructure[option]); setMenuOpen(false); }; return ( - <div className="w-200 h-200"> + <div className="w-200 h-200 relative"> <DropdownContainer> <DropdownTrigger title={ @@ -68,24 +63,29 @@ export const DropdownColorLegend = ({ value, onChange, dimension, distribution } showAxis={selectedColorLegend.showAxis} /> ) : ( - <p className="ml-2">{selectedOption}</p> + <p className="ml-2">{value}</p> )} </div> } + onClick={() => setMenuOpen(!menuOpen)} /> - <DropdownItemContainer className="w-60"> - {Object.keys(colorStructure).map((option: any, index) => ( - <li key={index} onClick={() => handleOptionClick(option)} className="cursor-pointer flex items-center ml-2 h-4 m-2"> - <ColorLegend - key={index.toString() + '_colorLegend'} - colors={colorStructure[option].colors} - data={colorStructure[option].data} - name={colorStructure[option].name} - showAxis={colorStructure[option].showAxis} - /> - </li> - ))} - </DropdownItemContainer> + {menuOpen && ( + <DropdownItemContainer className="absolute w-60 bg-white shadow-lg z-10"> + <ul> + {Object.keys(colorStructure).map((option: string, index) => ( + <li key={index} onClick={() => handleOptionClick(option)} className="cursor-pointer flex items-center ml-2 h-4 m-2"> + <ColorLegend + key={index.toString() + '_colorLegend'} + colors={colorStructure[option].colors} + data={colorStructure[option].data} + name={colorStructure[option].name} + showAxis={colorStructure[option].showAxis} + /> + </li> + ))} + </ul> + </DropdownItemContainer> + )} </DropdownContainer> </div> ); diff --git a/libs/shared/lib/components/colorComponents/colorPicker/index.tsx b/libs/shared/lib/components/colorComponents/colorPicker/index.tsx index bd9a01d16778937875963bab89eb4d10e26ee834..afe153a855b7670ec4acbaa4e05dd21eb1c60f0f 100644 --- a/libs/shared/lib/components/colorComponents/colorPicker/index.tsx +++ b/libs/shared/lib/components/colorComponents/colorPicker/index.tsx @@ -32,7 +32,7 @@ export default function ColorPicker({ value, updateValue }: Props) { <div className="w-5 h-5" style={{ - backgroundColor: `rgb(${value[0]}, ${value[1]}, ${value[2]})`, + backgroundColor: `rgb(${value?.[0] ?? 0}, ${value?.[1] ?? 0}, ${value?.[2] ?? 0})`, }} /> </div> diff --git a/libs/shared/lib/vis/common/types.ts b/libs/shared/lib/vis/common/types.ts index 988ab9acdfa353db11527ce74ac4cc15556ce8f2..a6693a8fbf3dbca0644ada3b2731ecd46a32a998 100644 --- a/libs/shared/lib/vis/common/types.ts +++ b/libs/shared/lib/vis/common/types.ts @@ -1,8 +1,8 @@ +import { FC } from 'react'; import { GraphQueryResult } from '../../data-access/store/graphQueryResultSlice'; import { ML } from '../../data-access/store/mlSlice'; import { SchemaGraph } from '../../schema'; import type { AppDispatch } from '../../data-access'; -import { FC, ReactElement } from 'react'; import { GraphMetadata } from '../../data-access/statistics'; import { Node, Edge } from '../../data-access/store/graphQueryResultSlice'; import { Visualizations } from '../components/VisualizationPanel'; diff --git a/libs/shared/lib/vis/visualizations/mapvis/MapSettings.tsx b/libs/shared/lib/vis/visualizations/mapvis/MapSettings.tsx deleted file mode 100644 index a5682a798b9b00f9a9b39f3cbf7292737411d0e1..0000000000000000000000000000000000000000 --- a/libs/shared/lib/vis/visualizations/mapvis/MapSettings.tsx +++ /dev/null @@ -1,103 +0,0 @@ -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/components/ActionBar.tsx b/libs/shared/lib/vis/visualizations/mapvis/components/ActionBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..41f4e58db0ee063d34b3404ec76669d304cb386b --- /dev/null +++ b/libs/shared/lib/vis/visualizations/mapvis/components/ActionBar.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Icon } from '@graphpolaris/shared/lib/components'; +import { BoundingBoxType } from '../mapvis.types'; +import { SearchBar } from './SearchBar'; + +type Props = { + isSearching: boolean; + setSelectingRectangle: (val: boolean) => void; + setIsSearching: (val: boolean) => void; + flyToBoundingBox: (minLat: number, maxLat: number, minLon: number, maxLon: number) => void; +}; + +export default function ActionBar({ isSearching, setIsSearching, setSelectingRectangle, flyToBoundingBox }: Props) { + return ( + <div> + <div className="absolute left-0 top-0 m-1"> + <div className="cursor-pointer p-1 pb-0 bg-white shadow-md rounded" onClick={() => setSelectingRectangle(true)}> + <Icon component="icon-[ic--baseline-highlight-alt]" /> + </div> + <div className="cursor-pointer p-1 mt-1 pb-0 bg-white shadow-md rounded" onClick={() => setIsSearching(!isSearching)}> + <Icon component="icon-[ic--outline-search]" /> + </div> + </div> + {isSearching && ( + <SearchBar + onSearch={(boundingBox: BoundingBoxType) => { + flyToBoundingBox(...boundingBox); + setIsSearching(false); + }} + /> + )} + </div> + ); +} diff --git a/libs/shared/lib/vis/visualizations/mapvis/components/Attribution.tsx b/libs/shared/lib/vis/visualizations/mapvis/components/Attribution.tsx new file mode 100644 index 0000000000000000000000000000000000000000..34d421dddf67372310c59645da5e42932d9bb85b --- /dev/null +++ b/libs/shared/lib/vis/visualizations/mapvis/components/Attribution.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +export default function Attribution() { + return ( + <div className="absolute right-0 bottom-0 p-1 bg-secondary-200 bg-opacity-75 text-xs"> + {'© '} + <a className="underline" href="http://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer"> + OpenStreetMap + </a> + </div> + ); +} diff --git a/libs/shared/lib/vis/visualizations/mapvis/components/MapSettings.tsx b/libs/shared/lib/vis/visualizations/mapvis/components/MapSettings.tsx new file mode 100644 index 0000000000000000000000000000000000000000..066629a480c33695eac077ffcd53e8c3cdcf5c18 --- /dev/null +++ b/libs/shared/lib/vis/visualizations/mapvis/components/MapSettings.tsx @@ -0,0 +1,97 @@ +import React, { useEffect, useMemo } from 'react'; +import { SettingsContainer } from '../../../components/config'; +import { layerSettings, LayerTypes, layerTypes } from '../layers'; +import { Input } from '../../../..'; +import { VisualizationSettingsPropTypes } from '../../../common'; +import { MapProps } from '../mapvis'; +import { LayerSettingsType } from '../mapvis.types'; + +export const MapSettings = ({ settings, graphMetadata, updateSettings }: VisualizationSettingsPropTypes<MapProps>) => { + // For backwards compatibility with older saveStates, we migrate information from settings.nodes to settings.location + // FIXME: this can be removed once all systems have updated their saveStates. + if (!('location' in settings)) { + settings = JSON.parse(JSON.stringify(settings)); // Undo Object.preventExtensions() + settings.location = Object.entries(settings.nodes) + .map(([k, v]) => [k, { + lat: (v as any).lat, + lon: (v as any).lon + }]) + .reduce((obj, [k, v]) => ({...obj, [k as string]: v}), {}); + } + + const DataLayerSettings = settings.layer && layerSettings?.[settings.layer]; + + const updateLayerSettings = (updatedKeyValue: Partial<LayerSettingsType>) => { + updateSettings({ + ...settings, + [settings.layer]: { + ...settings[settings.layer], + ...updatedKeyValue, + }, + }); + }; + + 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]); + + const updateSpatialAttribute = (label: string, attribute: 'lat' | 'lon', value: string) => { + updateSettings({ + ...settings, + location: { + ...settings.location, + [label]: { + ...settings.location[label], + [attribute]: value, + }, + }, + }); + }; + + useEffect(() => { + // Autodetect a lat or lon attribute if not already set + graphMetadata.nodes.labels.forEach((node) => { + if ((!settings.location[node]?.lat || !settings.location[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({ + location: { + ...settings.location, + [node]: { ...settings.location[node], lat, lon }, + }, + }); + } + } + }); + }, [spatialAttributes, settings, updateSettings]); + + return ( + <SettingsContainer> + <Input + label="Data layer" + type="dropdown" + inline + value={settings.layer} + options={Object.keys(layerTypes)} + onChange={(val) => updateSettings({ layer: val as LayerTypes })} + /> + {DataLayerSettings && !!spatialAttributes && ( + <DataLayerSettings + graphMetadata={graphMetadata} + settings={settings} + updateLayerSettings={updateLayerSettings} + spatialAttributes={spatialAttributes} + updateSpatialAttribute={updateSpatialAttribute} + /> + )} + </SettingsContainer> + ); +}; diff --git a/libs/shared/lib/vis/visualizations/mapvis/SearchBar.tsx b/libs/shared/lib/vis/visualizations/mapvis/components/SearchBar.tsx similarity index 93% rename from libs/shared/lib/vis/visualizations/mapvis/SearchBar.tsx rename to libs/shared/lib/vis/visualizations/mapvis/components/SearchBar.tsx index d6d37127e623dfd4b31ece91e4ea2bd57753ccc1..05626d492f7ea4bf653400a37f0c44dfeaac0da3 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/SearchBar.tsx +++ b/libs/shared/lib/vis/visualizations/mapvis/components/SearchBar.tsx @@ -2,9 +2,10 @@ 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'; +import { BoundingBoxType } from '../mapvis.types'; interface SearchBarProps { - onSearch: (boundingBox: [number, number, number, number]) => void; + onSearch: (boundingBox: BoundingBoxType) => void; } export const SearchBar: React.FC<SearchBarProps> = ({ onSearch }) => { diff --git a/libs/shared/lib/vis/visualizations/mapvis/components/Tooltip.tsx b/libs/shared/lib/vis/visualizations/mapvis/components/Tooltip.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dd1d546bfc32d7fe25876c6f917c8e40e8c56ed9 --- /dev/null +++ b/libs/shared/lib/vis/visualizations/mapvis/components/Tooltip.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@graphpolaris/shared/lib/components'; +import { NodeType } from '../../nodelinkvis/types'; +import { GeoJsonType } from '../mapvis.types'; + +export type NodelinkPopupProps = { + type: 'node' | 'area'; + data: { + node: NodeType | GeoJsonType; + pos: { + x: number; + y: number; + }; + }; + onClose: () => void; +}; + +const isGeoJsonType = (data: NodeType | GeoJsonType): data is GeoJsonType => { + return (data as GeoJsonType).properties !== undefined; +}; + +export const MapTooltip = (props: NodelinkPopupProps) => { + const { type, data } = props; + + const renderNodeDetails = (node: NodeType) => ( + <div> + {node.attributes && + Object.entries(node.attributes).map(([k, v]) => ( + <div key={k} className="flex flex-row gap-3"> + <span>{k}: </span> + <span className="ml-auto max-w-[10rem] text-right truncate"> + <span title={JSON.stringify(v)}>{JSON.stringify(v)}</span> + </span> + </div> + ))} + </div> + ); + + const renderAreaDetails = (geoJson: GeoJsonType) => ( + <div> + <div className="flex flex-row gap-3"> + <span>Area id: </span> + <span className="ml-auto max-w-[10rem] text-right truncate"> + <span>{geoJson.properties?.regioFacetId ?? 'N/A'}</span> + </span> + </div> + <div className="flex flex-row gap-3"> + <span>Nodes in area: </span> + <span className="ml-auto max-w-[10rem] text-right truncate"> + <span>{geoJson.properties?.nodes?.length ?? 0}</span> + </span> + </div> + <div className="flex flex-row gap-3"> + <span>Townships in area: </span> + <span className="ml-auto max-w-[10rem] text-right truncate"> + <span>{geoJson.properties?.townships?.length ?? 0}</span> + </span> + </div> + </div> + ); + + return ( + <div className="text-[0.9rem] min-w-[10rem]"> + <div className="card-body p-0"> + <span className="px-2.5 pt-2"> + <span>{type === 'node' ? 'Node' : 'Area'}</span> + <span className="float-right"> + {type === 'node' ? (data.node as NodeType)?._id : isGeoJsonType(data.node) ? data.node.properties?.name : 'N/A'} + </span> + </span> + <div className="h-[1px] w-full bg-secondary-200"></div> + <div className="px-2.5 text-[0.8rem]"> + {type === 'node' + ? data.node && 'attributes' in data.node + ? renderNodeDetails(data.node as NodeType) + : null + : data.node && isGeoJsonType(data.node) + ? renderAreaDetails(data.node as GeoJsonType) + : null} + </div> + <div className="h-[1px] w-full"></div> + </div> + </div> + ); +}; diff --git a/libs/shared/lib/vis/visualizations/mapvis/hooks/index.ts b/libs/shared/lib/vis/visualizations/mapvis/hooks/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b0c0055061f1ac67f1429eeb5e2549338f37b1ee --- /dev/null +++ b/libs/shared/lib/vis/visualizations/mapvis/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useCoordinateLookup'; +export * from './useSelectionLayer'; diff --git a/libs/shared/lib/vis/visualizations/mapvis/hooks/useCoordinateLookup.tsx b/libs/shared/lib/vis/visualizations/mapvis/hooks/useCoordinateLookup.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b313c97e1298448ebb544ccbdc40001db3fce14e --- /dev/null +++ b/libs/shared/lib/vis/visualizations/mapvis/hooks/useCoordinateLookup.tsx @@ -0,0 +1,25 @@ +import { useMemo } from 'react'; +import { LocationInfo, Coordinate } from '../mapvis.types'; +import { Node } from '@graphpolaris/shared/lib/data-access'; + +export const useCoordinateLookup = (nodes: Node[], locationSettings: Record<string, LocationInfo>) => { + return useMemo(() => { + return nodes.reduce( + (acc, node) => { + const latitude = locationSettings?.[node.label]?.lat + ? (node?.attributes?.[locationSettings[node.label].lat as any] as string) + : undefined; + const longitude = locationSettings?.[node.label]?.lon + ? (node?.attributes?.[locationSettings[node.label].lon as any] as string) + : undefined; + + if (latitude !== undefined && longitude !== undefined) { + acc[node._id] = [parseFloat(longitude), parseFloat(latitude)]; + } + + return acc; + }, + {} as { [id: string]: Coordinate }, + ); + }, [nodes, locationSettings]); +}; diff --git a/libs/shared/lib/vis/visualizations/mapvis/hooks/useSelectionLayer.tsx b/libs/shared/lib/vis/visualizations/mapvis/hooks/useSelectionLayer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..51aa3abe5f3431d308e3f803db5ab18621fa6c3d --- /dev/null +++ b/libs/shared/lib/vis/visualizations/mapvis/hooks/useSelectionLayer.tsx @@ -0,0 +1,18 @@ +import { useMemo } from 'react'; +import { SelectionLayer } from '@deck.gl-community/editable-layers'; +import { Coordinate } from '../mapvis.types'; + +export const useSelectionLayer = (selectingRectangle: boolean, layerIds: string[], onSelect: (pickingInfos: any[]) => void) => { + return useMemo(() => { + return ( + selectingRectangle && + new SelectionLayer({ + id: 'selection', + selectionType: 'rectangle', + onSelect: ({ pickingInfos }: { pickingInfos: any[] }) => onSelect(pickingInfos), + layerIds: layerIds, + getTentativeFillColor: () => [22, 37, 67, 100], + }) + ); + }, [selectingRectangle, layerIds, onSelect]); +}; diff --git a/libs/shared/lib/vis/visualizations/mapvis/layers/choropleth-layer/ChoroplethLayer.tsx b/libs/shared/lib/vis/visualizations/mapvis/layers/choropleth-layer/ChoroplethLayer.tsx index 065e675b5633bfeda624e668cee9138c994204cd..9974f74c4febd8ac6db772c946bda5a8ed46ebc5 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/layers/choropleth-layer/ChoroplethLayer.tsx +++ b/libs/shared/lib/vis/visualizations/mapvis/layers/choropleth-layer/ChoroplethLayer.tsx @@ -1,290 +1,204 @@ import React from 'react'; -import { CompositeLayer } from 'deck.gl'; -import { ArcLayer, GeoJsonLayer, ScatterplotLayer } from '@deck.gl/layers'; -import { europeData, usaData, worldData, netherlands } from '../../../../../mock-data/geo-json'; -import { Edge, Node, LayerProps } from '../../mapvis.types'; -import { RGBColor, color, geoBounds, geoCentroid, geoContains, interpolateYlGnBu, scaleSequential } from 'd3'; - -export const circumferencesMap = { - netherlands: netherlands, - europe: europeData, - usa: usaData, - world: worldData, +import { CompositeLayer, Layer } from 'deck.gl'; +import { ArcLayer, GeoJsonLayer } from '@deck.gl/layers'; +import { netherlands } from '../../../../../mock-data/geo-json'; +import { Edge, Node, LayerProps, CompositeLayerType, Coordinate } from '../../mapvis.types'; +import { RGBColor, color, geoBounds, geoCentroid, geoContains, scaleSequential, interpolateYlOrRd } from 'd3'; +import * as d3 from 'd3'; + +const colorScales: Record<string, any> = { + green: d3.interpolateGreens, + blue: d3.interpolateBlues, + red: d3.interpolateReds, + orange: d3.interpolateOranges, + purple: d3.interpolatePurples, }; -export const dataTypeMap = { - nodes: 'nodes', - edges: 'edges', -}; +export class ChoroplethLayer extends CompositeLayer<CompositeLayerType> { + static layerName = 'choropleth'; + private _layers: Record<string, Layer> = {}; -export const optionsMap = { - nodes: { - graphName: 'nodeAttributes', - operations: { - average: { - options: true, - }, - sum: { - options: true, - }, - }, - }, - edges: { - graphName: 'edgeAttributes', - operations: { - withinEdges: {}, - OutgoingEdges: {}, - IncomingEdges: {}, - }, - }, -}; + constructor(props: LayerProps) { + super(props); + } -export const operationsMap = { - nodes: { - frequency: 'frequency', - average: 'average', - sum: 'sum', - }, - edges: { - withinEdges: 'withinEdges', - OutgoingEdges: 'OutgoingEdges', - IncomingEdges: 'IncomingEdges', - }, -}; + updateState({ props, oldProps, changeFlags }: { props: any; oldProps: any; context: any; changeFlags: any }) { + if (changeFlags.dataChanged) { + const geojsonData = this.concatData(netherlands, props.data.nodes); + this.setState({ geojsonData }); + } -export const graphMap = { - nodes: 'nodeAttributes', - edges: 'edgeAttributes', -}; + if (changeFlags.propsChanged) this.extractColorInformation(); + } -export const ChoroplethConfig = { - circumference: 'netherlands', - data: { - type: '', - operation: '', - attribute: '', - }, - color: {}, - edgesOnHover: true, - pitchOnSelect: true, -}; + concatData(geojson: any, nodes: Node[]) { + const updatedGeojson = { ...geojson }; -export class ChoroplethLayer extends CompositeLayer<LayerProps> { - static type = 'Choropleth'; + nodes.forEach((node: Node) => { + const coordinates: Coordinate = this.props.getNodeLocation(node.id); + + if (coordinates) { + updatedGeojson.features.forEach((feature: any) => { + if (geoContains(feature, coordinates)) { + feature.properties.nodes = feature.properties.nodes ?? []; + feature.properties.nodes.push(node.id); + + feature.properties.townships.forEach((township: any) => { + if (geoContains(township, coordinates)) { + township.properties.nodes = township.properties.nodes ?? []; + township.properties.nodes.push(node.id); + } + }); + } + }); + } + }); - shouldUpdateState({ changeFlags }: { changeFlags: any }) { - return changeFlags.propsOrDataChanged || changeFlags.somethingChanged; + return updatedGeojson; } - updateState({ props, oldProps, context, changeFlags }: { props: any; oldProps: any; context: any; changeFlags: any }) { - if (changeFlags.dataChanged) { - const nodes = props.graph.getNodes(); - const geojson = circumferencesMap[props.config.circumference as keyof typeof circumferencesMap]; - const data = this.concatData(geojson, nodes); - this.setState({ data }); - } + setColorScale(min: number, max: number) { + const { settings } = this.props; + const colorSettings = settings[ChoroplethLayer.layerName]?.colorScale ?? 'blue'; + const colorScale = colorScales[colorSettings]; + const sequentialScale = d3.scaleSequential(colorScale).domain([min, max]); + this.setState({ colorScale: sequentialScale }); + } - if (props.selected != oldProps.selected) { - const selected = props.selected; - this.setState({ selected }); + extractColorInformation() { + const { settings, data } = this.props; + const { geojsonData } = this.state; - if (!props.isSelecting) { - let bounds; - let flyOptions; + let min = Infinity; + let max = -Infinity; - if (props.selected.length == 0) { - bounds = geoBounds(this.state.data as any); - flyOptions = { pitch: 0 }; - } else if (props.selected.length == 1) { - bounds = geoBounds(props.selected[0]); - flyOptions = { pitch: props.config.pitchOnSelect ? 50 : 0 }; - } + const updateMinMax = (value: number) => { + if (min > value) min = value; + if (max < value) max = value; + }; - if (bounds) { - props.flyToBoundingBox(bounds[0][1], bounds[1][1], bounds[0][0], bounds[1][0], flyOptions); - } + (geojsonData as any).features.forEach((feature: any) => { + const nodes = feature.properties?.nodes ?? []; + + switch (settings[ChoroplethLayer.layerName].coloringStrategy) { + case 'Node count': + updateMinMax(nodes.length); + break; + + case 'Edge count': + updateMinMax(data.edges.filter((edge) => nodes.includes(edge.from) && nodes.includes(edge.to)).length); + break; + + case 'Incoming edges': + updateMinMax(data.edges.filter((edge) => nodes.includes(edge.to)).length); + break; + + case 'Outgoing edges': + updateMinMax(data.edges.filter((edge) => nodes.includes(edge.from)).length); + break; + + case 'Connected edges': + updateMinMax(data.edges.filter((edge) => nodes.includes(edge.from) || nodes.includes(edge.to)).length); + break; + + case 'Attribute': + break; + + default: + break; } - } + }); - if (changeFlags.propsChanged) { - const { type, operation, attribute } = props.config.data; - } + this.setColorScale(min, max); } - concatData(geojson: any, nodes: Node[]) { - const updatedGeojson = { ...geojson }; - nodes.map((node: Node) => { - const coordinates: [number, number] = [node.attributes.long, node.attributes.lat]; - updatedGeojson.features.map((feature: any) => { - const isInside = geoContains(feature, coordinates); - if (isInside) { - feature.properties.nodes = feature.properties.nodes ?? []; - feature.properties.nodes.push(node.id); - const nIncomingEdges: number = node.connectedEdges.filter((edge: string) => this.props.graph.getEdge(edge).to === node.id).length; - const nOutgoingEdges: number = node.connectedEdges.filter( - (edge: string) => this.props.graph.getEdge(edge).from === node.id, - ).length; - - feature.properties.incomingEdges = - feature.properties.incomingEdges !== undefined ? feature.properties.incomingEdges + nIncomingEdges : nIncomingEdges; - - feature.properties.outgoingEdges = - feature.properties.outgoingEdges !== undefined ? feature.properties.outgoingEdges + nOutgoingEdges : nOutgoingEdges; - - feature.properties.townships.map((township: any) => { - const isInside = geoContains(township, coordinates); - if (isInside) { - township.properties.nodes = township.properties.nodes ?? []; - township.properties.nodes.push(node.id); - const nIncomingEdges: number = node.connectedEdges.filter( - (edge: string) => this.props.graph.getEdge(edge).to === node.id, - ).length; - const nOutgoingEdges: number = node.connectedEdges.filter( - (edge: string) => this.props.graph.getEdge(edge).from === node.id, - ).length; - - township.properties.incomingEdges = - township.properties.incomingEdges !== undefined ? township.properties.incomingEdges + nIncomingEdges : nIncomingEdges; - - township.properties.outgoingEdges = - township.properties.outgoingEdges !== undefined ? township.properties.outgoingEdges + nOutgoingEdges : nOutgoingEdges; - } - }); - return; - } - }); - }); - return updatedGeojson; + getColorFromScale(value: number): [number, number, number] { + const { colorScale } = this.state; + if (!colorScale) return [100, 0, 0]; + + // @ts-ignore + const color = d3.rgb(colorScale(value)); + return [color.r, color.g, color.b]; } - setColorScale() {} + getColor(polygon: any): [number, number, number] { + const { data, settings } = this.props; - getColor(polygon: any) { - const length = polygon.properties.outgoingEdges ?? 1; - const colorScale = scaleSequential(interpolateYlGnBu); - const _color = color(colorScale(length)) as RGBColor; + const nodes = polygon.properties?.nodes ?? []; - if (_color) { - return [_color.r, _color.g, _color.b]; - } - return [100, 0, 0]; - } + switch (settings[ChoroplethLayer.layerName].coloringStrategy) { + case 'Node count': + return this.getColorFromScale(nodes.length); - renderLayers() { - const { graph, config, hoverObject, isSelecting, getNodeLocation } = this.props; - const { data, selected } = this.state; - const layers: any = []; + case 'Edge count': + return this.getColorFromScale(data.edges.filter((edge) => nodes.includes(edge.from) && nodes.includes(edge.to)).length); - if (isSelecting) { - const nodes = (selected as any).flatMap((feature: any) => feature.properties.nodes ?? []); + case 'Incoming edges': + return this.getColorFromScale(data.edges.filter((edge) => nodes.includes(edge.to)).length); - if (nodes.length > 0) { - const filteredEdges = graph.getEdges().filter((edge: Edge) => { - return nodes.some((node: string) => node === edge.from || node === edge.to); - }); + case 'Outgoing edges': + return this.getColorFromScale(data.edges.filter((edge) => nodes.includes(edge.from)).length); - if (filteredEdges.length > 0) { - layers.push([ - new ArcLayer( - this.getSubLayerProps({ - id: 'selected-edges', - data: filteredEdges, - pickable: true, - getWidth: (d: any) => 0.5, - getSourcePosition: (d: any) => getNodeLocation(d.from), - getTargetPosition: (d: any) => getNodeLocation(d.to), - getSourceColor: (d: any) => [220, 220, 220], - getTargetColor: (d: any) => [220, 220, 220], - }), - ), - new ScatterplotLayer( - this.getSubLayerProps({ - id: 'selected-nodes', - data: filteredEdges, - pickable: true, - opacity: 1, - filled: true, - radiusScale: 3, - radiusMinPixels: 2, - radiusMaxPixels: 100, - getFillColor: (d: any) => [0, 0, 0], - getRadius: (d: any) => 1, - getPosition: (d: any) => graph.getNodeLocation(d.to), - }), - ), - ]); - } - } + case 'Connected edges': + return this.getColorFromScale(data.edges.filter((edge) => nodes.includes(edge.from) || nodes.includes(edge.to)).length); + + case 'Attribute': + return [0, 0, 0]; + + default: + return [0, 0, 0]; } + } - if (hoverObject && config.edgesOnHover) { - const nodes = hoverObject.properties.nodes ?? []; - if (nodes) { - const filteredEdges = graph.getEdges().filter((edge: Edge) => { - return nodes.some((node: string) => node === edge.from || node === edge.to); - }); + renderLayers() { + const { geojsonData } = this.state; + const { hoverObject, getNodeLocation, data, settings } = this.props; + const layerSettings = settings[ChoroplethLayer.layerName]; + + (geojsonData as any).features.forEach((feature: any) => { + const layerId = `${feature.properties.name}-area`; + + this._layers[layerId] = new GeoJsonLayer({ + id: `feature-layer-${feature.properties.name}`, + data: feature, + opacity: layerSettings?.opacity ?? 0.3, + pickable: true, + stroked: true, + filled: true, + extruded: false, + lineWidthScale: 0.5, + lineWidthMinPixels: 1, + getLineWidth: () => 1, + getLineColor: () => [220, 220, 220], + getFillColor: (d: any) => this.getColor(d), + updateTriggers: { + getFillColor: [layerSettings.coloringStrategy, layerSettings.colorScale, layerSettings.opacity], + }, + }); + }); + if (hoverObject && layerSettings.enableBrushing) { + const nodes = (hoverObject as { properties?: { nodes: string[] } }).properties?.nodes ?? []; + if (nodes) { + const filteredEdges = data.edges.filter((edge) => nodes.includes(edge.from) || nodes.includes(edge.to)); + // @ts-ignore const centroid = geoCentroid(hoverObject); if (filteredEdges.length > 0) { - layers.push([ - new ArcLayer( - this.getSubLayerProps({ - id: 'hovered-edges', - data: filteredEdges, - pickable: true, - getWidth: (d: any) => 0.5, - getSourcePosition: (d: any) => centroid, - getTargetPosition: (d: any) => graph.getNodeLocation(d.to), - getSourceColor: (d: any) => [220, 220, 220], - getTargetColor: (d: any) => [220, 220, 220], - }), - ), - new ScatterplotLayer( - this.getSubLayerProps({ - id: 'hovered-nodes', - data: filteredEdges, - pickable: true, - opacity: 1, - filled: true, - radiusScale: 3, - radiusMinPixels: 2, - radiusMaxPixels: 100, - getFillColor: (d: any) => [0, 0, 0], - getRadius: (d: any) => 1, - getPosition: (d: any) => graph.getNodeLocation(d.to), - }), - ), - ]); + this._layers['hovered-edges'] = new ArcLayer({ + id: 'hovered-edges', + data: filteredEdges, + pickable: true, + getWidth: () => 0.5, + getSourcePosition: () => centroid, + getTargetPosition: (d: any) => getNodeLocation(d.to), + getSourceColor: (d: any) => layerSettings.edges[d.label].color, + getTargetColor: (d: any) => layerSettings.edges[d.label].color, + }); } } } - (data as any).features.forEach((feature: any) => { - const isFeatureSelected = (selected as any).includes(feature); - - layers.push( - new GeoJsonLayer( - this.getSubLayerProps({ - id: `feature-layer-${feature.properties.name}`, - data: isFeatureSelected ? feature.properties.townships : feature, - opacity: isFeatureSelected ? 0.5 : 0.3, - pickable: true, - stroked: true, - filled: true, - extruded: false, - lineWidthScale: 0.5, - lineWidthMinPixels: 1, - getLineWidth: (d: any) => 1, - getLineColor: (d: any) => [220, 220, 220], - getFillColor: (d: any) => this.getColor(d), - }), - ), - ); - }); - - return [...layers]; + return Object.values(this._layers); } } - -ChoroplethLayer.layerName = 'Choropleth'; 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 a05d7e7bb27c3c425d7061d924f971acc1aa8acd..6f19671e936b46a569e4e865af62a7c1f90d38b0 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 @@ -1,84 +1,181 @@ -import React, { useState, useMemo, useEffect } from 'react'; +import React, { 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 { EntityPill, Icon, Input, RelationPill } from '@graphpolaris/shared/lib/components'; +import { Button, DropdownColorLegend, EntityPill, Input, RelationPill } from '@graphpolaris/shared/lib/components'; +import { LayerSettingsComponentType } from '../../mapvis.types'; -export function ChoroplethOptions({ settings, graphMetadata, updateSettings }: VisualizationSettingsPropTypes<MapProps>) { - const [collapsed, setCollapsed] = useState<Record<string, boolean>>({}); +const areaColoringStrategies = ['Node count', 'Edge count', 'Incoming edges', 'Outgoing edges', 'Connected edges', 'Attribute']; + +export type coloringStrategiesType = 'Node count' | 'Edge count' | 'Incoming edges' | 'Outgoing edges' | 'Connected edges' | 'Attribute'; + +export function ChoroplethOptions({ + settings, + graphMetadata, + spatialAttributes, + updateLayerSettings, + updateSpatialAttribute, +}: LayerSettingsComponentType<MapProps>) { + const layerType = 'choropleth'; + const layerSettings = settings[layerType]; useEffect(() => { - graphMetadata.nodes.labels.forEach((node) => { - updateSettings({ - [node]: { - color: [0, 0, 0], - hidden: false, - fixed: true, - min: 0, - max: 10, - sizeAttribute: '', - lon: '', - lat: '', - ...settings?.[node], + if (!layerSettings) { + const initialSettingsObject = { coloringStrategy: 'Node count', colorScale: 'orange', opacity: 0.8, nodes: {}, edges: {} }; + + graphMetadata.nodes.labels.forEach((node) => { + initialSettingsObject.nodes = { + ...initialSettingsObject.nodes, + [node]: { + color: [0, 0, 0], + hidden: false, + fixed: true, + min: 0, + max: 10, + sizeAttribute: '', + }, + }; + }); + + graphMetadata.edges.labels.forEach((edge) => { + initialSettingsObject.edges = { + ...initialSettingsObject.edges, + [edge]: { + color: [0, 0, 0], + onHover: true, + }, + }; + }); + + updateLayerSettings({ ...initialSettingsObject }); + } + }, [graphMetadata, layerType, settings, updateLayerSettings]); + + const handleCollapseToggle = (type: string, itemType: 'nodes' | 'edges') => { + if (layerSettings) { + updateLayerSettings({ + [itemType]: { + ...layerSettings[itemType], + [type]: { + ...layerSettings[itemType][type], + collapsed: !layerSettings[itemType][type]?.collapsed ?? true, + }, }, }); - }); - }, [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], - })); + } }; 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 } })} - /> */} + layerSettings && ( + <div> + <div className="mt-2"> + <p className="text-bold">Area color</p> + <Input + inline + label="Based on" + type="dropdown" + value={layerSettings?.coloringStrategy} + options={areaColoringStrategies} + onChange={(val) => updateLayerSettings({ coloringStrategy: val as coloringStrategiesType })} + /> + <DropdownColorLegend value={settings?.colorScale} onChange={(val) => updateLayerSettings({ colorScale: val })} /> + <Input + label="Opacity" + type="slider" + min={0} + max={1} + step={0.05} + unit="%" + value={layerSettings?.opacity ?? 0.8} + onChange={(val) => updateLayerSettings({ opacity: val as number })} + /> + </div> + + {graphMetadata.nodes.labels.map((nodeType) => { + const nodeSettings = layerSettings?.nodes?.[nodeType] || {}; + + return ( + <div className="mt-2" key={nodeType}> + <div className="flex items-center"> + <Button + size="2xs" + iconComponent={nodeSettings.collapsed ? 'icon-[ic--baseline-arrow-right]' : 'icon-[ic--baseline-arrow-drop-down]'} + variant="ghost" + onClick={() => handleCollapseToggle(nodeType, 'nodes')} + /> + <div className="w-3/4 mr-6 cursor-pointer" onClick={() => handleCollapseToggle(nodeType, 'nodes')}> + <EntityPill title={nodeType} /> + </div> + </div> + + {!nodeSettings.collapsed && ( + <div> + <Input + inline + label="Latitude" + type="dropdown" + value={settings?.location[nodeType].lat} + options={[...spatialAttributes[nodeType]]} + disabled={spatialAttributes[nodeType].length < 1} + onChange={(val) => updateSpatialAttribute(nodeType, 'lat', val as string)} + /> + <Input + inline + label="Longitude" + type="dropdown" + value={settings?.location[nodeType].lon} + options={[...spatialAttributes[nodeType]]} + disabled={spatialAttributes[nodeType].length < 1} + onChange={(val) => updateSpatialAttribute(nodeType, 'lon', val as string)} + /> + </div> + )} </div> - </div> - - {!collapsed[nodeType] && ( - <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 } })} - /> - <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 } })} - /> + ); + })} + + {graphMetadata.edges.labels.map((edgeType) => { + const edgeSettings = layerSettings?.edges?.[edgeType] || {}; + + return ( + <div key={edgeType} className="mt-2"> + <div className="flex items-center"> + <Button + size="2xs" + iconComponent={edgeSettings?.collapsed ? 'icon-[ic--baseline-arrow-right]' : 'icon-[ic--baseline-arrow-drop-down]'} + variant="ghost" + onClick={() => handleCollapseToggle(edgeType, 'edges')} + /> + <div className="w-3/4 mr-6 cursor-pointer" onClick={() => handleCollapseToggle(edgeType, 'edges')}> + <RelationPill title={edgeType} /> + </div> + </div> + + {!edgeSettings.collapsed && ( + <div> + <div className="flex justify-between"> + <span className="font-bold">Color</span> + <ColorPicker + value={edgeSettings.color} + updateValue={(val) => + updateLayerSettings({ edges: { ...settings.edges, [edgeType]: { ...edgeSettings, color: val } } }) + } + /> + </div> + + <Input + label="Edges on hover" + type="boolean" + value={layerSettings?.enableBrushing} + onChange={(val) => { + updateLayerSettings({ enableBrushing: val as boolean }); + }} + /> + </div> + )} </div> - )} - </div> - ))} - </div> + ); + })} + </div> + ) ); } diff --git a/libs/shared/lib/vis/visualizations/mapvis/layers/choropleth-layer/newChoroplethLayer.tsx b/libs/shared/lib/vis/visualizations/mapvis/layers/choropleth-layer/newChoroplethLayer.tsx deleted file mode 100644 index 3abe7302831c29871d64773c39d943d9a5dc6f4d..0000000000000000000000000000000000000000 --- a/libs/shared/lib/vis/visualizations/mapvis/layers/choropleth-layer/newChoroplethLayer.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import React from 'react'; -import { CompositeLayer } from 'deck.gl'; -import { ArcLayer, GeoJsonLayer, ScatterplotLayer } from '@deck.gl/layers'; -import { netherlands } from '../../../../../mock-data/geo-json'; -import { Edge, Node, LayerProps } from '../../mapvis.types'; -import { RGBColor, color, geoBounds, geoCentroid, geoContains, interpolateYlOrRd, scaleSequential } from 'd3'; - -export class ChoroplethLayer extends CompositeLayer<LayerProps> { - static type = 'Choropleth'; - - shouldUpdateState({ props, oldProps, context, changeFlags }: { props: any; oldProps: any; context: any; changeFlags: any }) { - return changeFlags.dataChanged || changeFlags.propsChanged; - } - - updateState({ props, oldProps, context, changeFlags }: { props: any; oldProps: any; context: any; changeFlags: any }) { - if (changeFlags.dataChanged) { - const nodes = props.graph.nodes; - const geojson = netherlands; - const data = this.concatData(geojson, nodes); - this.setState({ data }); - } - } - - concatData(geojson: any, nodes: Node[]) { - const updatedGeojson = { ...geojson }; - - nodes.map((node: Node) => { - const coordinates: [number, number] = this.props.getNodeLocation(node.id); - - if (coordinates) { - updatedGeojson.features.map((feature: any) => { - const isInside = geoContains(feature, coordinates); - if (isInside) { - feature.properties.nodes = feature.properties.nodes ?? []; - feature.properties.nodes.push(node.id); - - feature.properties.townships.map((township: any) => { - const isInside = geoContains(township, coordinates); - - if (isInside) { - township.properties.nodes = township.properties.nodes ?? []; - township.properties.nodes.push(node.id); - } - }); - return; - } - }); - } - }); - - return updatedGeojson; - } - - getColor(polygon: any, min: number, max: number) { - const nodeLength = polygon.properties?.nodes?.length ?? 0; - - if (nodeLength === min) return [128, 128, 128]; - - const colorScale = scaleSequential(interpolateYlOrRd).domain([min, max]); - const _color = color(colorScale(nodeLength)) as RGBColor; - - return _color ? [_color.r, _color.g, _color.b] : [100, 0, 0]; - } - - renderLayers() { - const { data } = this.state; - - const layers: any = []; - - let max = 0; - - (data as any).features.forEach((feature: any) => { - const nodeCount = feature.properties?.nodes ? feature.properties.nodes.length : 0; - if (nodeCount > max) max = nodeCount; - }); - - (data as any).features.forEach((feature: any) => { - layers.push( - new GeoJsonLayer( - this.getSubLayerProps({ - id: `feature-layer-${feature.properties.name}`, - data: feature, - opacity: 0.3, - pickable: true, - stroked: true, - filled: true, - extruded: false, - lineWidthScale: 0.5, - lineWidthMinPixels: 1, - getLineWidth: (d: any) => 1, - getLineColor: (d: any) => [220, 220, 220], - getFillColor: (d: any) => this.getColor(d, 0, max), - }), - ), - ); - }); - - return [...layers]; - } -} - -ChoroplethLayer.layerName = 'Choropleth'; 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 4453d6dbc22f71f29964e37a2822ecc01836044c..52395513b83a59ebd11cd2ef183643fcf580345b 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 @@ -1,12 +1,17 @@ import React from 'react'; -import { CompositeLayer, HeatmapLayer } from 'deck.gl'; +import { CompositeLayer, HeatmapLayer, Layer } from 'deck.gl'; import * as d3 from 'd3'; import { getDistance } from '../../utlis'; import { CompositeLayerType, Edge, LayerProps } from '../../mapvis.types'; import { Node } from '@graphpolaris/shared/lib/data-access'; export class HeatLayer extends CompositeLayer<CompositeLayerType> { - static type = 'Heatmap'; + static type = 'heatmap'; + private _layers: Record<string, Layer> = {}; + + constructor(props: LayerProps) { + super(props); + } updateState({ changeFlags }: { changeFlags: any }) { return changeFlags.propsOrDataChanged || changeFlags.somethingChanged; @@ -44,32 +49,29 @@ export class HeatLayer extends CompositeLayer<CompositeLayerType> { } renderLayers() { - const { data, settings, getNodeLocation, selected, setLayerIds, graphMetadata } = this.props; + const { data, settings, getNodeLocation, setLayerIds, graphMetadata } = this.props; + const layerSettings = settings[HeatLayer.type]; - const layers: any[] = []; const layerIds: string[] = []; graphMetadata.nodes.labels.forEach((label: string) => { const layerId = `${label}-nodes-heatmaplayer`; layerIds.push(layerId); - console.log(settings.nodes[label].size); - layers.push( - 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: any) => settings.nodes[label].size, - radiusPixels: settings.nodes[label].size, - aggregation: 'SUM', - }), - ); + this._layers[layerId] = new HeatmapLayer<Node>({ + id: layerId, + data: data.nodes.filter((node: Node) => node.label === label), + visible: !layerSettings.nodes[label].hidden, + getPosition: (d: any) => getNodeLocation(d.id), + getWeight: (d: any) => layerSettings.nodes[label].size, + radiusPixels: layerSettings.nodes[label].size, + aggregation: 'SUM', + }); }); setLayerIds(layerIds); - return layers; + return Object.values(this._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 8b0ce65c20586fd5e727f00c19d474182b43cd84..fcd791172d41b3320150ac5aacfe09942b52d3e6 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 @@ -1,60 +1,104 @@ -import React, { useState, useMemo, useEffect } from 'react'; -import { VisualizationSettingsPropTypes } from '@graphpolaris/shared/lib/vis/common'; +import React, { useEffect } from 'react'; import { MapProps } from '../../mapvis'; import { Button, EntityPill, Input } from '@graphpolaris/shared/lib/components'; -import { MapLayerSettingsPropTypes } from '..'; +import { LayerSettingsComponentType } from '../../mapvis.types'; -export function HeatLayerOptions({ settings, graphMetadata, updateSettings, spatialAttributes }: MapLayerSettingsPropTypes) { - const handleCollapseToggle = (nodeType: string) => { - settings.nodes[nodeType].collapsed = !settings.nodes[nodeType].collapsed; - updateSettings({ nodes: settings.nodes }); +export function HeatLayerOptions({ + settings, + graphMetadata, + updateLayerSettings, + spatialAttributes, + updateSpatialAttribute, +}: LayerSettingsComponentType<MapProps>) { + const layerType = 'heatmap'; + const layerSettings = settings[layerType]; + + useEffect(() => { + if (!layerSettings) { + const initialSettingsObject = { nodes: {}, edges: {} }; + + graphMetadata.nodes.labels.forEach((node) => { + initialSettingsObject.nodes = { + ...initialSettingsObject.nodes, + [node]: { + size: 10, + hidden: false, + }, + }; + }); + + graphMetadata.edges.labels.forEach((edge) => { + initialSettingsObject.edges = { + ...initialSettingsObject.edges, + [edge]: {}, + }; + }); + + updateLayerSettings({ ...initialSettingsObject }); + } + }, [graphMetadata, layerType, settings, updateLayerSettings]); + + const handleCollapseToggle = (type: string, itemType: 'nodes' | 'edges') => { + if (layerSettings) { + updateLayerSettings({ + [itemType]: { + ...layerSettings[itemType], + [type]: { + ...layerSettings[itemType][type], + collapsed: !layerSettings[itemType][type]?.collapsed ?? true, + }, + }, + }); + } }; return ( - <div> - {settings?.nodes && - Object.keys(settings.nodes).map((nodeType) => { - const nodeSettings = settings.nodes[nodeType]; + layerSettings && ( + <div> + {graphMetadata.nodes.labels.map((nodeType) => { + const nodeSettings = layerSettings?.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)}> + <Button + size="2xs" + iconComponent={nodeSettings.collapsed ? 'icon-[ic--baseline-arrow-right]' : 'icon-[ic--baseline-arrow-drop-down]'} + variant="ghost" + onClick={() => handleCollapseToggle(nodeType, 'nodes')} + /> + <div className="w-3/4 mr-6 cursor-pointer" onClick={() => handleCollapseToggle(nodeType, 'nodes')}> <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> {!nodeSettings.collapsed && ( <div> + <Input + label="Hidden" + type="boolean" + value={nodeSettings.hidden ?? false} + onChange={(val) => { + updateLayerSettings({ nodes: { ...layerSettings.nodes, [nodeType]: { ...nodeSettings, hidden: val } } }); + }} + /> <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) } } }); - }} + value={settings?.location[nodeType].lat} + options={[...spatialAttributes[nodeType]]} + disabled={spatialAttributes[nodeType].length < 1} + onChange={(val) => updateSpatialAttribute(nodeType, 'lat', val as string)} /> <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) } } }); - }} + value={settings?.location[nodeType].lon} + options={[...spatialAttributes[nodeType]]} + disabled={spatialAttributes[nodeType].length < 1} + onChange={(val) => updateSpatialAttribute(nodeType, 'lon', val as string)} /> <Input label="Size" @@ -64,7 +108,7 @@ export function HeatLayerOptions({ settings, graphMetadata, updateSettings, spat step={1} value={nodeSettings.size} onChange={(val) => { - updateSettings({ nodes: { ...settings.nodes, [nodeType]: { ...nodeSettings, size: Number(val) } } }); + updateLayerSettings({ nodes: { ...layerSettings.nodes, [nodeType]: { ...nodeSettings, size: Number(val) } } }); }} /> </div> @@ -72,6 +116,7 @@ export function HeatLayerOptions({ settings, graphMetadata, updateSettings, spat </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 deleted file mode 100644 index e4a3e1e09765baa15391b61469ae35b5d1f378ab..0000000000000000000000000000000000000000 --- a/libs/shared/lib/vis/visualizations/mapvis/layers/icon-layer/IconLayer.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react'; -import { CompositeLayer } from 'deck.gl'; -import { IconLayer } from '@deck.gl/layers'; -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<CompositeLayerType> { - static type = 'Icon'; - - updateState({ changeFlags }: { changeFlags: any }) { - return changeFlags.propsOrDataChanged || changeFlags.somethingChanged; - } - - renderLayers() { - const { data, settings, getNodeLocation, setLayerIds, graphMetadata } = this.props; - - const layers: any[] = []; - const layerIds: string[] = []; - - graphMetadata.nodes.labels.forEach((label: string) => { - const layerId = `${label}-nodes-iconlayer`; - layerIds.push(layerId); - - layers.push( - new IconLayer({ - id: layerId, - 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) => settings.nodes[label].color, - getPosition: (d: any) => getNodeLocation(d._id), - getSize: (d: any) => 3, - }), - ); - }); - - setLayerIds(layerIds); - - return layers; - } -} - -NodeIconLayer.layerName = 'Icon'; 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 deleted file mode 100644 index 3643d74b3a80b36e6ec38683bcfd45c35f014c29..0000000000000000000000000000000000000000 --- a/libs/shared/lib/vis/visualizations/mapvis/layers/icon-layer/IconOptions.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React, { useState, useMemo, 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 { EntityPill, Icon, Input, RelationPill } from '@graphpolaris/shared/lib/components'; - -export function IconOptions({ 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, - 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); - }); - - const handleCollapseToggle = (nodeType: string) => { - setCollapsed((prevCollapsed) => ({ - ...prevCollapsed, - [nodeType]: !prevCollapsed[nodeType], - })); - }; - - 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="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="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 } })} - /> - - <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 } })} - /> - </div> - </div> - )} - </div> - ))} - </div> - ); -} diff --git a/libs/shared/lib/vis/visualizations/mapvis/layers/index.tsx b/libs/shared/lib/vis/visualizations/mapvis/layers/index.tsx index cd7d9fe0d03d3bc2a24fbf2fc505d796cd5d0f7e..9e5f7c31e76c2f3752733dd3c4c0f33ed2462500 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/layers/index.tsx +++ b/libs/shared/lib/vis/visualizations/mapvis/layers/index.tsx @@ -1,34 +1,29 @@ -import { ChoroplethLayer } from './choropleth-layer/newChoroplethLayer'; +import { ChoroplethLayer } from './choropleth-layer/ChoroplethLayer'; 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 { 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'; +import { LayerSettingsComponentType } from '../mapvis.types'; -export const layerTypes: Record<string, any> = { - // node: NodeLayer, - // icon: NodeIconLayer, +export type LayerTypes = 'nodelink' | 'heatmap' | 'choropleth'; + +export const layerTypes: Record<LayerTypes, any> = { nodelink: NodeLinkLayer, heatmap: HeatLayer, - // choropleth: ChoroplethLayer, + choropleth: ChoroplethLayer, }; -export type MapLayerSettingsPropTypes = VisualizationSettingsPropTypes<MapProps> & { +export type MapLayerSettingsPropTypes = LayerSettingsComponentType<MapProps> & { spatialAttributes: { [id: string]: string[] }; }; export const layerSettings: Record<string, React.FC<MapLayerSettingsPropTypes>> = { nodelink: NodeLinkOptions, heatmap: HeatLayerOptions, - // node: NodeOptions, - // icon: IconOptions, - // choropleth: ChoroplethOptions, + choropleth: ChoroplethOptions, }; const MAP_PROVIDER = [ 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 deleted file mode 100644 index 82f4a3a47885bf86a2755d5a466cecc483fe72fb..0000000000000000000000000000000000000000 --- a/libs/shared/lib/vis/visualizations/mapvis/layers/node-layer/NodeLayer.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react'; -import { CompositeLayer } from 'deck.gl'; -import { ScatterplotLayer } from '@deck.gl/layers'; -import { CompositeLayerType, LayerProps } from '../../mapvis.types'; -import { Node } from '@graphpolaris/shared/lib/data-access'; - -export class NodeLayer extends CompositeLayer<CompositeLayerType> { - static type = 'Node'; - - updateState({ changeFlags }: { changeFlags: any }) { - return changeFlags.propsOrDataChanged || changeFlags.somethingChanged; - } - - getRadius(node: Node, config: any) { - if (config[node.label]?.fixed) return config[node.label].radius; - const sizeAttribute = config[node.label]?.sizeAttribute; - if (sizeAttribute) { - const minValue = config[node.label]?.min ?? 0; - const maxValue = config[node.label]?.max ?? 10; - const attributeValue = parseFloat(node.attributes[sizeAttribute] as string); - - if (!isNaN(attributeValue)) { - const normalizedValue = (attributeValue - minValue) / (maxValue - minValue); - return Math.max(1, normalizedValue * 10); - } - } - return config[node.label]?.radius ?? 5; - } - - renderLayers() { - const { data, settings, getNodeLocation, selected, setLayerIds, graphMetadata } = this.props; - - const layers: any[] = []; - const layerIds: any[] = []; - - graphMetadata.nodes.labels.forEach((label: string) => { - const layerId = `${label}-nodes-scatterplot`; - layerIds.push(layerId); - - layers.push( - new ScatterplotLayer({ - id: layerId, - visible: !settings.nodes[label].hidden, - data: data.nodes.filter((node: Node) => node.label === label), - pickable: true, - filled: true, - radiusScale: 6, - radiusMinPixels: 7, - radiusMaxPixels: 100, - lineWidthMinPixels: 1, - getPosition: (d: any) => getNodeLocation(d._id), - getFillColor: (d: any) => settings.nodes[label].color, - getRadius: (d: any) => settings.nodes[label].radius, - }), - ); - }); - - setLayerIds(layerIds); - - return layers; - } -} 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 deleted file mode 100644 index 2210c29f65f41b1852a8d1c5ccf54e5c35c1cfd1..0000000000000000000000000000000000000000 --- a/libs/shared/lib/vis/visualizations/mapvis/layers/node-layer/NodeOptions.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import ColorPicker from '@graphpolaris/shared/lib/components/colorComponents/colorPicker'; -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, spatialAttributes }: MapLayerSettingsPropTypes) { - const handleCollapseToggle = (nodeType: string) => { - settings.nodes[nodeType].collapsed = !settings.nodes[nodeType].collapsed; - updateSettings({ nodes: settings.nodes }); - }; - - return ( - <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="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> - <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) } } }); - }} - /> - - <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={nodeSettings.hidden} - onChange={(val) => { - updateSettings({ nodes: { ...settings.nodes, [nodeType]: { ...nodeSettings, hidden: val } } }); - }} - /> - </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 - 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> - ); -} 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 56004039f0a519784302e744f9294cf398faf5d3..b9fddcfca0c689c3420884e476e994f28b02def6 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,13 +1,13 @@ import React from 'react'; import { CompositeLayer, Layer } from 'deck.gl'; -import { IconLayer, LineLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers'; +import { IconLayer, LineLayer, TextLayer } from '@deck.gl/layers'; import { CompositeLayerType, LayerProps } from '../../mapvis.types'; import { BrushingExtension, CollisionFilterExtension } from '@deck.gl/extensions'; -import { Edge, Node } from '@graphpolaris/shared/lib/data-access'; +import { Node } from '@graphpolaris/shared/lib/data-access'; import { createIcon } from './shapeFactory'; export class NodeLinkLayer extends CompositeLayer<CompositeLayerType> { - static type = 'NodeLink'; + static type = 'nodelink'; private _layers: Record<string, Layer> = {}; constructor(props: LayerProps) { @@ -19,42 +19,56 @@ export class NodeLinkLayer extends CompositeLayer<CompositeLayerType> { } renderLayers() { - const { data, settings, getNodeLocation, selected, setLayerIds, graphMetadata } = this.props; + const { data, settings, getNodeLocation, ml, graphMetadata } = this.props; + const layerSettings = settings[NodeLinkLayer.type]; const brushingExtension = new BrushingExtension(); const collisionFilter = new CollisionFilterExtension(); graphMetadata.edges.labels.forEach((label: string) => { const layerId = `${label}-edges-line`; - const edgeData = - selected.length > 0 ? data.edges.filter((edge: Edge) => selected.includes(edge.from) || selected.includes(edge.to)) : data.edges; + const edgeData = data.edges; - this._layers[layerId] = new LineLayer<Edge>({ + this._layers[layerId] = new LineLayer({ id: layerId, data: edgeData, - visible: !settings.edges[label].hidden, + visible: !layerSettings.edges[label].hidden, pickable: true, - getWidth: settings.edges[label].width, + getWidth: layerSettings.edges[label].width, getSourcePosition: (d) => getNodeLocation(d.from), getTargetPosition: (d) => getNodeLocation(d.to), - getColor: (d) => settings.edges[d.label].color, + getColor: (d) => layerSettings.edges[d.label].color, extensions: [brushingExtension], + brushingEnabled: layerSettings.enableBrushing, }); }); + + if (ml.linkPrediction.enabled) { + this._layers['link_prediction'] = new LineLayer({ + id: 'link-prediction-layer', + data: ml.linkPrediction.result, + pickable: false, + getWidth: 1, + getSourcePosition: (d) => getNodeLocation(d.from), + getTargetPosition: (d) => getNodeLocation(d.to), + getColor: (d) => [0, 0, 0], + }); + } + graphMetadata.nodes.labels.forEach((label: string) => { const layerId = `${label}-nodes-scatterplot`; - this._layers[layerId] = new IconLayer<Node>({ + this._layers[layerId] = new IconLayer({ id: layerId, - visible: !settings.nodes[label].hidden, + visible: !layerSettings.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, + getSize: (d) => layerSettings.nodes[label].size, getPosition: (d) => getNodeLocation(d._id), getIcon: (d: any) => { return { - url: createIcon(settings.nodes[label].shape, settings.nodes[label].color), + url: createIcon(layerSettings.nodes[label].shape, layerSettings.nodes[label].color), width: 24, height: 24, }; 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 6c530cf3741393dae5f5981f9606d73cbf492ab5..306774b916bb3d26875f68e5f42164826024ee72 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,136 +1,262 @@ -import React, { useState, useEffect } from 'react'; +import React, { useEffect } from 'react'; import ColorPicker from '@graphpolaris/shared/lib/components/colorComponents/colorPicker'; -import { Button, EntityPill, Icon, Input, RelationPill } from '@graphpolaris/shared/lib/components'; -import { MapLayerSettingsPropTypes } from '..'; +import { Button, DropdownColorLegend, EntityPill, Icon, Input, RelationPill } from '@graphpolaris/shared/lib/components'; +import { MapProps } from '../../mapvis'; +import { LayerSettingsComponentType } from '../../mapvis.types'; -export function NodeLinkOptions({ settings, graphMetadata, updateSettings, spatialAttributes }: MapLayerSettingsPropTypes) { - const handleCollapseToggle = (nodeType: string) => { - settings.nodes[nodeType].collapsed = !settings.nodes[nodeType].collapsed; - updateSettings({ nodes: settings.nodes }); - }; +export function NodeLinkOptions({ + settings, + graphMetadata, + updateLayerSettings, + spatialAttributes, + updateSpatialAttribute, +}: LayerSettingsComponentType<MapProps>) { + const layerType = 'nodelink'; + const layerSettings = settings[layerType]; useEffect(() => { - graphMetadata.nodes.labels.map((nodeType) => { - if (settings?.[nodeType]?.lat) { - } - }); - }, [settings.node, graphMetadata]); + if (!layerSettings) { + const initialSettingsObject = { enableBrushing: false, nodes: {}, edges: {} }; + + graphMetadata.nodes.labels.forEach((node) => { + initialSettingsObject.nodes = { + ...initialSettingsObject.nodes, + [node]: { + colorByAttribute: false, + colorAttribute: undefined, + colorAttributeType: undefined, + hidden: false, + shape: 'circle', + color: [Math.floor(Math.random() * 251), Math.floor(Math.random() * 251), 0], + size: 10, + }, + }; + }); + + graphMetadata.edges.labels.forEach((edge) => { + initialSettingsObject.edges = { + ...initialSettingsObject.edges, + [edge]: { + hidden: false, + width: 1, + sizeAttribute: '', + fixed: true, + color: [0, 0, 0], + }, + }; + }); + + updateLayerSettings({ ...initialSettingsObject }); + } + }, [graphMetadata, settings, updateLayerSettings]); + + const handleCollapseToggle = (type: string, itemType: 'nodes' | 'edges') => { + if (layerSettings) { + updateLayerSettings({ + [itemType]: { + ...layerSettings[itemType], + [type]: { + ...layerSettings[itemType][type], + collapsed: !layerSettings[itemType][type]?.collapsed ?? true, + }, + }, + }); + } + }; return ( - <div> - {settings?.nodes && - Object.keys(settings.nodes).map((nodeType) => { - const nodeSettings = settings.nodes[nodeType]; + layerSettings && ( + <div> + {graphMetadata.nodes.labels.map((nodeType) => { + const nodeSettings = layerSettings?.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)}> + <Button + size="2xs" + iconComponent={nodeSettings.collapsed ? 'icon-[ic--baseline-arrow-right]' : 'icon-[ic--baseline-arrow-drop-down]'} + variant="ghost" + onClick={() => handleCollapseToggle(nodeType, 'nodes')} + /> + <div className="flex flex-grow mr-2 cursor-pointer" onClick={() => handleCollapseToggle(nodeType, 'nodes')}> <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> {!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) } } }); - }} + label="Hidden" + type="boolean" + value={nodeSettings.hidden} + onChange={(val) => + updateLayerSettings({ nodes: { ...layerSettings.nodes, [nodeType]: { ...nodeSettings, hidden: val } } }) + } /> + <div className="border-t-2 my-2"> + <span className="font-bold mt-2">Location attributes</span> + <Input + inline + label="Latitude" + type="dropdown" + value={settings?.location[nodeType]?.lat} + options={[...spatialAttributes[nodeType]]} + disabled={spatialAttributes[nodeType].length < 1} + onChange={(val) => updateSpatialAttribute(nodeType, 'lat', val as string)} + /> + <Input + inline + label="Longitude" + type="dropdown" + value={settings?.location[nodeType]?.lon} + options={[...spatialAttributes[nodeType]]} + disabled={spatialAttributes[nodeType].length < 1} + onChange={(val) => updateSpatialAttribute(nodeType, 'lon', val as string)} + /> + </div> + + <div className="border-t-2 my-2"> + <div className="flex justify-between"> + <span className="font-bold">Color</span> + {!nodeSettings.colorByAttribute && ( + <ColorPicker + value={nodeSettings.color} + updateValue={(val) => { + updateLayerSettings({ nodes: { ...layerSettings.nodes, [nodeType]: { ...nodeSettings, color: val } } }); + }} + /> + )} + </div> + + <div> + <Input + label="By attribute" + type="boolean" + value={nodeSettings.colorByAttribute ?? false} + onChange={(val) => + updateLayerSettings({ nodes: { ...layerSettings.nodes, [nodeType]: { ...nodeSettings, colorByAttribute: val } } }) + } + /> + {nodeSettings.colorByAttribute && ( + <div> + <Input + inline + label="Color based on" + type="dropdown" + value={nodeSettings.colorAttribute} + options={Object.keys(graphMetadata.nodes.types[nodeType]?.attributes)} + disabled={!settings.nodes} + onChange={(val) => + updateLayerSettings({ + nodes: { + ...layerSettings.nodes, + [nodeType]: { + ...nodeSettings, + colorAttribute: String(val), + colorAttributeType: graphMetadata.nodes.types[nodeType].attributes[val].dimension, + }, + }, + }) + } + /> + {nodeSettings.colorAttributeType === 'numerical' ? ( + <div> + <DropdownColorLegend + value={settings?.colorScale} + onChange={(val) => + updateLayerSettings({ + nodes: { ...layerSettings.nodes, [nodeType]: { ...nodeSettings, colorScale: val } }, + }) + } + /> + </div> + ) : ( + <div>Categorical</div> + )} + </div> + )} + </div> + </div> + + <div className="border-t-2 my-2"> + <span className="font-bold mt-2">Shape & Size</span> + <Input + inline + label="Shape" + type="dropdown" + value={nodeSettings.shape} + options={['circle', 'square', 'triangle', 'diamond', 'location', 'star']} + disabled={!settings.shape} + onChange={(val) => + updateLayerSettings({ + nodes: { ...layerSettings.nodes, [nodeType]: { ...nodeSettings, shape: String(val) } }, + }) + } + /> + <Input + label="Size" + type="slider" + min={0} + max={40} + step={1} + value={nodeSettings.size} + onChange={(val) => + updateLayerSettings({ + nodes: { ...layerSettings.nodes, [nodeType]: { ...nodeSettings, size: Number(val) } }, + }) + } + /> + </div> </div> )} </div> ); })} - - {settings?.edges && - Object.keys(settings.edges).map((edgeType) => { - const edgeSettings = settings.edges[edgeType]; + {graphMetadata.edges.labels.map((edgeType) => { + const edgeSettings = layerSettings?.edges?.[edgeType] || {}; return ( <div className="mt-2" key={edgeType}> <div className="flex items-center"> - <div className="w-3/4 mr-6 cursor-pointer" onClick={() => handleCollapseToggle(edgeType)}> + <Button + size="2xs" + iconComponent={edgeSettings.collapsed ? 'icon-[ic--baseline-arrow-right]' : 'icon-[ic--baseline-arrow-drop-down]'} + variant="ghost" + onClick={() => handleCollapseToggle(edgeType, 'edges')} + /> + <div className="w-3/4 mr-6 cursor-pointer" onClick={() => handleCollapseToggle(edgeType, 'edges')}> <RelationPill title={edgeType} /> </div> - <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="Hidden" + type="boolean" + value={edgeSettings.hidden ?? false} + onChange={(val) => { + updateLayerSettings({ edges: { ...settings.edges, [edgeType]: { ...edgeSettings, hidden: val } } }); + }} + /> + + <div className="flex justify-between"> + <span className="font-bold">Color</span> + <ColorPicker + value={edgeSettings.color} + updateValue={(val) => + updateLayerSettings({ edges: { ...settings.edges, [edgeType]: { ...edgeSettings, color: val } } }) + } + /> + </div> + <Input label="Enable brushing" type="boolean" value={settings.enableBrushing} onChange={(val) => { - updateSettings({ enableBrushing: val as boolean }); + updateLayerSettings({ enableBrushing: val as boolean }); }} /> @@ -143,7 +269,7 @@ export function NodeLinkOptions({ settings, graphMetadata, updateSettings, spati label="Fixed" type="boolean" value={edgeSettings.fixed} - onChange={(val) => updateSettings({ edges: { ...settings.edges, [edgeType]: { ...edgeSettings, fixed: val } } })} + onChange={(val) => updateLayerSettings({ edges: { ...settings.edges, [edgeType]: { ...edgeSettings, fixed: val } } })} /> {!edgeSettings.fixed ? ( <div> @@ -152,15 +278,17 @@ export function NodeLinkOptions({ settings, graphMetadata, updateSettings, spati 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', + graphMetadata.edges.types[edgeType]?.attributes + ? Object.keys(graphMetadata.edges.types[edgeType].attributes).filter( + (key) => graphMetadata.edges.types[edgeType].attributes[key].dimension === 'numerical', ) : [] } - value={edgeSettings.sizeAttribute ?? ''} + value={edgeSettings.sizeAttribute} onChange={(val) => - updateSettings({ edges: { ...settings.edges, [edgeType]: { ...edgeSettings, sizeAttribute: String(val) } } }) + updateLayerSettings({ + edges: { ...settings.edges, [edgeType]: { ...edgeSettings, sizeAttribute: String(val) } }, + }) } /> <div className="flex"> @@ -169,14 +297,18 @@ export function NodeLinkOptions({ settings, graphMetadata, updateSettings, spati label="min" size="xs" value={edgeSettings.min} - onChange={(val) => updateSettings({ edges: { ...settings.edges, [edgeType]: { ...edgeSettings, min: val } } })} + onChange={(val) => + updateLayerSettings({ 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 } } })} + onChange={(val) => + updateLayerSettings({ edges: { ...settings.edges, [edgeType]: { ...edgeSettings, max: val } } }) + } /> </div> </div> @@ -188,9 +320,9 @@ export function NodeLinkOptions({ settings, graphMetadata, updateSettings, spati min={0} max={10} step={0.2} - value={settings.edges[edgeType].width} + value={edgeSettings.width} onChange={(val) => - updateSettings({ edges: { ...settings.edges, [edgeType]: { ...edgeSettings, width: Number(val) } } }) + updateLayerSettings({ edges: { ...settings.edges, [edgeType]: { ...edgeSettings, width: Number(val) } } }) } /> </div> @@ -201,6 +333,7 @@ export function NodeLinkOptions({ settings, graphMetadata, updateSettings, spati </div> ); })} - </div> + </div> + ) ); } diff --git a/libs/shared/lib/vis/visualizations/mapvis/mapvis.tsx b/libs/shared/lib/vis/visualizations/mapvis/mapvis.tsx index 43a4646a66f4ab38567f47cfa4cb577bc97d24c0..b18b40972209dee73e21f66cf073cb7326b58783 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/mapvis.tsx +++ b/libs/shared/lib/vis/visualizations/mapvis/mapvis.tsx @@ -1,53 +1,27 @@ import React, { useEffect, useMemo, useCallback, useState, useRef } from 'react'; import DeckGL from '@deck.gl/react'; -import { CompositeLayer, FlyToInterpolator, Position, WebMercatorViewport } from '@deck.gl/core'; -import { SelectionLayer } from '@deck.gl-community/editable-layers'; -import { CompositeLayerType, Coordinate, Layer } from './mapvis.types'; +import { CompositeLayer, FlyToInterpolator, WebMercatorViewport } from '@deck.gl/core'; +import { CompositeLayerType, Coordinate, LayerSettingsType, LocationInfo } from './mapvis.types'; import { VISComponentType, VisualizationPropTypes } from '../../common'; -import { layerTypes, createBaseMap, MapLayerSettingsPropTypes } from './layers'; -import { MapSettings } from './MapSettings'; +import { layerTypes, createBaseMap, LayerTypes } from './layers'; +import { MapSettings } from './components/MapSettings'; import { Node } from '@graphpolaris/shared/lib/data-access/store/graphQueryResultSlice'; -import { SearchBar } from './SearchBar'; -import { Icon } from '@graphpolaris/shared/lib/components'; - -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; -}; +import { MapTooltip } from './components/Tooltip'; +import { geoCentroid } from 'd3'; +import Attribution from './components/Attribution'; +import ActionBar from './components/ActionBar'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@graphpolaris/shared/lib/components'; +import { useSelectionLayer, useCoordinateLookup } from './hooks'; export type MapProps = { - layer: string; - enableBrushing: boolean; - nodes: Record<string, MapNodeData>; - edges: Record<string, MapEdgeData>; -}; + layer: LayerTypes; + location: Record<string, LocationInfo>; +} & Partial<Record<LayerTypes, LayerSettingsType>>; -const settings: MapProps = { layer: 'nodelink', enableBrushing: false, nodes: {}, edges: {} }; +const settings: MapProps = { + layer: 'nodelink', + location: {}, +}; const INITIAL_VIEW_STATE = { latitude: 52.1006, @@ -60,6 +34,7 @@ const INITIAL_VIEW_STATE = { const FLY_SPEED = 1000; export const MapVis = (props: VisualizationPropTypes<MapProps>) => { + const ref = useRef<HTMLDivElement>(null); const baseLayer = useRef(createBaseMap()); const [viewport, setViewport] = useState<Record<string, any>>(INITIAL_VIEW_STATE); const [hoverObject, setHoverObject] = useState<Node | null>(null); @@ -68,6 +43,8 @@ export const MapVis = (props: VisualizationPropTypes<MapProps>) => { const [layerIds, setLayerIds] = useState<string[]>([]); const [isSearching, setIsSearching] = useState<boolean>(false); + const coordinateLookup = useCoordinateLookup(props.data.nodes, props.settings.location); + const getFittedViewport = useCallback( (minLat: number, maxLat: number, minLon: number, maxLon: number) => { const viewportWebMercator = new WebMercatorViewport(viewport).fitBounds( @@ -106,29 +83,10 @@ export const MapVis = (props: VisualizationPropTypes<MapProps>) => { } const layer = { - component: props.settings.layer ? layerTypes?.[props.settings.layer] : layerTypes.nodelink, - settings: props.settings, id: props.settings.layer, + component: props.settings.layer ? layerTypes?.[props.settings.layer] : layerTypes.nodelink, }; - const coordinateLookup: { [id: string]: Position } = props.data.nodes.reduce( - (acc, node) => { - 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)]; - } - - return acc; - }, - {} as { [id: string]: Position }, - ); - const layerProps: CompositeLayerType = { ...props, selected: selected, @@ -139,7 +97,6 @@ export const MapVis = (props: VisualizationPropTypes<MapProps>) => { }; if (dataLayer && dataLayer.id === layer.id) { - // dataLayer.updateState; setDataLayer({ component: dataLayer.component.clone(layerProps), id: props.settings.layer }); } else { // @ts-ignore @@ -147,97 +104,114 @@ export const MapVis = (props: VisualizationPropTypes<MapProps>) => { } }, [props.settings.layer, props.data, selected, hoverObject, props.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)); - props.handleSelect({ nodes, edges }); - } else { - props.handleSelect(); + const selectionLayer = useSelectionLayer(selectingRectangle, layerIds, (pickingInfos: any[]) => { + const nodes: Node[] = []; + const edges: any[] = []; + pickingInfos.forEach(({ object }) => { + if (object._id) { + if (object.from && object.to) { + edges.push(object); + } else { + nodes.push(object); + } + } + }); + setSelected(nodes.map((node) => node._id)); + props.handleSelect({ nodes, edges }); + setSelectingRectangle(false); + }); + + const coordinateToXY = useCallback( + (coordinate: Coordinate) => { + const [longitude, latitude] = coordinate; + return new WebMercatorViewport(viewport).project([longitude, latitude]); + }, + [viewport], + ); + + useEffect(() => { + if (selected.length > 0) { + const updatedSelected = selected.map((node) => { + let x, y; + if (props.settings.layer === 'nodelink' && node.lon && node.lat) { + const coordinate: Coordinate = [parseFloat(node.lon), parseFloat(node.lat)]; + [x, y] = coordinateToXY(coordinate); + } else if (props.settings.layer === 'choropleth') { + const centroid = geoCentroid(node); + if (centroid) { + [x, y] = coordinateToXY([centroid[0], centroid[1]]); + } + } + return { ...node, x, y }; + }); + setSelected(updatedSelected); + } + }, [viewport]); + + const handleClick = useCallback( + (object: any) => { + if (props.data) { + if (!object) { + props.handleSelect(); + setSelected([]); + return; + } + if (object.hasOwnProperty('attributes') && object.hasOwnProperty('id') && object.hasOwnProperty('label')) { + const objectLocation: Coordinate = coordinateLookup[object.id]; + props.handleSelect({ nodes: [object] }); + if (objectLocation) { + const [x, y] = coordinateToXY(objectLocation); + setSelected([{ ...object, x, y, lon: objectLocation[0], lat: objectLocation[1], selectedType: 'node' }]); } - setSelectingRectangle(false); - }, - layerIds: layerIds, - getTentativeFillColor: () => [22, 37, 67, 100], - }), - [selectingRectangle], + } + if (object.type === 'Feature') { + const centroid = geoCentroid(object); + if (centroid) { + const [x, y] = coordinateToXY(centroid); + setSelected([{ ...object, x, y, selectedType: 'area' }]); + } + const ids = object.properties.nodes; + if (ids && ids.length > 0) { + const nodes = props.data.nodes.filter((node) => ids.includes((node as unknown as { id: string }).id)); + props.handleSelect({ nodes: [...nodes] }); + } + } + } + }, + [props, coordinateLookup, coordinateToXY], ); 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)}> - <Icon component="icon-[ic--baseline-highlight-alt]" /> - </div> - <div className="cursor-pointer p-1 bg-white shadow-md rounded" onClick={() => setIsSearching(!isSearching)}> - <Icon component="icon-[ic--outline-search]" /> - </div> - </div> - {isSearching && ( - <SearchBar - onSearch={(boundingBox: [number, number, number, number]) => { - flyToBoundingBox(...boundingBox); - setIsSearching(false); - }} - /> - )} + <div className="w-full h-full flex-grow relative overflow-hidden" ref={ref}> <DeckGL layers={[baseLayer.current, dataLayer?.component, selectionLayer]} controller={true} initialViewState={viewport} onViewStateChange={({ viewState }) => setViewport(viewState)} - onClick={({ object }) => { - if (props.data) { - if (!object) { - props.handleSelect(); - setSelected([]); - return; - } - if (object.hasOwnProperty('attributes') && object.hasOwnProperty('id') && object.hasOwnProperty('label')) { - props.handleSelect({ nodes: [object] }); - setSelected([object.id]); - } - if (object.type === 'Feature') { - const ids = object.properties.nodes; - if (ids.length > 0) { - const nodes = props.data.nodes.filter((node) => ids.includes((node as unknown as { id: string }).id)); - props.handleSelect({ nodes: [...nodes] }); - } else { - props.handleSelect(); - setSelected([]); - return; - } - } - } - }} - onHover={({ object }) => { - setHoverObject(object !== undefined ? object : null); - }} + onClick={({ object }) => handleClick(object)} + onHover={({ object }) => setHoverObject(object !== undefined ? object : null)} + /> + <ActionBar + isSearching={isSearching} + setSelectingRectangle={setSelectingRectangle} + setIsSearching={setIsSearching} + flyToBoundingBox={flyToBoundingBox} /> - <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> + {selected.length > 0 && + selected.map((node, index) => ( + <Tooltip key={index} open={true} interactive={false} boundaryElement={ref} showArrow={true}> + <TooltipTrigger x={node.x} y={node.y} /> + <TooltipContent> + <MapTooltip + type={node.selectedType} + onClose={() => {}} + data={{ node: { ...node }, pos: { x: node.x, y: node.y } }} + key={node._id} + /> + </TooltipContent> + </Tooltip> + ))} + <Attribution /> </div> ); }; diff --git a/libs/shared/lib/vis/visualizations/mapvis/mapvis.types.ts b/libs/shared/lib/vis/visualizations/mapvis/mapvis.types.ts index 3dcad5c67ab35ba0fa1cd47f61685b8cbcdbb326..ca106950ccbe807b868646bcf000ccc09f8851fe 100644 --- a/libs/shared/lib/vis/visualizations/mapvis/mapvis.types.ts +++ b/libs/shared/lib/vis/visualizations/mapvis/mapvis.types.ts @@ -1,10 +1,62 @@ -import { CompositeLayer, Position } from 'deck.gl'; -import { MapLayerSettingsPropTypes } from './layers'; +import { CompositeLayer } from 'deck.gl'; import { VisualizationPropTypes, VisualizationSettingsType } from '../../common'; import { MapProps } from './mapvis'; import { Node as QueryNode } from '@graphpolaris/shared/lib/data-access/store/graphQueryResultSlice'; +import { GraphMetadata } from '@graphpolaris/shared/lib/data-access/statistics'; -export type Coordinate = [number, number] | []; +export type Coordinate = [number, number]; + +export type LocationInfo = { lat: string; lon: string }; + +export type MapNodeData = { + color: [number, number, number]; + hidden: boolean; + fixed: boolean; + min: number; + max: number; + radius: number; + collapsed: boolean; + lat?: string; + lon?: string; + shape: string; + size: number; + sizeAttribute?: string; + colorByAttribute?: boolean; + colorAttribute?: string | undefined; + colorAttributeType?: string | undefined; + colorScale: 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; + enableBrushing?: boolean; +}; + +export type LayerSettingsType = { + nodes: Record<string, MapNodeData>; + edges: Record<string, MapEdgeData>; + coloringStrategy?: string; + colorScale?: string; + opacity?: number; + [id: string]: any; +}; + +export type LayerSettingsComponentType<T = {}> = { + settings: T & VisualizationSettingsType; + graphMetadata: GraphMetadata; + spatialAttributes: { [k: string]: string[] }; + updateSpatialAttribute: (label: string, attribute: 'lat' | 'lon', value: string) => void; + updateLayerSettings: (val: Partial<LayerSettingsType>) => void; +}; export interface LayerProps { [key: string]: any; @@ -13,7 +65,7 @@ export interface LayerProps { export type CompositeLayerType = VisualizationPropTypes<MapProps> & { selected: any[]; hoverObject: QueryNode | null; - getNodeLocation: (d: string) => Position; + getNodeLocation: (d: string) => Coordinate; flyToBoundingBox: (minLat: number, maxLat: number, minLon: number, maxLon: number, options?: { [key: string]: any }) => void; setLayerIds: (val: string[]) => void; }; @@ -44,3 +96,13 @@ export type Edge = { }; [key: string]: any; }; + +export type GeoJsonType = { + properties: { + name: string; + [id: string]: any; + }; + [id: string]: any; +}; + +export type BoundingBoxType = [number, number, number, number];