diff --git a/graylog2-web-interface/src/views/components/visualizations/network/NetworkGraphVisualization.tsx b/graylog2-web-interface/src/views/components/visualizations/network/NetworkGraphVisualization.tsx index 048a9b773d5e..56aa03c513b7 100644 --- a/graylog2-web-interface/src/views/components/visualizations/network/NetworkGraphVisualization.tsx +++ b/graylog2-web-interface/src/views/components/visualizations/network/NetworkGraphVisualization.tsx @@ -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'; @@ -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 => { const simNodes: Array = Array.from({ length: nodeCount }, (_, i) => ({ id: i })); @@ -124,12 +128,12 @@ type EdgeCustomData = { source: EdgeEndpoint; target: EdgeEndpoint; value: numbe type EdgeTrace = { type: 'scatter'; mode: 'lines'; - x: Array; - y: Array; + x: Array; + y: Array; 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; + // Both endpoints carry the same edge metadata so plotly attaches it wherever + // the user clicks along the segment. + customdata: Array; hoverinfo: 'none'; showlegend: false; }; @@ -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; ys: Array } | null>(() => { + const plot = useMemo<{ traces: [...Array, NodeTrace]; xs: Array; ys: Array } | null>(() => { const rowFields = config.rowPivots.flatMap((pivot) => pivot.fields); const columnFields = config.columnPivots.flatMap((pivot) => pivot.fields); const allFields = [...rowFields, ...columnFields]; @@ -243,9 +247,16 @@ const NetworkGraphVisualization = makeVisualization(({ config, data, height, wid const positions = runLayout(nodes.length, edges); - const edgeX: Array = []; - const edgeY: Array = []; - const edgeCustomData: Array = []; + 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 = []; edges.forEach((edge) => { const s = positions[edge.source]; const t = positions[edge.target]; @@ -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); @@ -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]); diff --git a/graylog2-web-interface/src/views/components/visualizations/network/__tests__/NetworkGraphVisualization.test.tsx b/graylog2-web-interface/src/views/components/visualizations/network/__tests__/NetworkGraphVisualization.test.tsx index 7d3c1b4befdb..89c99df7a543 100644 --- a/graylog2-web-interface/src/views/components/visualizations/network/__tests__/NetworkGraphVisualization.test.tsx +++ b/graylog2-web-interface/src/views/components/visualizations/network/__tests__/NetworkGraphVisualization.test.tsx @@ -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'; @@ -60,13 +61,26 @@ const baseProps = { toggleEdit: () => {}, }; -const lastTraces = () => { +const chartData = (): Array> => { const { calls } = asMock(GenericPlot).mock; const lastCall = calls[calls.length - 1]; - return lastCall[0].chartData as [Record, Record]; + return lastCall[0].chartData as Array>; }; +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(); @@ -83,26 +97,67 @@ describe('NetworkGraphVisualization', () => { render(); - 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(); + + // 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(); + const forwardMax = colorForValue(7); + + asMock(GenericPlot).mockClear(); + render(); + + // 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', () => { @@ -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', () => { @@ -155,10 +210,10 @@ describe('NetworkGraphVisualization', () => { render(); - 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', () => { @@ -170,10 +225,10 @@ describe('NetworkGraphVisualization', () => { render(); - 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', () => { @@ -186,12 +241,12 @@ describe('NetworkGraphVisualization', () => { render(); - 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' }, diff --git a/graylog2-web-interface/src/views/components/visualizations/network/__tests__/edgeColorScale.test.ts b/graylog2-web-interface/src/views/components/visualizations/network/__tests__/edgeColorScale.test.ts new file mode 100644 index 000000000000..f6435a2b0a51 --- /dev/null +++ b/graylog2-web-interface/src/views/components/visualizations/network/__tests__/edgeColorScale.test.ts @@ -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 + * . + */ +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()); + }); +}); diff --git a/graylog2-web-interface/src/views/components/visualizations/network/__tests__/normalizeEdgeValue.test.ts b/graylog2-web-interface/src/views/components/visualizations/network/__tests__/normalizeEdgeValue.test.ts new file mode 100644 index 000000000000..ec6938e16e24 --- /dev/null +++ b/graylog2-web-interface/src/views/components/visualizations/network/__tests__/normalizeEdgeValue.test.ts @@ -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 + * . + */ +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); + }); +}); diff --git a/graylog2-web-interface/src/views/components/visualizations/network/edgeColorScale.ts b/graylog2-web-interface/src/views/components/visualizations/network/edgeColorScale.ts new file mode 100644 index 000000000000..f394a0450b8f --- /dev/null +++ b/graylog2-web-interface/src/views/components/visualizations/network/edgeColorScale.ts @@ -0,0 +1,34 @@ +/* + * 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 + * . + */ +import { scales } from 'plotly.js/src/components/colorscale'; +import chroma from 'chroma-js'; + +/** + * Build a chroma scale from Plotly's own colorscale definition, so edge colors match the node + * markers — which are colored by Plotly using the same named scale. (The shared `scaleForGradient` + * helper resolves several names via ColorBrewer, whose domain order is the reverse of Plotly's for + * scales like YlOrRd/Greys/Blues, which would map edges inversely to the nodes.) + */ +const edgeColorScale = (colorScale: string): chroma.Scale => { + const stops = scales[colorScale] as Array<[domain: number, color: string]>; + const domains = stops.map(([domain]) => domain); + const colors = stops.map(([, color]) => color); + + return chroma.scale(colors).domain(domains); +}; + +export default edgeColorScale; diff --git a/graylog2-web-interface/src/views/components/visualizations/network/normalizeEdgeValue.ts b/graylog2-web-interface/src/views/components/visualizations/network/normalizeEdgeValue.ts new file mode 100644 index 000000000000..8ebe78abd624 --- /dev/null +++ b/graylog2-web-interface/src/views/components/visualizations/network/normalizeEdgeValue.ts @@ -0,0 +1,25 @@ +/* + * 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 + * . + */ +/** + * Linearly map an edge's aggregated metric value to a position in [0, 1], used to sample a color + * from the configured colorscale. When every edge shares the same value (including a single-edge + * graph) there is no meaningful range, so all edges sample the low end of the scale. + */ +const normalizeEdgeValue = (value: number, min: number, max: number): number => + max === min ? 0 : (value - min) / (max - min); + +export default normalizeEdgeValue;