Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import usePlotOnClickPopover from 'views/components/visualizations/hooks/usePlot
import NetworkVisualizationConfig from 'views/logic/aggregationbuilder/visualizations/NetworkVisualizationConfig';

import buildGraph from './buildGraph';
import edgeColorScale from './edgeColorScale';
import normalizeEdgeValue from './normalizeEdgeValue';
import networkOnClickPopover from './networkOnClickPopover';

import GenericPlot from '../GenericPlot';
Expand Down Expand Up @@ -57,6 +59,8 @@ type SimLink = { source: number | SimNode; target: number | SimNode };

const LAYOUT_ITERATIONS = 500;
const NODE_RADIUS = 75;
// Edge weight is encoded through the colorscale, not thickness, so every edge shares one width.
const EDGE_WIDTH = 2;

const runLayout = (nodeCount: number, links: ReadonlyArray<{ source: number; target: number }>): Array<SimNode> => {
const simNodes: Array<SimNode> = Array.from({ length: nodeCount }, (_, i) => ({ id: i }));
Expand Down Expand Up @@ -124,12 +128,12 @@ type EdgeCustomData = { source: EdgeEndpoint; target: EdgeEndpoint; value: numbe
type EdgeTrace = {
type: 'scatter';
mode: 'lines';
x: Array<number | null>;
y: Array<number | null>;
x: Array<number>;
y: Array<number>;
line: { width: number; color: string };
// Per-point customdata so plotly attaches the edge metadata to whichever point the
// user lands on when clicking the line segment.
customdata: Array<EdgeCustomData | null>;
// Both endpoints carry the same edge metadata so plotly attaches it wherever
// the user clicks along the segment.
customdata: Array<EdgeCustomData>;
hoverinfo: 'none';
showlegend: false;
};
Expand Down Expand Up @@ -225,7 +229,7 @@ const NetworkGraphVisualization = makeVisualization(({ config, data, height, wid
const visualizationConfig =
(config.visualizationConfig as NetworkVisualizationConfig) ?? NetworkVisualizationConfig.empty();

const plot = useMemo<{ traces: [EdgeTrace, NodeTrace]; xs: Array<number>; ys: Array<number> } | null>(() => {
const plot = useMemo<{ traces: [...Array<EdgeTrace>, NodeTrace]; xs: Array<number>; ys: Array<number> } | null>(() => {
const rowFields = config.rowPivots.flatMap((pivot) => pivot.fields);
const columnFields = config.columnPivots.flatMap((pivot) => pivot.fields);
const allFields = [...rowFields, ...columnFields];
Expand All @@ -243,9 +247,16 @@ const NetworkGraphVisualization = makeVisualization(({ config, data, height, wid

const positions = runLayout(nodes.length, edges);

const edgeX: Array<number | null> = [];
const edgeY: Array<number | null> = [];
const edgeCustomData: Array<EdgeCustomData | null> = [];
const values = edges.map((edge) => edge.value);
const minValue = Math.min(...values);
const maxValue = Math.max(...values);
// Edge weight is encoded through the configured node colorscale: each edge's aggregated value is
// normalized to [0, 1] and sampled from the same scale, honouring the reverse-scale option.
const scale = edgeColorScale(visualizationConfig.colorScale);

// Plotly's `line.color` is per-trace, so each edge is its own two-point line trace to let its
// color track the metric value.
const edgeTraces: Array<EdgeTrace> = [];
edges.forEach((edge) => {
const s = positions[edge.source];
const t = positions[edge.target];
Expand All @@ -266,26 +277,23 @@ const NetworkGraphVisualization = makeVisualization(({ config, data, height, wid
value: edge.value,
};

edgeX.push(s.x, t.x, null);
edgeY.push(s.y, t.y, null);
// Both points carry the same edge metadata; the separator slot is `null` so
// plotly won't surface a click between segments.
edgeCustomData.push(cd, cd, null);
const normalized = normalizeEdgeValue(edge.value, minValue, maxValue);
const position = visualizationConfig.reverseScale ? 1 - normalized : normalized;

edgeTraces.push({
type: 'scatter',
mode: 'lines',
x: [s.x, t.x],
y: [s.y, t.y],
line: { width: EDGE_WIDTH, color: scale(position).hex() },
customdata: [cd, cd],
hoverinfo: 'none',
showlegend: false,
});
});

const textColor = theme.colors.text.primary;

const edgeTrace: EdgeTrace = {
type: 'scatter',
mode: 'lines',
x: edgeX,
y: edgeY,
line: { width: 1, color: theme.colors.text.secondary },
customdata: edgeCustomData,
hoverinfo: 'none',
showlegend: false,
};

const displayLabels = nodes.map((n) => String(mapKeys(n.value, n.field) ?? n.label));

const xs = positions.map((p) => p.x ?? 0);
Expand Down Expand Up @@ -324,7 +332,7 @@ const NetworkGraphVisualization = makeVisualization(({ config, data, height, wid
showlegend: false,
};

return { traces: [edgeTrace, nodeTrace], xs, ys };
return { traces: [...edgeTraces, nodeTrace], xs, ys };
}, [config, mapKeys, rows, theme, visualizationConfig]);

const layout = useMemo(() => buildLayout(width, height, plot), [width, height, plot]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import mockComponent from 'helpers/mocking/MockComponent';
import Pivot from 'views/logic/aggregationbuilder/Pivot';
import Series from 'views/logic/aggregationbuilder/Series';
import AggregationWidgetConfig from 'views/logic/aggregationbuilder/AggregationWidgetConfig';
import NetworkVisualizationConfig from 'views/logic/aggregationbuilder/visualizations/NetworkVisualizationConfig';
import type { AbsoluteTimeRange } from 'views/logic/queries/Query';
import type { FieldTypeMappingsList } from 'views/logic/fieldtypes/types';
import TestStoreProvider from 'views/test/TestStoreProvider';
Expand Down Expand Up @@ -60,13 +61,26 @@ const baseProps = {
toggleEdit: () => {},
};

const lastTraces = () => {
const chartData = (): Array<Record<string, any>> => {
const { calls } = asMock(GenericPlot).mock;
const lastCall = calls[calls.length - 1];

return lastCall[0].chartData as [Record<string, any>, Record<string, any>];
return lastCall[0].chartData as Array<Record<string, any>>;
};

const nodeTrace = () => {
const data = chartData();

return data[data.length - 1];
};

const edgeTraces = () => chartData().slice(0, -1);

// Each edge trace carries its aggregated value in customdata, so we can look up the color assigned
// to a specific edge value.
const colorForValue = (value: number) =>
edgeTraces().find((edge) => edge.customdata[0].value === value)?.line.color;

describe('NetworkGraphVisualization', () => {
useViewsPlugin();

Expand All @@ -83,26 +97,67 @@ describe('NetworkGraphVisualization', () => {

render(<WrappedNetwork {...baseProps} config={config} data={fixtures.twoRowPivots} />);

const [edgeTrace, nodeTrace] = lastTraces();

expect(edgeTrace.type).toBe('scatter');
expect(edgeTrace.mode).toBe('lines');
expect(edgeTrace.x).toHaveLength(9);
expect(edgeTrace.y).toHaveLength(9);
expect(edgeTrace.x[2]).toBeNull();
expect(edgeTrace.x[5]).toBeNull();
expect(edgeTrace.x[8]).toBeNull();

expect(nodeTrace.type).toBe('scatter');
expect(nodeTrace.mode).toBe('markers+text');
expect(nodeTrace.text).toEqual(['a1', 'b1', 'b2', 'a2']);
expect(nodeTrace.customdata).toEqual([
const edges = edgeTraces();
const node = nodeTrace();

// 3 edges, each its own two-point line trace.
expect(edges).toHaveLength(3);
edges.forEach((edge) => {
expect(edge.type).toBe('scatter');
expect(edge.mode).toBe('lines');
expect(edge.x).toHaveLength(2);
expect(edge.y).toHaveLength(2);
});

expect(node.type).toBe('scatter');
expect(node.mode).toBe('markers+text');
expect(node.text).toEqual(['a1', 'b1', 'b2', 'a2']);
expect(node.customdata).toEqual([
{ field: 'source', value: 'a1' },
{ field: 'target', value: 'b1' },
{ field: 'target', value: 'b2' },
{ field: 'source', value: 'a2' },
]);
expect(nodeTrace.marker.color).toEqual([2, 2, 1, 1]);
expect(node.marker.color).toEqual([2, 2, 1, 1]);
});

it('colors edges by the aggregated metric value via the colorscale, at a uniform width', () => {
const config = AggregationWidgetConfig.builder()
.rowPivots([Pivot.createValues(['source']), Pivot.createValues(['target'])])
.series([Series.forFunction('count()')])
.visualization('network')
.build();

render(<WrappedNetwork {...baseProps} config={config} data={fixtures.twoRowPivots} />);

// All edges share one width; weight is conveyed by color.
edgeTraces().forEach((edge) => {
expect(edge.line.width).toBe(2);
expect(edge.line.color).toMatch(/^#[0-9a-f]{6}$/i);
});

// twoRowPivots has distinct edge values (3, 5, 7), so the low and high edges sample different
// ends of the colorscale.
expect(colorForValue(3)).not.toBe(colorForValue(7));
});

it('reverses the color mapping when reverseScale is enabled', () => {
const buildConfig = (reverseScale: boolean) =>
AggregationWidgetConfig.builder()
.rowPivots([Pivot.createValues(['source']), Pivot.createValues(['target'])])
.series([Series.forFunction('count()')])
.visualization('network')
.visualizationConfig(NetworkVisualizationConfig.create('YlOrRd', reverseScale))
.build();

render(<WrappedNetwork {...baseProps} config={buildConfig(false)} data={fixtures.twoRowPivots} />);
const forwardMax = colorForValue(7);

asMock(GenericPlot).mockClear();
render(<WrappedNetwork {...baseProps} config={buildConfig(true)} data={fixtures.twoRowPivots} />);

// With the scale reversed, the max value samples the opposite end, so its color changes.
expect(colorForValue(7)).not.toBe(forwardMax);
});

it('makes the graph zoomable and pannable', () => {
Expand Down Expand Up @@ -134,16 +189,16 @@ describe('NetworkGraphVisualization', () => {

const { calls } = asMock(GenericPlot).mock;
const props = calls[calls.length - 1][0];
const nodeTrace = props.chartData[1];
const node = props.chartData[props.chartData.length - 1];
const [xLo, xHi] = props.layout.xaxis.range;
const [yLo, yHi] = props.layout.yaxis.range;

// The initial range is an explicit padded range (so double-click reset restores it) that fully
// contains every node with room to spare for the labels.
expect(xLo).toBeLessThan(Math.min(...nodeTrace.x));
expect(xHi).toBeGreaterThan(Math.max(...nodeTrace.x));
expect(yLo).toBeLessThan(Math.min(...nodeTrace.y));
expect(yHi).toBeGreaterThan(Math.max(...nodeTrace.y));
expect(xLo).toBeLessThan(Math.min(...node.x));
expect(xHi).toBeGreaterThan(Math.max(...node.x));
expect(yLo).toBeLessThan(Math.min(...node.y));
expect(yHi).toBeGreaterThan(Math.max(...node.y));
});

it('unifies same value across stages into a single node', () => {
Expand All @@ -155,10 +210,10 @@ describe('NetworkGraphVisualization', () => {

render(<WrappedNetwork {...baseProps} config={config} data={fixtures.sharedValue} />);

const [, nodeTrace] = lastTraces();
const node = nodeTrace();

expect(nodeTrace.text).toEqual(['x', 'y']);
expect(nodeTrace.marker.color).toEqual([2, 2]);
expect(node.text).toEqual(['x', 'y']);
expect(node.marker.color).toEqual([2, 2]);
});

it('uses static weight of 1 per edge when no metric is configured', () => {
Expand All @@ -170,10 +225,10 @@ describe('NetworkGraphVisualization', () => {

render(<WrappedNetwork {...baseProps} config={config} data={fixtures.twoRowPivotsNoMetric} />);

const [, nodeTrace] = lastTraces();
const node = nodeTrace();

expect(nodeTrace.text).toEqual(['a1', 'b1', 'a2', 'b2']);
expect(nodeTrace.marker.color).toEqual([1, 1, 1, 1]);
expect(node.text).toEqual(['a1', 'b1', 'a2', 'b2']);
expect(node.marker.color).toEqual([1, 1, 1, 1]);
});

it('chains edges across 3 groupings', () => {
Expand All @@ -186,12 +241,12 @@ describe('NetworkGraphVisualization', () => {

render(<WrappedNetwork {...baseProps} config={config} data={fixtures.threeGroupings} />);

const [edgeTrace, nodeTrace] = lastTraces();
const node = nodeTrace();

expect(nodeTrace.text).toEqual(['a1', 'b1', 'c1', 'c2', 'b2']);
// 5 edges × 3 entries (src, tgt, null) = 15
expect(edgeTrace.x).toHaveLength(15);
expect(nodeTrace.customdata).toEqual([
expect(node.text).toEqual(['a1', 'b1', 'c1', 'c2', 'b2']);
// 5 edges → 5 traces, each a two-point segment.
expect(edgeTraces()).toHaveLength(5);
expect(node.customdata).toEqual([
{ field: 'a', value: 'a1' },
{ field: 'b', value: 'b1' },
{ field: 'c', value: 'c1' },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright (C) 2020 Graylog, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Server Side Public License, version 1,
* as published by MongoDB, Inc.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Server Side Public License for more details.
*
* You should have received a copy of the Server Side Public License
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import edgeColorScale from '../edgeColorScale';

describe('edgeColorScale', () => {
it("follows Plotly's domain order for YlOrRd (0 = dark red, 1 = pale yellow)", () => {
const scale = edgeColorScale('YlOrRd');

// Matches Plotly's YlOrRd definition, so edges map values the same way the nodes do.
expect(scale(0).hex()).toBe('#800026');
expect(scale(1).hex()).toBe('#ffffcc');
});

it('interpolates between stops for intermediate positions', () => {
const scale = edgeColorScale('YlOrRd');
const mid = scale(0.5).hex();

expect(mid).toMatch(/^#[0-9a-f]{6}$/i);
expect(mid).not.toBe(scale(0).hex());
expect(mid).not.toBe(scale(1).hex());
});

it('supports Plotly-native scales such as Viridis', () => {
const scale = edgeColorScale('Viridis');

expect(scale(0).hex()).toMatch(/^#[0-9a-f]{6}$/i);
expect(scale(1).hex()).toMatch(/^#[0-9a-f]{6}$/i);
expect(scale(0).hex()).not.toBe(scale(1).hex());
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright (C) 2020 Graylog, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Server Side Public License, version 1,
* as published by MongoDB, Inc.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Server Side Public License for more details.
*
* You should have received a copy of the Server Side Public License
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import normalizeEdgeValue from '../normalizeEdgeValue';

describe('normalizeEdgeValue', () => {
it('maps the minimum value to 0', () => {
expect(normalizeEdgeValue(2, 2, 10)).toBe(0);
});

it('maps the maximum value to 1', () => {
expect(normalizeEdgeValue(10, 2, 10)).toBe(1);
});

it('maps the midpoint value to 0.5', () => {
expect(normalizeEdgeValue(6, 2, 10)).toBe(0.5);
});

it('returns 0 when all values are equal', () => {
expect(normalizeEdgeValue(5, 5, 5)).toBe(0);
});

it('handles negative ranges', () => {
expect(normalizeEdgeValue(-10, -10, 10)).toBe(0);
expect(normalizeEdgeValue(0, -10, 10)).toBe(0.5);
expect(normalizeEdgeValue(10, -10, 10)).toBe(1);
});
});
Loading
Loading