From 9865c4f6ca26be1f935e164c02ee540d96a7c8aa Mon Sep 17 00:00:00 2001 From: Dennis Collaris <d.a.c.collaris@uu.nl> Date: Mon, 3 Feb 2025 11:58:42 +0000 Subject: [PATCH] feat: edge bundling --- .env.development | 1 + .env.example | 1 + src/config/envVariables.ts | 1 + .../components/featureFlags/featureFlags.ts | 1 + .../nodelinkvis/components/NLPixi.tsx | 128 +++-- .../nodelinkvis/components/edgeBundling.tsx | 476 ++++++++++++++++++ .../nodelinkvis/nodelinkvis.tsx | 14 + 7 files changed, 578 insertions(+), 44 deletions(-) create mode 100644 src/lib/vis/visualizations/nodelinkvis/components/edgeBundling.tsx diff --git a/.env.development b/.env.development index aab317865..2b9c6f144 100644 --- a/.env.development +++ b/.env.development @@ -28,5 +28,6 @@ VIS1D=true INSIGHT_SHARING=true VIEWER_PERMISSIONS=true SHARABLE_EXPLORATION=true +EDGE_BUNDLING=true MAPBOX_TOKEN= \ No newline at end of file diff --git a/.env.example b/.env.example index c8b01e263..6988f791b 100644 --- a/.env.example +++ b/.env.example @@ -27,5 +27,6 @@ VIS1D=true INSIGHT_SHARING=true VIEWER_PERMISSIONS=true SHARABLE_EXPLORATION=true +EDGE_BUNDLING=true MAPBOX_TOKEN= \ No newline at end of file diff --git a/src/config/envVariables.ts b/src/config/envVariables.ts index 192875eea..29b2274fa 100644 --- a/src/config/envVariables.ts +++ b/src/config/envVariables.ts @@ -30,6 +30,7 @@ export const envVar = { INSIGHT_SHARING: import.meta.env.INSIGHT_SHARING, VIEWER_PERMISSIONS: import.meta.env.VIEWER_PERMISSIONS, SHARABLE_EXPLORATION: import.meta.env.SHARABLE_EXPLORATION, + EDGE_BUNDLING: import.meta.env.EDGE_BUNDLING, MAPBOX_TOKEN: import.meta.env.MAPBOX_TOKEN, }; diff --git a/src/lib/components/featureFlags/featureFlags.ts b/src/lib/components/featureFlags/featureFlags.ts index aaf2db9bb..0463406f0 100644 --- a/src/lib/components/featureFlags/featureFlags.ts +++ b/src/lib/components/featureFlags/featureFlags.ts @@ -34,6 +34,7 @@ export const FEATURE_FLAGS = { INSIGHT_SHARING: getFeatureVariable('INSIGHT_SHARING'), VIEWER_PERMISSIONS: getFeatureVariable('VIEWER_PERMISSIONS'), SHARABLE_EXPLORATION: getFeatureVariable('SHARABLE_EXPLORATION'), + EDGE_BUNDLING: getFeatureVariable('EDGE_BUNDLING'), } as const satisfies Record<string, boolean>; export type FeatureFlagName = keyof typeof FEATURE_FLAGS; diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx index 446962cbe..17b139c18 100644 --- a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx @@ -25,6 +25,8 @@ import { GraphType, GraphTypeD3, EdgeType, EdgeTypeD3, NodeType, NodeTypeD3 } fr import { NLPopUp } from './NLPopup'; import { nodeColor, nodeColorHex } from './utils'; import { useAsyncMemo } from '@/utils'; +import { ForceEdgeBundling, type Point } from './edgeBundling'; +import { canViewFeature } from '@/lib/components/featureFlags'; type Props = { onClick: (event?: { node: NodeTypeD3; pos: PointData }) => void; @@ -55,6 +57,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { update(); }, [globalConfig.currentTheme]); + let edgeBundling: Point[][]; const ref = useRef<HTMLDivElement>(null); const canvas = useRef<HTMLCanvasElement>(null); const app = useAsyncMemo(async () => { @@ -221,7 +224,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { LAYOUT_ALGORITHM: props.layoutAlgorithm || lastConfig.LAYOUT_ALGORITHM, }; }); - }, [props.layoutAlgorithm, props.configuration]); + }, [props.layoutAlgorithm, props.configuration, props.configuration.edgeBundlingEnabled]); const imperative = useRef<any>(null); @@ -375,6 +378,9 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { getNodeAttributes() { return props.configuration.nodes.labelAttributes; }, + getEdgeBundlingEnabled() { + return (canViewFeature('EDGE_BUNDLING') && props.configuration.edgeBundlingEnabled) ?? false; + }, })); useImperativeHandle(refExternal, () => ({ @@ -645,7 +651,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { return text; }; - const updateEdge = (edge: EdgeTypeD3) => { + const updateEdge = (edge: EdgeTypeD3, edgeBundle?: Point[]) => { if (!props.graph || nodeMap.current.size === 0) return; const edgeMeta = props.graph.edges[edge._id]; @@ -653,7 +659,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { ? graph.current.edges.filter( x => (x.source == edgeMeta.source && x.target == edgeMeta.target) || (x.source == edgeMeta.target && x.target == edgeMeta.source), ).length - : null; + : 0; const _source = edge.source; const _target = edge.target; @@ -683,42 +689,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { return; } - // let color = edge.color || 0x000000; - let color = config.LINE_COLOR_DEFAULT; - let style = imperative.current.getEdgeWidth(); - let alpha = edgeMeta.alpha || 1; - if (edgeMeta.mlEdge) { - color = config.LINE_COLOR_ML; - if (edgeMeta.value > ml.communityDetection.jaccard_threshold) { - style = edgeMeta.value * 1.8; - } else { - style = 0; - alpha = 0.2; - } - } else if (props.highlightedLinks && props.highlightedLinks.includes(edgeMeta)) { - if (edgeMeta.mlEdge && ml.communityDetection.jaccard_threshold) { - if (edgeMeta.value > ml.communityDetection.jaccard_threshold) { - color = dataColors.magenta[50]; - // 0xaa00ff; - style = edgeMeta.value * 1.8; - } - } else { - color = dataColors.red[70]; - // color = 0xff0000; - style = 1.0; - } - } else if (props.currentShortestPathEdges && props.currentShortestPathEdges.includes(edgeMeta)) { - color = dataColors.green[50]; - // color = 0x00ff00; - style = 3.0; - } - - // Conditional alpha for search results - if (searchResults.nodes.length > 0 || searchResults.edges.length > 0) { - // FIXME: searchResults.edges should be a hashmap to improve performance. - const isLinkInSearchResults = searchResults.edges.some(resultEdge => resultEdge.id === edge._id); - alpha = isLinkInSearchResults ? 1 : 0.05; - } + const { style, color, alpha } = getEdgeStyle(edgeMeta); const sx = source.x as number; const sy = source.y as number; @@ -741,7 +712,6 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { } // Draw the edge - // - Self-loops if (sourceId === targetId && target.x != null && target.y != null) { const selfLoopSize = 30; @@ -765,7 +735,19 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { } // - Regular edge - if (imperative.current.getShowMultipleEdges() && multiple > 1) { + if (edgeBundle != null) { + edgeGfx.moveTo(edgeBundle[0].x, edgeBundle[0].y); + + edgeBundle.forEach(p => { + edgeGfx.lineTo(p.x, p.y); + }); + + edgeGfx.stroke({ + width: style, + color: color, + alpha: alpha, + }); + } else if (imperative.current.getShowMultipleEdges() && multiple > 1) { // Perpendicular vector let px = ty - sy; let py = -(tx - sx); @@ -839,6 +821,47 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { } }; + const getEdgeStyle = (edgeMeta: EdgeType) => { + // let color = edge.color || 0x000000; + let color = config.LINE_COLOR_DEFAULT; + let style = imperative.current.getEdgeWidth(); + let alpha = edgeMeta.alpha || 1; + if (edgeMeta.mlEdge) { + color = config.LINE_COLOR_ML; + if (edgeMeta.value > ml.communityDetection.jaccard_threshold) { + style = edgeMeta.value * 1.8; + } else { + style = 0; + alpha = 0.2; + } + } else if (props.highlightedLinks && props.highlightedLinks.includes(edgeMeta)) { + if (edgeMeta.mlEdge && ml.communityDetection.jaccard_threshold) { + if (edgeMeta.value > ml.communityDetection.jaccard_threshold) { + color = dataColors.magenta[50]; + // 0xaa00ff; + style = edgeMeta.value * 1.8; + } + } else { + color = dataColors.red[70]; + // color = 0xff0000; + style = 1.0; + } + } else if (props.currentShortestPathEdges && props.currentShortestPathEdges.includes(edgeMeta)) { + color = dataColors.green[50]; + // color = 0x00ff00; + style = 3.0; + } + + // Conditional alpha for search results + if (searchResults.nodes.length > 0 || searchResults.edges.length > 0) { + // FIXME: searchResults.edges should be a hashmap to improve performance. + const isLinkInSearchResults = searchResults.edges.some(resultEdge => resultEdge.id === edge._id); + alpha = isLinkInSearchResults ? 1 : 0.05; + } + + return { style, color, alpha }; + }; + const updateEdgeLabel = (edge: EdgeTypeD3) => { if (graph.current.nodes.length > config.LABEL_MAX_NODES || viewport.current!.scale.x < 2) return; @@ -965,7 +988,19 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { const tick = () => { if (app == null) return; - if (layoutState.current === 'paused') return; + if (layoutState.current === 'paused') { + if (edgeBundling == null && imperative.current.getEdgeBundlingEnabled()) { + edgeBundling = ForceEdgeBundling() + .nodes( + graph.current.nodes.reduce((a, b) => { + return { ...a, [b._id]: { x: b.x, y: b.y } }; + }, {}), + ) + .edges(graph.current.edges)(); + } else { + return; + } + } if (layoutState.current === 'reset') layoutStoppedCount.current = 0; if (props.graph) { @@ -1019,8 +1054,13 @@ export const NLPixi = forwardRef((props: Props, refExternal) => { // Draw the edges edgeGfx.clear(); - graph.current.edges.forEach((link: any) => { - updateEdge(link); + + graph.current.edges.forEach((link: any, i: number) => { + if (edgeBundling != null && imperative.current.getEdgeBundlingEnabled()) { + updateEdge(link, edgeBundling[i]); // FIXME: edgeBundling omits self-loops, index may not always match exactly! + } else { + updateEdge(link); + } updateEdgeLabel(link); }); diff --git a/src/lib/vis/visualizations/nodelinkvis/components/edgeBundling.tsx b/src/lib/vis/visualizations/nodelinkvis/components/edgeBundling.tsx new file mode 100644 index 000000000..8f09d2413 --- /dev/null +++ b/src/lib/vis/visualizations/nodelinkvis/components/edgeBundling.tsx @@ -0,0 +1,476 @@ +/* + FDEB algorithm implementation [www.win.tue.nl/~dholten/papers/forcebundles_eurovis.pdf]. + + Author: Corneliu S. (github.com/upphiminn) + 2013 + */ + +export type Point = { _id?: string; x: number; y: number }; +type Line = { target: Point; source: Point }; +type Edge = { _id?: string; target: string; source: string }; + +export const ForceEdgeBundling = function () { + let data_nodes: { [nodeid: string]: Point } = {}, // {'nodeid':{'x':,'y':},..} + data_edges: Edge[] = [], // [{'source':'nodeid1', 'target':'nodeid2'},..] + K = 0.1, // global bundling constant controlling edge stiffness + S_initial = 0.1, // init. distance to move points + P_rate = 2, // subdivision rate increase + C = 6, // number of cycles to perform + I_initial = 90, // init. number of iterations for cycle + I_rate = 0.6666667, // rate at which iteration number decreases i.e. 2/3 + compatibility_threshold = 0.6, + P: Edge | null = null; + + const compatibility_list_for_edge: number[][] = [], + subdivision_points_for_edge: Point[][] = [], + P_initial = 1, // init. subdivision number + eps = 1e-6; + + /*** Geometry Helper Methods ***/ + function vector_dot_product(p: Point, q: Point) { + return p.x * q.x + p.y * q.y; + } + + function edge_as_vector(P: Edge) { + return { + x: data_nodes[P.target].x - data_nodes[P.source].x, + y: data_nodes[P.target].y - data_nodes[P.source].y, + }; + } + + function edge_length(e: Edge) { + // handling nodes that are on the same location, so that K/edge_length != Inf + if ( + Math.abs(data_nodes[e.source].x - data_nodes[e.target].x) < eps && + Math.abs(data_nodes[e.source].y - data_nodes[e.target].y) < eps + ) { + return eps; + } + + return Math.sqrt( + Math.pow(data_nodes[e.source].x - data_nodes[e.target].x, 2) + Math.pow(data_nodes[e.source].y - data_nodes[e.target].y, 2), + ); + } + + function custom_edge_length(e) { + return Math.sqrt(Math.pow(e.source.x - e.target.x, 2) + Math.pow(e.source.y - e.target.y, 2)); + } + + function edge_midpoint(e: Edge) { + const middle_x = (data_nodes[e.source].x + data_nodes[e.target].x) / 2.0; + const middle_y = (data_nodes[e.source].y + data_nodes[e.target].y) / 2.0; + + return { + x: middle_x, + y: middle_y, + }; + } + + function compute_divided_edge_length(e_idx: number) { + let length = 0; + + for (let i = 1; i < subdivision_points_for_edge[e_idx].length; i++) { + const segment_length = euclidean_distance(subdivision_points_for_edge[e_idx][i], subdivision_points_for_edge[e_idx][i - 1]); + length += segment_length; + } + + return length; + } + + function euclidean_distance(p: Point, q: Point) { + return Math.sqrt(Math.pow(p.x - q.x, 2) + Math.pow(p.y - q.y, 2)); + } + + function project_point_on_line(p: Point, Q: Line) { + const L = Math.sqrt((Q.target.x - Q.source.x) * (Q.target.x - Q.source.x) + (Q.target.y - Q.source.y) * (Q.target.y - Q.source.y)); + const r = ((Q.source.y - p.y) * (Q.source.y - Q.target.y) - (Q.source.x - p.x) * (Q.target.x - Q.source.x)) / (L * L); + + return { + x: Q.source.x + r * (Q.target.x - Q.source.x), + y: Q.source.y + r * (Q.target.y - Q.source.y), + }; + } + + /*** ********************** ***/ + + /*** Initialization Methods ***/ + function initialize_edge_subdivisions() { + for (let i = 0; i < data_edges.length; i++) { + if (P_initial === 1) { + subdivision_points_for_edge[i] = []; //0 subdivisions + } else { + subdivision_points_for_edge[i] = []; + subdivision_points_for_edge[i].push(data_nodes[data_edges[i].source]); + subdivision_points_for_edge[i].push(data_nodes[data_edges[i].target]); + } + } + } + + function initialize_compatibility_lists() { + for (let i = 0; i < data_edges.length; i++) { + compatibility_list_for_edge[i] = []; //0 compatible edges. + } + } + + function filter_self_loops(edgelist) { + const filtered_edge_list = []; + + for (let e = 0; e < edgelist.length; e++) { + if ( + data_nodes[edgelist[e].source].x != data_nodes[edgelist[e].target].x || + data_nodes[edgelist[e].source].y != data_nodes[edgelist[e].target].y + ) { + //or smaller than eps + filtered_edge_list.push(edgelist[e]); + } + } + + return filtered_edge_list; + } + + /*** ********************** ***/ + + /*** Force Calculation Methods ***/ + function apply_spring_force(e_idx: number, i: number, kP: number) { + const prev = subdivision_points_for_edge[e_idx][i - 1]; + const next = subdivision_points_for_edge[e_idx][i + 1]; + const crnt = subdivision_points_for_edge[e_idx][i]; + let x = prev.x - crnt.x + next.x - crnt.x; + let y = prev.y - crnt.y + next.y - crnt.y; + + x *= kP; + y *= kP; + + return { + x: x, + y: y, + }; + } + + function apply_electrostatic_force(e_idx: number, i: number) { + const sum_of_forces = { + x: 0, + y: 0, + }; + const compatible_edges_list = compatibility_list_for_edge[e_idx]; + + for (let oe = 0; oe < compatible_edges_list.length; oe++) { + const force = { + x: subdivision_points_for_edge[compatible_edges_list[oe]][i].x - subdivision_points_for_edge[e_idx][i].x, + y: subdivision_points_for_edge[compatible_edges_list[oe]][i].y - subdivision_points_for_edge[e_idx][i].y, + }; + + if (Math.abs(force.x) > eps || Math.abs(force.y) > eps) { + const diff = + 1 / + Math.pow( + custom_edge_length({ + source: subdivision_points_for_edge[compatible_edges_list[oe]][i], + target: subdivision_points_for_edge[e_idx][i], + }), + 1, + ); + + sum_of_forces.x += force.x * diff; + sum_of_forces.y += force.y * diff; + } + } + + return sum_of_forces; + } + + function apply_resulting_forces_on_subdivision_points(e_idx: number, P: number, S: number) { + const kP = K / (edge_length(data_edges[e_idx]) * (P + 1)); // kP=K/|P|(number of segments), where |P| is the initial length of edge P. + // (length * (num of sub division pts - 1)) + const resulting_forces_for_subdivision_points = [ + { + x: 0, + y: 0, + }, + ]; + + for (let i = 1; i < P + 1; i++) { + // exclude initial end points of the edge 0 and P+1 + const resulting_force = { + x: 0, + y: 0, + }; + + const spring_force = apply_spring_force(e_idx, i, kP); + const electrostatic_force = apply_electrostatic_force(e_idx, i); + + resulting_force.x = S * (spring_force.x + electrostatic_force.x); + resulting_force.y = S * (spring_force.y + electrostatic_force.y); + + resulting_forces_for_subdivision_points.push(resulting_force); + } + + resulting_forces_for_subdivision_points.push({ + x: 0, + y: 0, + }); + + return resulting_forces_for_subdivision_points; + } + + /*** ********************** ***/ + + /*** Edge Division Calculation Methods ***/ + function update_edge_divisions(P: number) { + for (let e_idx = 0; e_idx < data_edges.length; e_idx++) { + if (P === 1) { + subdivision_points_for_edge[e_idx].push(data_nodes[data_edges[e_idx].source]); // source + subdivision_points_for_edge[e_idx].push(edge_midpoint(data_edges[e_idx])); // mid point + subdivision_points_for_edge[e_idx].push(data_nodes[data_edges[e_idx].target]); // target + } else { + const divided_edge_length = compute_divided_edge_length(e_idx); + const segment_length = divided_edge_length / (P + 1); + let current_segment_length = segment_length; + const new_subdivision_points = []; + new_subdivision_points.push(data_nodes[data_edges[e_idx].source]); //source + + for (let i = 1; i < subdivision_points_for_edge[e_idx].length; i++) { + let old_segment_length = euclidean_distance(subdivision_points_for_edge[e_idx][i], subdivision_points_for_edge[e_idx][i - 1]); + + while (old_segment_length > current_segment_length) { + const percent_position = current_segment_length / old_segment_length; + let new_subdivision_point_x = subdivision_points_for_edge[e_idx][i - 1].x; + let new_subdivision_point_y = subdivision_points_for_edge[e_idx][i - 1].y; + + new_subdivision_point_x += + percent_position * (subdivision_points_for_edge[e_idx][i].x - subdivision_points_for_edge[e_idx][i - 1].x); + new_subdivision_point_y += + percent_position * (subdivision_points_for_edge[e_idx][i].y - subdivision_points_for_edge[e_idx][i - 1].y); + new_subdivision_points.push({ + x: new_subdivision_point_x, + y: new_subdivision_point_y, + }); + + old_segment_length -= current_segment_length; + current_segment_length = segment_length; + } + current_segment_length -= old_segment_length; + } + new_subdivision_points.push(data_nodes[data_edges[e_idx].target]); //target + subdivision_points_for_edge[e_idx] = new_subdivision_points; + } + } + } + + /*** ********************** ***/ + + /*** Edge compatibility measures ***/ + function angle_compatibility(P: Edge, Q: Edge) { + return Math.abs(vector_dot_product(edge_as_vector(P), edge_as_vector(Q)) / (edge_length(P) * edge_length(Q))); + } + + function scale_compatibility(P: Edge, Q: Edge) { + const lavg = (edge_length(P) + edge_length(Q)) / 2.0; + return 2.0 / (lavg / Math.min(edge_length(P), edge_length(Q)) + Math.max(edge_length(P), edge_length(Q)) / lavg); + } + + function position_compatibility(P: Edge, Q: Edge) { + const lavg = (edge_length(P) + edge_length(Q)) / 2.0; + const midP = { + x: (data_nodes[P.source].x + data_nodes[P.target].x) / 2.0, + y: (data_nodes[P.source].y + data_nodes[P.target].y) / 2.0, + }; + const midQ = { + x: (data_nodes[Q.source].x + data_nodes[Q.target].x) / 2.0, + y: (data_nodes[Q.source].y + data_nodes[Q.target].y) / 2.0, + }; + + return lavg / (lavg + euclidean_distance(midP, midQ)); + } + + function edge_visibility(P: Edge, Q: Edge) { + const I0 = project_point_on_line(data_nodes[Q.source], { + source: data_nodes[P.source], + target: data_nodes[P.target], + }); + const I1 = project_point_on_line(data_nodes[Q.target], { + source: data_nodes[P.source], + target: data_nodes[P.target], + }); //send actual edge points positions + const midI = { + x: (I0.x + I1.x) / 2.0, + y: (I0.y + I1.y) / 2.0, + }; + const midP = { + x: (data_nodes[P.source].x + data_nodes[P.target].x) / 2.0, + y: (data_nodes[P.source].y + data_nodes[P.target].y) / 2.0, + }; + + return Math.max(0, 1 - (2 * euclidean_distance(midP, midI)) / euclidean_distance(I0, I1)); + } + + function visibility_compatibility(P: Edge, Q: Edge) { + return Math.min(edge_visibility(P, Q), edge_visibility(Q, P)); + } + + function compatibility_score(P: Edge, Q: Edge) { + return angle_compatibility(P, Q) * scale_compatibility(P, Q) * position_compatibility(P, Q) * visibility_compatibility(P, Q); + } + + function are_compatible(P: Edge, Q: Edge) { + return compatibility_score(P, Q) >= compatibility_threshold; + } + + function compute_compatibility_lists() { + for (let e = 0; e < data_edges.length - 1; e++) { + for (let oe = e + 1; oe < data_edges.length; oe++) { + // don't want any duplicates + if (are_compatible(data_edges[e], data_edges[oe])) { + compatibility_list_for_edge[e].push(oe); + compatibility_list_for_edge[oe].push(e); + } + } + } + } + + /*** ************************ ***/ + + /*** Main Bundling Loop Methods ***/ + // TODO: make class + const forcebundle = function () { + let S = S_initial; + let I = I_initial; + let P = P_initial; + + initialize_edge_subdivisions(); + initialize_compatibility_lists(); + update_edge_divisions(P); + compute_compatibility_lists(); + + for (let cycle = 0; cycle < C; cycle++) { + for (let iteration = 0; iteration < I; iteration++) { + const forces = []; + for (let edge = 0; edge < data_edges.length; edge++) { + forces[edge] = apply_resulting_forces_on_subdivision_points(edge, P, S); + } + for (let e = 0; e < data_edges.length; e++) { + for (let i = 0; i < P + 1; i++) { + subdivision_points_for_edge[e][i].x += forces[e][i].x; + subdivision_points_for_edge[e][i].y += forces[e][i].y; + } + } + } + // prepare for next cycle + S = S / 2; + P = P * P_rate; + I = I_rate * I; + + update_edge_divisions(P); + //console.log('C' + cycle); + //console.log('P' + P); + //console.log('S' + S); + } + return subdivision_points_for_edge; + }; + /*** ************************ ***/ + + /*** Getters/Setters Methods ***/ + forcebundle.nodes = function (nl) { + if (arguments.length === 0) { + return data_nodes; + } else { + data_nodes = nl; + } + + return forcebundle; + }; + + forcebundle.edges = function (ll) { + if (arguments.length === 0) { + return data_edges; + } else { + data_edges = filter_self_loops(ll); //remove edges to from to the same point + } + + return forcebundle; + }; + + forcebundle.bundling_stiffness = function (k) { + if (arguments.length === 0) { + return K; + } else { + K = k; + } + + return forcebundle; + }; + + forcebundle.step_size = function (step) { + if (arguments.length === 0) { + return S_initial; + } else { + S_initial = step; + } + + return forcebundle; + }; + + forcebundle.cycles = function (c) { + if (arguments.length === 0) { + return C; + } else { + C = c; + } + + return forcebundle; + }; + + forcebundle.iterations = function (i) { + if (arguments.length === 0) { + return I_initial; + } else { + I_initial = i; + } + + return forcebundle; + }; + + forcebundle.iterations_rate = function (i) { + if (arguments.length === 0) { + return I_rate; + } else { + I_rate = i; + } + + return forcebundle; + }; + + forcebundle.subdivision_points_seed = function (p) { + if (arguments.length == 0) { + return P; + } else { + P = p; + } + + return forcebundle; + }; + + forcebundle.subdivision_rate = function (r) { + if (arguments.length === 0) { + return P_rate; + } else { + P_rate = r; + } + + return forcebundle; + }; + + forcebundle.compatibility_threshold = function (t) { + if (arguments.length === 0) { + return compatibility_threshold; + } else { + compatibility_threshold = t; + } + + return forcebundle; + }; + + /*** ************************ ***/ + + return forcebundle; +}; diff --git a/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx b/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx index 8fb7801f4..91e1b3a5c 100644 --- a/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx +++ b/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx @@ -12,6 +12,7 @@ import { nodeColorHex } from './components/utils'; import { NodeQueryResult, ML } from 'ts-common'; import { type PointData } from 'pixi.js'; import { VisualizationPropTypes, VISComponentType, VisualizationSettingsPropTypes } from '../../common'; +import { canViewFeature } from '@/lib/components/featureFlags'; // 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. @@ -59,6 +60,7 @@ export type NodelinkVisProps = { nodeList: string[]; showArrows: boolean; showMultipleEdges: boolean; + edgeBundlingEnabled: boolean; }; const settings: NodelinkVisProps = { @@ -81,6 +83,7 @@ const settings: NodelinkVisProps = { nodeList: [], showArrows: false, showMultipleEdges: true, + edgeBundlingEnabled: false, }; const NodeLinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<NodelinkVisProps>>( @@ -161,6 +164,7 @@ const NodeLinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin }} layoutAlgorithm={settings.layout} showPopupsOnHover={settings.showPopUpOnHover} + edgeBundlingEnabled={settings.edgeBundlingEnabled} /> ); }, @@ -277,6 +281,16 @@ const NodelinkSettings = ({ settings, graphMetadata, updateSettings }: Visualiza onChange={val => updateSettings({ showMultipleEdges: val })} /> </div> + {canViewFeature('EDGE_BUNDLING') ? ( + <div> + <Input + type="boolean" + label="Edge bundling" + value={settings.edgeBundlingEnabled} + onChange={val => updateSettings({ edgeBundlingEnabled: val })} + /> + </div> + ) : null} </SettingsContainer> ); }; -- GitLab