Skip to content

Commit

Permalink
[edge-curve] First full featured version
Browse files Browse the repository at this point in the history
Details:
- Fixes broken shaders
- Adds indexParallelEdgesIndex to help drawing parallel edges as curves
  with varying curvatures
- Adds curved edges canvas label renderer
- Adds more examples on interactions, labels and parallel edges
  • Loading branch information
jacomyal committed Mar 13, 2024
1 parent 7be2cd1 commit ba91141
Show file tree
Hide file tree
Showing 11 changed files with 112,909 additions and 13 deletions.
146 changes: 146 additions & 0 deletions packages/edge-curve/src/edge-labels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { Attributes } from "graphology-types";
import { Settings } from "sigma/settings";
import { NodeDisplayData, PartialButFor } from "sigma/types";

import { CurvedEdgeDisplayData, DEFAULT_EDGE_CURVATURE } from "./utils.ts";

interface Point {
x: number;
y: number;
}

function getCurvePoint(t: number, p0: Point, p1: Point, p2: Point): Point {
const x = (1 - t) ** 2 * p0.x + 2 * (1 - t) * t * p1.x + t ** 2 * p2.x;
const y = (1 - t) ** 2 * p0.y + 2 * (1 - t) * t * p1.y + t ** 2 * p2.y;
return { x, y };
}

function getCurveLength(p0: Point, p1: Point, p2: Point): number {
const steps = 20;
let length = 0;
let lastPoint = p0;
for (let i = 0; i < steps; i++) {
const point = getCurvePoint((i + 1) / steps, p0, p1, p2);
length += Math.sqrt((lastPoint.x - point.x) ** 2 + (lastPoint.y - point.y) ** 2);
lastPoint = point;
}

return length;
}

export function drawCurvedEdgeLabel<
N extends Attributes = Attributes,
E extends Attributes = Attributes,
G extends Attributes = Attributes,
>(
context: CanvasRenderingContext2D,
edgeData: PartialButFor<CurvedEdgeDisplayData, "label" | "color" | "size" | "curvature">,
sourceData: PartialButFor<NodeDisplayData, "x" | "y" | "size">,
targetData: PartialButFor<NodeDisplayData, "x" | "y" | "size">,
settings: Settings<N, E, G>,
): void {
const size = settings.edgeLabelSize,
curvature = edgeData.curvature || DEFAULT_EDGE_CURVATURE,
font = settings.edgeLabelFont,
weight = settings.edgeLabelWeight,
color = settings.edgeLabelColor.attribute
? edgeData[settings.edgeLabelColor.attribute] || settings.edgeLabelColor.color || "#000"
: settings.edgeLabelColor.color;

let label = edgeData.label;

if (!label) return;

context.fillStyle = color;
context.font = `${weight} ${size}px ${font}`;

// Computing positions without considering nodes sizes:
let sourceX = sourceData.x;
let sourceY = sourceData.y;
let targetX = targetData.x;
let targetY = targetData.y;
const centerX = (sourceX + targetX) / 2;
const centerY = (sourceY + targetY) / 2;
const diffX = targetX - sourceX;
const diffY = targetY - sourceY;
const diff = Math.sqrt(diffX ** 2 + diffY ** 2);
// Anchor point:
let anchorX = centerX + diffY * curvature;
let anchorY = centerY - diffX * curvature;

// Adapt curve points to edge thickness:
const offset = edgeData.size * 0.7 + 5;
const sourceOffsetVector = {
x: anchorY - sourceY,
y: -(anchorX - sourceX),
};
const sourceOffsetVectorLength = Math.sqrt(sourceOffsetVector.x ** 2 + sourceOffsetVector.y ** 2);
const targetOffsetVector = {
x: targetY - anchorY,
y: -(targetX - anchorX),
};
const targetOffsetVectorLength = Math.sqrt(targetOffsetVector.x ** 2 + targetOffsetVector.y ** 2);
sourceX += (offset * sourceOffsetVector.x) / sourceOffsetVectorLength;
sourceY += (offset * sourceOffsetVector.y) / sourceOffsetVectorLength;
targetX += (offset * targetOffsetVector.x) / targetOffsetVectorLength;
targetY += (offset * targetOffsetVector.y) / targetOffsetVectorLength;
// For anchor, the vector is simpler, so it is inlined:
anchorX += (offset * diffY) / diff;
anchorY -= (offset * diffX) / diff;

// Compute curve length:
const anchorPoint = { x: anchorX, y: anchorY };
const sourcePoint = { x: sourceX, y: sourceY };
const targetPoint = { x: targetX, y: targetY };
const curveLength = getCurveLength(sourcePoint, anchorPoint, targetPoint);

if (curveLength < sourceData.size + targetData.size) return;

// Handling ellipsis
let textLength = context.measureText(label).width;
const availableTextLength = curveLength - sourceData.size - targetData.size;
if (textLength > availableTextLength) {
const ellipsis = "…";
label = label + ellipsis;
textLength = context.measureText(label).width;

while (textLength > availableTextLength && label.length > 1) {
label = label.slice(0, -2) + ellipsis;
textLength = context.measureText(label).width;
}

if (label.length < 4) return;
}

// Measure each character:
const charactersLengthCache: Record<string, number> = {};
for (let i = 0, length = label.length; i < length; i++) {
const character = label[i];

if (!charactersLengthCache[character]) {
charactersLengthCache[character] = context.measureText(character).width * (1 + curvature * 0.35);
}
}

// Draw each character:
let t = 0.5 - textLength / curveLength / 2;
for (let i = 0, length = label.length; i < length; i++) {
const character = label[i];
const point = getCurvePoint(t, sourcePoint, anchorPoint, targetPoint);

const tangentX = 2 * (1 - t) * (anchorX - sourceX) + 2 * t * (targetX - anchorX);
const tangentY = 2 * (1 - t) * (anchorY - sourceY) + 2 * t * (targetY - anchorY);
const angle = Math.atan2(tangentY, tangentX);

context.save();
context.translate(point.x, point.y);
context.rotate(angle);

// Dessiner le caractère
context.fillText(character, 0, 0);

context.restore();

t += charactersLengthCache[character] / curveLength;
}
}
12 changes: 8 additions & 4 deletions packages/edge-curve/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
import { Attributes } from "graphology-types";
import { EdgeProgram, ProgramInfo } from "sigma/rendering";
import { EdgeDisplayData, NodeDisplayData, RenderParams } from "sigma/types";
import { NodeDisplayData, RenderParams } from "sigma/types";
import { floatColor } from "sigma/utils";

