Skip to content

Commit

Permalink
feat(graph): add last year color viz
Browse files Browse the repository at this point in the history
  • Loading branch information
ahonestla committed Jan 3, 2024
1 parent 81f39b3 commit 6a13da0
Show file tree
Hide file tree
Showing 9 changed files with 288 additions and 26 deletions.
43 changes: 25 additions & 18 deletions client/src/layout/Graph.jsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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();
Expand All @@ -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'] },
);
Expand All @@ -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]);
Expand All @@ -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 (
Expand All @@ -100,7 +94,8 @@ export default function Graph({ data, selectedGraph }) {
<SigmaContainer
style={{ height: '400px' }}
graph={selectedNode ? highlightGraph(graph, selectedNode) : graph}
settings={{ nodeProgramClasses: { border: NodeProgramBorder } }}
settings={{ nodeProgramClasses: { border: NodeProgramBorder },
edgeProgramClasses: { curve: EdgeProgramCurve } }}
>
<GraphEvents
onNodeClick={(event) => {
Expand All @@ -122,6 +117,18 @@ export default function Graph({ data, selectedGraph }) {
<ControlsContainer position="top-right">
<SearchControl style={{ width: '200px' }} />
</ControlsContainer>
<ControlsContainer position="bottom-left">
<div style={{ fontSize: 12 }}>
&nbsp;
{`Items: ${graph.order} | Links: ${graph.size} | Clusters: ${Object.keys(communities).length}`}
&nbsp;
</div>
</ControlsContainer>
<ControlsContainer position="top-left">
<Button size="sm" icon={(switchMode) ? 'ri-palette-line' : 'ri-palette-fill'} hasBorder={false} onClick={() => enableSwitchMode(!switchMode)}>
Last year
</Button>
</ControlsContainer>
</SigmaContainer>
</Col>
<Col n="12">
Expand Down
11 changes: 5 additions & 6 deletions client/src/layout/NodePanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,13 +18,13 @@ export default function NodePanel({ selectedNode, graph, publications }) {
<Badge colorFamily="yellow-tournesol" text={`${selectedNode.id}`} />
<Badge
colorFamily="orange-terre-battue"
text={`Last publication: ${selectedNode?.years ?? Math.max(
text={`Last publication: ${Object.keys(selectedNode?.years || {}) ?? Math.max(
...graph.getNodeAttribute(selectedNode.id, 'publications').map((publicationId) => publications[publicationId].year),
)}`}
/>
<Badge
colorFamily="blue-cumulus"
text={`Community ${GetColorName(COMMUNTIY_COLORS[selectedNode.community])} (${selectedNode.community})`}
text={`Community ${GetColorName(selectedNode.communityColor)} (${selectedNode.community})`}
/>
</BadgeGroup>
<Accordion className="fr-mt-1w">
Expand All @@ -38,9 +37,9 @@ export default function NodePanel({ selectedNode, graph, publications }) {
))}
</AccordionItem>
{(selectedNode.domains && (
<AccordionItem title={`${selectedNode.domains.lenght} domains`}>
{Object.entries(selectedNode.domains).map(({ key, value }) => (
<p>{`${key}: ${value}`}</p>
<AccordionItem title={`${Object.keys(selectedNode.domains).length} domains`}>
{Object.entries(selectedNode.domains).map((item) => (
<p>{`${item[0]} (${item[1]})`}</p>
))}
</AccordionItem>
))}
Expand Down
20 changes: 19 additions & 1 deletion client/src/styles/colors.js
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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,
});
42 changes: 42 additions & 0 deletions client/src/styles/rendering/edge.curve.frag.glsl
Original file line number Diff line number Diff line change
@@ -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;
}
}
102 changes: 102 additions & 0 deletions client/src/styles/rendering/edge.curve.js
Original file line number Diff line number Diff line change
@@ -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);
}
}


79 changes: 79 additions & 0 deletions client/src/styles/rendering/edge.curve.vert.glsl
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion client/src/utils/graphUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});
});

Expand Down
Loading

0 comments on commit 6a13da0

Please sign in to comment.