diff --git a/src/lib/vis/visualizations/mapvis/layers/nodelink-layer/NodeLinkLayer.tsx b/src/lib/vis/visualizations/mapvis/layers/nodelink-layer/NodeLinkLayer.tsx index 8ad7df06d3c256b04e58e5b48754ec051eb80c20..efe109ce47ebaf4be2c4d8782f3bf3d805f3ed6d 100644 --- a/src/lib/vis/visualizations/mapvis/layers/nodelink-layer/NodeLinkLayer.tsx +++ b/src/lib/vis/visualizations/mapvis/layers/nodelink-layer/NodeLinkLayer.tsx @@ -1,5 +1,5 @@ import { CompositeLayer, Layer, Viewport } from 'deck.gl'; -import { LineLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers'; +import { LineLayer, ScatterplotLayer, TextLayer, SolidPolygonLayer } from '@deck.gl/layers'; import { CompositeLayerType, Coordinate, LayerProps, EDGE_COLOR_DEFAULT } from '../../mapvis.types'; import { BrushingExtension, CollisionFilterExtension } from '@deck.gl/extensions'; import { scaleLinear, ScaleLinear, color, interpolateRgb } from 'd3'; @@ -142,6 +142,7 @@ export class NodeLinkLayer extends CompositeLayer<CompositeLayerType> { graphMetadata.edges.labels.forEach((label: string) => { const layerId = `${label}-edges-line`; + const arrowPolygons: any[] = []; const parseEdge = (edge: EdgeQueryResult) => { if (edge == null || edge?.attributes?.hidden) return []; @@ -166,15 +167,13 @@ export class NodeLinkLayer extends CompositeLayer<CompositeLayerType> { ay /= amag; } - const nodeRadius = wtz * 40; + const nodeTo = data.nodes.find(n => n._id == edge.to)!; + const nodeRadius = wtz * getNodeSize(nodeTo, label); if (wtx - wsx != 0) wtx -= (nodeRadius / amag) * (wtx - wsx); if (wty - wsy != 0) wty -= (nodeRadius / amag) * (wty - wsy); // Draw arrow heads - const arrowSize = nodeRadius / 7; - const arrowRatio = 1.75; - let px = wty - wsy; let py = -(wtx - wsx); const pmag = Math.sqrt(px * px + py * py); @@ -183,40 +182,23 @@ export class NodeLinkLayer extends CompositeLayer<CompositeLayerType> { py /= pmag; } - const arrow1_x = px - ax * arrowRatio; - const arrow1_y = py - ay * arrowRatio; - const arrow2_x = -px - ax * arrowRatio; - const arrow2_y = -py - ay * arrowRatio; - - return [ - // Arrow head line 1 - { - _id: edge._id + '_arrow1', - label: edge.label, - from: this.context.viewport.unprojectPosition([wtx, wty]), - to: this.context.viewport.unprojectPosition([wtx + arrow1_x * arrowSize, wty + arrow1_y * arrowSize]), - attributes: edge.attributes, - hidden: hidden, - }, - // Edge - { - _id: edge._id, - label: edge.label, - attributes: edge.attributes, - from: [sx, sy, 0] as [number, number, number], - to: this.context.viewport.unprojectPosition([wtx, wty]), - hidden: hidden, - }, - // Arrow head line 2 - { - _id: edge._id + '_arrow2', - label: edge.label, - from: this.context.viewport.unprojectPosition([wtx, wty]), - to: this.context.viewport.unprojectPosition([wtx + arrow2_x * arrowSize, wty + arrow2_y * arrowSize]), - attributes: edge.attributes, - hidden: hidden, - }, - ]; + arrowPolygons.push({ + _id: edge._id + '_arrow', + label: edge.label, + vectors: { wtx, wty, px, py, ax, ay }, + attributes: edge.attributes, + hidden: hidden, + }); + + // Return the edge line so both the arrow and edge are rendered. + return { + _id: edge._id, + label: edge.label, + attributes: edge.attributes, + from: [sx, sy, 0] as [number, number, number], + to: this.context.viewport.unprojectPosition([wtx, wty]), + hidden: hidden, + }; } else { return { _id: edge._id, @@ -244,6 +226,45 @@ export class NodeLinkLayer extends CompositeLayer<CompositeLayerType> { extensions: [brushingExtension], brushingEnabled: layerSettings?.enableBrushing, }); + + // Create a new layer for filled arrow triangles if any exist. + if (arrowPolygons.length > 0) { + const arrowLayerId = `${label}-arrows`; + this._layers[arrowLayerId] = new SolidPolygonLayer({ + id: arrowLayerId, + data: arrowPolygons.filter(p => !p.hidden), + visible: !layerSettings?.edges[label]?.hidden, + pickable: true, + // each data object has polygon property containing 3 points + getPolygon: d => { + const { wtx, wty, px, py, ax, ay } = d.vectors; + const arrowSize = 10; + const arrowRatio = 1.75; + const arrowSizeScaled = arrowSize / 2 ** this.context.viewport.zoom; + + const tip = this.context.viewport.unprojectPosition([wtx, wty]); + const point1 = this.context.viewport.unprojectPosition([ + wtx + (px - ax * arrowRatio) * arrowSizeScaled, + wty + (py - ay * arrowRatio) * arrowSizeScaled, + ]); + const pointMid = this.context.viewport.unprojectPosition([wtx - ax * arrowSizeScaled * 0.8, wty - ay * arrowSizeScaled * 0.8]); + const point2 = this.context.viewport.unprojectPosition([ + wtx + (-px - ax * arrowRatio) * arrowSizeScaled, + wty + (-py - ay * arrowRatio) * arrowSizeScaled, + ]); + + return [tip, point1, pointMid, point2]; + }, + getFillColor: (d: EdgeQueryResult) => this.getEdgeColor(d), + }); + + // ensure that arrow heads are rerendered every zoom event. Quite a performance hit! + this.context.deck!.setProps({ + onViewStateChange: () => { + this.setNeedsUpdate(); + }, + }); + } }); if (ml?.linkPrediction?.enabled) { @@ -292,14 +313,15 @@ export class NodeLinkLayer extends CompositeLayer<CompositeLayerType> { return nodeColorRGB(layerSettings?.nodes[label].color); }, getPosition: (d: NodeQueryResult) => getNodeLocation(d._id), - getRadius: () => layerSettings?.nodes[label]?.size, + // Dynamically compute node radius based on its degree + getRadius: (d: NodeQueryResult) => getNodeSize(d, label), radiusMinPixels: 5, - getLineWidth: (d: NodeQueryResult) => (selected && selected.some(sel => sel._id === d._id) ? 2 : 1), - lineWidthUnits: 'pixels', + // getLineWidth: (d: NodeQueryResult) => (selected && selected.some(sel => sel._id === d._id) ? 2 : 1), + // lineWidthUnits: 'pixels', stroked: true, updateTriggers: { getIcon: [selected], - getRadius: [layerSettings?.nodes[label].size], + getRadius: [layerSettings?.nodes[label]?.size, data.edges], getFillColor: [this.state.colorScales], }, }); @@ -328,6 +350,12 @@ export class NodeLinkLayer extends CompositeLayer<CompositeLayerType> { }); }); + function getNodeSize(d: NodeQueryResult, label: string) { + const baseSize = layerSettings?.nodes[label]?.size ?? 40; + const relationCount = data.edges.filter(edge => edge.from === d._id || edge.to === d._id).length; + return baseSize + relationCount * (layerSettings?.nodeSizeMultiplier ?? 0); + } + function getEdgeLocation(edge: EdgeQueryResult, viewport: Viewport) { const locationFrom = viewport.projectPosition([...getNodeLocation(edge.from), 1]); const locationTo = viewport.projectPosition([...getNodeLocation(edge.to), 1]); diff --git a/src/lib/vis/visualizations/mapvis/layers/nodelink-layer/NodeLinkOptions.tsx b/src/lib/vis/visualizations/mapvis/layers/nodelink-layer/NodeLinkOptions.tsx index 5debb05a91856a5093510836b501897fc1afe7f7..425bcee6f0c2e60c0004a816c6d0262d38db83a3 100644 --- a/src/lib/vis/visualizations/mapvis/layers/nodelink-layer/NodeLinkOptions.tsx +++ b/src/lib/vis/visualizations/mapvis/layers/nodelink-layer/NodeLinkOptions.tsx @@ -62,7 +62,7 @@ export function NodeLinkOptions({ updateSpatialAttribute, }: LayerSettingsComponentType<MapProps>) { const layerType = 'nodelink'; - const layerSettings = settings[layerType] ?? { enableBrushing: false, nodes: {}, edges: {} }; + const layerSettings = settings[layerType] ?? { enableBrushing: false, nodes: {}, edges: {}, nodeSizeMultiplier: 0 }; useEffect(() => { const nodes = layerSettings.nodes ?? {}; @@ -434,6 +434,23 @@ export function NodeLinkOptions({ ); })} </Accordion> + <div> + <span className="text-xs font-semibold">Node Size Degree Multiplier</span> + <Input + type="slider" + label="Node Size Degree Multiplier" + size="sm" + className="my-1" + tooltip="Multiplies the size of the node by the the number of connections the node has." + value={layerSettings.nodeSizeMultiplier} + onChangeConfirmed={val => { + updateLayerSettings({ nodeSizeMultiplier: val }); + }} + min={0} + max={10000} + step={100} + /> + </div> </div> ) );