import { drawCurvedEdgeLabel } from "./edge-labels.ts";
import FRAGMENT_SHADER_SOURCE from "./shader-frag";
import VERTEX_SHADER_SOURCE from "./shader-vert";
import { CurvedEdgeDisplayData, DEFAULT_EDGE_CURVATURE } from "./utils.ts";

const { UNSIGNED_BYTE, FLOAT } = WebGLRenderingContext;

const UNIFORMS = ["u_matrix", "u_sizeRatio", "u_dimensions", "u_pixelRatio"] as const;

const DEFAULT_EDGE_CURVATURE = 0.25;

export default class EdgeCurveProgram<
N extends Attributes = Attributes,
E extends Attributes = Attributes,
G extends Attributes = Attributes,
> extends EdgeProgram<(typeof UNIFORMS)[number], N, E, G> {
drawLabel = drawCurvedEdgeLabel;

getDefinition() {
return {
VERTICES: 6,
Expand Down Expand Up @@ -52,7 +54,7 @@ export default class EdgeCurveProgram<
startIndex: number,
sourceData: NodeDisplayData,
targetData: NodeDisplayData,
data: EdgeDisplayData & { curvature?: number },
data: CurvedEdgeDisplayData,
) {
const thickness = data.size || 1;
const x1 = sourceData.x;
Expand Down Expand Up @@ -84,3 +86,5 @@ export default class EdgeCurveProgram<
gl.uniform2f(u_dimensions, params.width * params.pixelRatio, params.height * params.pixelRatio);
}
}

export { indexParallelEdgesIndex, DEFAULT_EDGE_CURVATURE } from "./utils.ts";
4 changes: 2 additions & 2 deletions packages/edge-curve/src/shader-frag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ 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) {
vec2 getDistanceVector(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;
Expand All @@ -26,7 +26,7 @@ vec2 get_distance_vector(vec2 b0, vec2 b1, vec2 b2) {
}
float distToQuadraticBezierCurve(vec2 p, vec2 b0, vec2 b1, vec2 b2) {
return length(get_distance_vector(b0 - p, b1 - p, b2 - p));
return length(getDistanceVector(b0 - p, b1 - p, b2 - p));
}
const float epsilon = 0.7;
Expand Down
1 change: 1 addition & 0 deletions packages/edge-curve/src/shader-vert.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// language=GLSL
const VERTEX_SHADER_SOURCE = /*glsl*/ `
attribute vec4 a_id;
attribute vec4 a_color;
attribute float a_direction;
attribute float a_thickness;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import Graph from "graphology";
import Sigma from "sigma";

import EdgeCurveProgram from "../index.ts";
import data from "./data.json";
import data from "./data/les-miserables.json";
import "./stage.css";

const createPictogramsStage = () => {
const createStage = () => {
const stage = document.createElement("div");
stage.classList.add("stage");

Expand All @@ -24,17 +24,17 @@ const createPictogramsStage = () => {
return stage;
};

const meta: Meta<typeof createPictogramsStage> = {
const meta: Meta<typeof createStage> = {
title: "edge-curve",
render: () => createPictogramsStage(),
render: () => createStage(),
parameters: {
layout: "fullscreen",
},
};

export default meta;
type Story = StoryObj<typeof createPictogramsStage>;
type Story = StoryObj<typeof createStage>;

export const ComparisonExample: Story = {
name: "All features at once",
export const BasicExample: Story = {
name: "Basic example",
};
83 changes: 83 additions & 0 deletions packages/edge-curve/src/stories/Interactions.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Meta, StoryObj } from "@storybook/html";
import Graph from "graphology";
import Sigma from "sigma";
import { EdgeDisplayData, NodeDisplayData } from "sigma/types";

import EdgeCurveProgram from "../index.ts";
import data from "./data/les-miserables.json";
import "./stage.css";

const createStage = () => {
const stage = document.createElement("div");
stage.classList.add("stage");

const graph = new Graph();
graph.import(data);

let state: { type: "idle" } | { type: "hovered"; edge: string; source: string; target: string } = { type: "idle" };
const sigma = new Sigma(graph, stage, {
allowInvalidContainer: true,
enableEdgeEvents: true,
defaultEdgeType: "curve",
zIndex: true,
edgeProgramClasses: {
curve: EdgeCurveProgram,
},
edgeReducer: (edge, attributes) => {
const res: Partial<EdgeDisplayData> = { ...attributes };

if (state.type === "hovered") {
if (edge === state.edge) {
res.size = (res.size || 1) * 1.5;
res.zIndex = 1;
} else {
res.color = "#f0f0f0";
res.zIndex = 0;
}
}

return res;
},
nodeReducer: (node, attributes) => {
const res: Partial<NodeDisplayData> = { ...attributes };

if (state.type === "hovered") {
if (node === state.source || node === state.target) {
res.highlighted = true;
res.zIndex = 1;
} else {
res.label = undefined;
res.zIndex = 0;
}
}

return res;
},
});

sigma.on("enterEdge", ({ edge }) => {
state = { type: "hovered", edge, source: graph.source(edge), target: graph.target(edge) };
sigma.refresh();
});
sigma.on("leaveEdge", () => {
state = { type: "idle" };
sigma.refresh();
});

return stage;
};

const meta: Meta<typeof createStage> = {
title: "edge-curve",
render: () => createStage(),
parameters: {
layout: "fullscreen",
},
};

export default meta;
type Story = StoryObj<typeof createStage>;

export const InteractionsExample: Story = {
name: "Interactions",
};
59 changes: 59 additions & 0 deletions packages/edge-curve/src/stories/Labels.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Meta, StoryObj } from "@storybook/html";
import { MultiGraph } from "graphology";
import Sigma from "sigma";
import { EdgeArrowProgram } from "sigma/rendering";

import EdgeCurveProgram from "../index.ts";
import "./stage.css";

const createStage = () => {
const stage = document.createElement("div");
stage.classList.add("stage");

// Create a graph, with various parallel edges:
const graph = new MultiGraph();

graph.addNode("a", { x: 0, y: 0, size: 10, label: "Alexandra" });
graph.addNode("b", { x: 1, y: -1, size: 20, label: "Bastian" });
graph.addNode("c", { x: 3, y: -2, size: 10, label: "Charles" });
graph.addNode("d", { x: 1, y: -3, size: 10, label: "Dorothea" });
graph.addNode("e", { x: 3, y: -4, size: 20, label: "Ernestine" });
graph.addNode("f", { x: 4, y: -5, size: 10, label: "Fabian" });

graph.addEdge("a", "b", { forceLabel: true, size: 2, label: "works with" });
graph.addEdge("b", "c", { forceLabel: true, label: "works with", type: "curved", curvature: 0.5 });
graph.addEdge("b", "d", { forceLabel: true, label: "works with" });
graph.addEdge("c", "b", { forceLabel: true, size: 3, label: "works with", type: "curved" });
graph.addEdge("c", "e", { forceLabel: true, size: 3, label: "works with" });
graph.addEdge("d", "c", { forceLabel: true, label: "works with", type: "curved", curvature: 0.1 });
graph.addEdge("d", "e", { forceLabel: true, label: "works with", type: "curved", curvature: 1 });
graph.addEdge("e", "d", { forceLabel: true, size: 2, label: "works with", type: "curved" });
graph.addEdge("f", "e", { forceLabel: true, label: "works with", type: "curved" });

new Sigma(graph, stage, {
allowInvalidContainer: true,
defaultEdgeType: "straight",
renderEdgeLabels: true,
edgeProgramClasses: {
straight: EdgeArrowProgram,
curved: EdgeCurveProgram,
},
});

return stage;
};

const meta: Meta<typeof createStage> = {
title: "edge-curve",
render: () => createStage(),
parameters: {
layout: "fullscreen",
},
};

export default meta;
type Story = StoryObj<typeof createStage>;

export const LabelsExample: Story = {
name: "Labels",
};
Loading

0 comments on commit ba91141

Please sign in to comment.