diff --git a/client/src/layout/Graph.jsx b/client/src/layout/Graph.jsx index d4b07977..882816ca 100644 --- a/client/src/layout/Graph.jsx +++ b/client/src/layout/Graph.jsx @@ -1,5 +1,5 @@ import '@react-sigma/core/lib/react-sigma.min.css'; -import { Container, Col, Row, Alert, Radio, RadioGroup } from '@dataesr/react-dsfr'; +import { Container, Col, Row, Alert, Button } from '@dataesr/react-dsfr'; import { ControlsContainer, FullScreenControl, @@ -12,11 +12,11 @@ import { import { UndirectedGraph } from 'graphology'; import { useState, useEffect } from 'react'; import NodeProgramBorder from '../styles/rendering/node.border'; +import EdgeProgramCurve from '../styles/rendering/edge.curve'; import NodePanel from './NodePanel'; import ClustersPanel from './ClustersPanel'; import { groupBy } from '../utils/graphUtils'; -import { DEFAULT_NODE_COLOR, COMMUNTIY_COLORS } from '../styles/colors'; -import iwanthue from 'iwanthue'; +import { DEFAULT_NODE_COLOR, getColormap, getPalette } from '../styles/colors'; function GraphEvents({ onNodeClick, onStageClick }) { const registerEvents = useRegisterEvents(); @@ -42,8 +42,8 @@ const highlightGraph = (graph, selectedNode) => { (node, attr) => ({ ...attr, highlighted: node === selectedNode.id, - label: node === selectedNode.id || graph.neighbors(selectedNode.id).includes(node) ? attr.label : '', - color: node === selectedNode.id || graph.neighbors(selectedNode.id).includes(node) ? attr.color : '#E2E2E2', + label: node === selectedNode.id || graph.neighbors(selectedNode.id).includes(node) ? attr.label : null, + color: node === selectedNode.id || graph.neighbors(selectedNode.id).includes(node) ? attr.color : DEFAULT_NODE_COLOR, }), { attributes: ['highlighted', 'color'] }, ); @@ -62,6 +62,8 @@ export default function Graph({ data, selectedGraph }) { const { publications, structures } = data; const [selectedNode, setSelectedNode] = useState(null); + const [switchMode, enableSwitchMode] = useState(false); + console.log('switchMode', switchMode); console.log('selectedGraph', selectedGraph); const graph = UndirectedGraph.from(data.graph[selectedGraph]); @@ -74,23 +76,15 @@ export default function Graph({ data, selectedGraph }) { const communities = groupBy(data.graph[selectedGraph].nodes, ({ attributes }) => attributes.community); // console.log('communities', communities); - // With some options - // const palette = iwanthue(Object.keys(communities).length, { - // clustering: 'force-vector', - // colorSpace: 'sensible', - // seed: 42, - // attempts: 5, - // }); - - // console.log('palette', palette); - // Update nodes color + const palette = (switchMode) ? getColormap() : getPalette(Object.keys(communities).length); graph.updateEachNodeAttributes( (node, attr) => ({ ...attr, - color: COMMUNTIY_COLORS?.[attr.community] || DEFAULT_NODE_COLOR, + color: ((switchMode) ? palette[palette.length - 1 + Math.max(Object.keys(attr?.years ?? {})) - 2023] : palette?.[attr.community]) || DEFAULT_NODE_COLOR, + communityColor: palette?.[attr.community] || DEFAULT_NODE_COLOR, }), - { attributes: ['color'] }, + { attributes: ['color', 'communityColor'] }, ); return ( @@ -100,7 +94,8 @@ export default function Graph({ data, selectedGraph }) { { @@ -122,6 +117,18 @@ export default function Graph({ data, selectedGraph }) { + +
+   + {`Items: ${graph.order} | Links: ${graph.size} | Clusters: ${Object.keys(communities).length}`} +   +
+
+ + +
diff --git a/client/src/layout/NodePanel.jsx b/client/src/layout/NodePanel.jsx index 18dd140b..4ee32873 100644 --- a/client/src/layout/NodePanel.jsx +++ b/client/src/layout/NodePanel.jsx @@ -2,7 +2,6 @@ import '@react-sigma/core/lib/react-sigma.min.css'; import { Badge, BadgeGroup, Title, Accordion, AccordionItem, Container } from '@dataesr/react-dsfr'; import { GetColorName } from 'hex-color-to-color-name'; import { publicationsGetTopicsCount } from '../utils/publicationUtils'; -import { COMMUNTIY_COLORS } from '../styles/colors'; export default function NodePanel({ selectedNode, graph, publications }) { if (!selectedNode || !graph.order) return null; @@ -19,13 +18,13 @@ export default function NodePanel({ selectedNode, graph, publications }) { publications[publicationId].year), )}`} /> @@ -38,9 +37,9 @@ export default function NodePanel({ selectedNode, graph, publications }) { ))} {(selectedNode.domains && ( - - {Object.entries(selectedNode.domains).map(({ key, value }) => ( -

{`${key}: ${value}`}

+ + {Object.entries(selectedNode.domains).map((item) => ( +

{`${item[0]} (${item[1]})`}

))}
))} diff --git a/client/src/styles/colors.js b/client/src/styles/colors.js index f6737252..915f6d53 100644 --- a/client/src/styles/colors.js +++ b/client/src/styles/colors.js @@ -1,4 +1,7 @@ -export const DEFAULT_NODE_COLOR = '#7b7b7b'; +import iwanthue from 'iwanthue'; +import colormap from 'colormap'; + +export const DEFAULT_NODE_COLOR = '#E2E2E2'; export const COMMUNTIY_COLORS = [ '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#e377c2', '#bcbd22', '#17becf', '#66c2a5', '#fc8d62', '#8da0cb', '#e78ac3', '#a6d854', '#ffd92f', '#e5c494', '#aec7e8', '#ffbb78', '#98df8a', '#ff9896', @@ -8,3 +11,18 @@ export const COMMUNTIY_COLORS = [ '#a55194', '#ce6dbd', '#de9ed6', '#3182bd', '#6baed6', '#9ecae1', '#c6dbef', '#e6550d', '#fd8d3c', '#fdae6b', '#fdd0a2', '#31a354', '#74c476', '#a1d99b', '#c7e9c0', '#756bb1', '#9e9ac8', '#bcbddc', '#dadaeb', ]; +export const VIRIDIS_COLORS = ['#46327e', '#365c8d', '#277f8e', '#1fa187', '#4ac16d', '#a0da39']; + +export const getPalette = (size) => iwanthue(size, { + clustering: 'force-vector', + colorSpace: 'default', + quality: 100, + seed: 42, +}); + +export const getColormap = (size = 9) => colormap({ + colormap: 'cool', + nshades: size, + format: 'hex', + alpha: 1, +}); diff --git a/client/src/styles/rendering/edge.curve.frag.glsl b/client/src/styles/rendering/edge.curve.frag.glsl new file mode 100644 index 00000000..656b4e5e --- /dev/null +++ b/client/src/styles/rendering/edge.curve.frag.glsl @@ -0,0 +1,42 @@ +precision mediump float; + +varying vec4 v_color; +varying float v_thickness; +varying vec2 v_cpA; +varying vec2 v_cpB; +varying vec2 v_cpC; + +float det(vec2 a, vec2 b) { + return a.x * b.y - b.x * a.y; +} + +vec2 get_distance_vector(vec2 b0, vec2 b1, vec2 b2) { + float a = det(b0, b2), b = 2.0 * det(b1, b0), d = 2.0 * det(b2, b1); + float f = b * d - a * a; + vec2 d21 = b2 - b1, d10 = b1 - b0, d20 = b2 - b0; + vec2 gf = 2.0 * (b * d21 + d * d10 + a * d20); + gf = vec2(gf.y, -gf.x); + vec2 pp = -f * gf / dot(gf, gf); + vec2 d0p = b0 - pp; + float ap = det(d0p, d20), bp = 2.0 * det(d10, d0p); + float t = clamp((ap + bp) / (2.0 * a + b + d), 0.0, 1.0); + return mix(mix(b0, b1, t), mix(b1, b2, t), t); +} + +float distToQuadraticBezierCurve(vec2 p, vec2 b0, vec2 b1, vec2 b2) { + return length(get_distance_vector(b0 - p, b1 - p, b2 - p)); +} + +const float epsilon = 0.7; +const vec4 transparent = vec4(0.0, 0.0, 0.0, 0.0); + +void main(void) { + float dist = distToQuadraticBezierCurve(gl_FragCoord.xy, v_cpA, v_cpB, v_cpC); + + if (dist < v_thickness + epsilon) { + float inCurve = 1.0 - smoothstep(v_thickness - epsilon, v_thickness + epsilon, dist); + gl_FragColor = inCurve * vec4(v_color.rgb * v_color.a, v_color.a); + } else { + gl_FragColor = transparent; + } +} \ No newline at end of file diff --git a/client/src/styles/rendering/edge.curve.js b/client/src/styles/rendering/edge.curve.js new file mode 100644 index 00000000..f7164d19 --- /dev/null +++ b/client/src/styles/rendering/edge.curve.js @@ -0,0 +1,102 @@ +/* eslint-disable */ +import { floatColor, canUse32BitsIndices } from 'sigma/utils'; +import { AbstractEdgeProgram } from 'sigma/rendering/webgl/programs/common/edge'; + +import vertexShaderSource from './edge.curve.vert.glsl?raw'; +import fragmentShaderSource from './edge.curve.frag.glsl?raw'; + +const POINTS = 2, + ATTRIBUTES = 3; + +export default class EdgeFastProgram extends AbstractEdgeProgram { + positionLocation; + colorLocation; + matrixLocation; + + constructor(gl) { + super(gl, vertexShaderSource, fragmentShaderSource, POINTS, ATTRIBUTES); + + // Locations: + this.positionLocation = gl.getAttribLocation(this.program, "a_position"); + this.colorLocation = gl.getAttribLocation(this.program, "a_color"); + + // Uniform locations: + const matrixLocation = gl.getUniformLocation(this.program, "u_matrix"); + if (matrixLocation === null) throw new Error("EdgeFastProgram: error while getting matrixLocation"); + this.matrixLocation = matrixLocation; + + this.bind(); + } + + bind() { + const gl = this.gl; + + // Bindings + gl.enableVertexAttribArray(this.positionLocation); + gl.enableVertexAttribArray(this.colorLocation); + + gl.vertexAttribPointer( + this.positionLocation, + 2, + gl.FLOAT, + false, + this.attributes * Float32Array.BYTES_PER_ELEMENT, + 0, + ); + gl.vertexAttribPointer( + this.colorLocation, + 4, + gl.UNSIGNED_BYTE, + true, + this.attributes * Float32Array.BYTES_PER_ELEMENT, + 8, + ); + } + + computeIndices() { + //nothing to do + } + + process(sourceData, targetData, data, hidden, offset) { + const array = this.array; + + let i = 0; + if (hidden) { + for (let l = i + POINTS * ATTRIBUTES; i < l; i++) array[i] = 0; + return; + } + + const x1 = sourceData.x, + y1 = sourceData.y, + x2 = targetData.x, + y2 = targetData.y, + color = floatColor(data.color); + + i = POINTS * ATTRIBUTES * offset; + + // First point + array[i++] = x1; + array[i++] = y1; + array[i++] = color; + + // Second point + array[i++] = x2; + array[i++] = y2; + array[i] = color; + } + + render(params) { + if (this.hasNothingToRender()) return; + + const gl = this.gl; + const program = this.program; + + gl.useProgram(program); + + gl.uniformMatrix3fv(this.matrixLocation, false, params.matrix); + + gl.drawArrays(gl.LINES, 0, this.array.length / ATTRIBUTES); + } +} + + diff --git a/client/src/styles/rendering/edge.curve.vert.glsl b/client/src/styles/rendering/edge.curve.vert.glsl new file mode 100644 index 00000000..e940341e --- /dev/null +++ b/client/src/styles/rendering/edge.curve.vert.glsl @@ -0,0 +1,79 @@ +attribute vec4 a_color; +attribute float a_direction; +attribute float a_thickness; +attribute vec2 a_source; +attribute vec2 a_target; +attribute float a_current; + +uniform mat3 u_matrix; +uniform float u_sizeRatio; +uniform float u_pixelRatio; +uniform vec2 u_dimensions; + +varying vec4 v_color; +varying float v_thickness; +varying vec2 v_cpA; +varying vec2 v_cpB; +varying vec2 v_cpC; + +const float bias = 255.0 / 254.0; +const float epsilon = 0.7; +const float minThickness = 0.3; + +const float defaultCurveness = 0.25; + +vec2 clipspaceToViewport(vec2 pos, vec2 dimensions) { + return vec2( + (pos.x + 1.0) * dimensions.x / 2.0, + (pos.y + 1.0) * dimensions.y / 2.0 + ); +} + +vec2 viewportToClipspace(vec2 pos, vec2 dimensions) { + return vec2( + pos.x / dimensions.x * 2.0 - 1.0, + pos.y / dimensions.y * 2.0 - 1.0 + ); +} + +void main() { + + const float curveness = defaultCurveness; + + // Selecting the correct position + // Branchless "position = a_source if a_current == 1.0 else a_target" + vec2 position = a_source * max(0.0, a_current) + a_target * max(0.0, 1.0 - a_current); + position = (u_matrix * vec3(position, 1)).xy; + + vec2 source = (u_matrix * vec3(a_source, 1)).xy; + vec2 target = (u_matrix * vec3(a_target, 1)).xy; + + vec2 viewportPosition = clipspaceToViewport(position, u_dimensions); + vec2 viewportSource = clipspaceToViewport(source, u_dimensions); + vec2 viewportTarget = clipspaceToViewport(target, u_dimensions); + + vec2 delta = viewportTarget.xy - viewportSource.xy; + float len = length(delta); + vec2 normal = vec2(-delta.y, delta.x) * a_direction; + vec2 unitNormal = normal / len; + float boundingBoxThickness = len * curveness; + float curveThickness = max(minThickness, a_thickness / 2.0 / u_sizeRatio * u_pixelRatio); + + v_thickness = curveThickness; + + v_cpA = viewportSource; + v_cpB = 0.5 * (viewportSource + viewportTarget) + unitNormal * a_direction * boundingBoxThickness; + v_cpC = viewportTarget; + + vec2 viewportOffsetPosition = ( + viewportPosition + + unitNormal * (boundingBoxThickness / 2.0 + curveThickness + epsilon) * + max(0.0, a_direction) // NOTE: cutting the bounding box in half to avoid overdraw + ); + + position = viewportToClipspace(viewportOffsetPosition, u_dimensions); + gl_Position = vec4(position, 0, 1); + + v_color = a_color; + v_color.a *= bias; +} \ No newline at end of file diff --git a/client/src/utils/graphUtils.js b/client/src/utils/graphUtils.js index 746f8892..d77b5d7d 100644 --- a/client/src/utils/graphUtils.js +++ b/client/src/utils/graphUtils.js @@ -9,7 +9,7 @@ export const graphEncodeToJson = (data) => { label: node.attributes?.label, cluster: (node.attributes?.community ?? 0) + 1, weights: { Weight: node.attributes?.weight, Degree: node.attributes?.degree }, - scores: { 'Degree/weight': (node.attributes?.degree ?? 0) / (node.attributes?.weight || 1) }, + scores: { 'Last year': Math.max(Object.keys(node.attributes?.years ?? {})) ?? 0 }, }); }); diff --git a/package-lock.json b/package-lock.json index 298f5053..4bac2d44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "server" ], "dependencies": { + "colormap": "^2.3.2", "concurrently": "^8.0.1", "d3": "^7.8.5", "graphology": "^0.25.1", @@ -2459,6 +2460,14 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, + "node_modules/colormap": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/colormap/-/colormap-2.3.2.tgz", + "integrity": "sha512-jDOjaoEEmA9AgA11B/jCSAvYE95r3wRoAyTf3LEHGiUVlNHJaL1mRkf5AyLSpQBVGfTEPwGEqCIzL+kgr2WgNA==", + "dependencies": { + "lerp": "^1.0.3" + } + }, "node_modules/colorspace": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", @@ -5148,6 +5157,11 @@ "node": ">=0.10" } }, + "node_modules/lerp": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lerp/-/lerp-1.0.3.tgz", + "integrity": "sha512-70Rh4rCkJDvwWiTsyZ1HmJGvnyfFah4m6iTux29XmasRiZPDBpT9Cfa4ai73+uLZxnlKruUS62jj2lb11wURiA==" + }, "node_modules/leven": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/leven/-/leven-4.0.0.tgz", diff --git a/package.json b/package.json index 3808eba4..2d457530 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "server" ], "dependencies": { + "colormap": "^2.3.2", "concurrently": "^8.0.1", "d3": "^7.8.5", "graphology": "^0.25.1",