Skip to content

Commit 03ef2fe

Browse files
committed
[sigma] Adds more camera control settings
This commit fixes #1267. Details: - Adds camera.enableZooming and camera.enablePanning flags - Adds camera.clean optional function, that is used as a middleware on camera states - Adds new settings enableCameraZooming, enableCameraPanning and cameraPanBoundaries - Adds new story on camera controls
1 parent e1ec97d commit 03ef2fe

File tree

7 files changed

+332
-7
lines changed

7 files changed

+332
-7
lines changed

packages/sigma/src/core/camera.ts

+9-5
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ export default class Camera extends TypedEventEmitter<CameraEvents> implements C
3131

3232
minRatio: number | null = null;
3333
maxRatio: number | null = null;
34+
enabledZooming = true;
35+
enabledPanning = true;
3436
enabledRotation = true;
37+
clean: ((state: CameraState) => CameraState) | null = null;
3538

3639
private nextFrame: number | null = null;
3740
private previousState: CameraState | null = null;
@@ -120,11 +123,12 @@ export default class Camera extends TypedEventEmitter<CameraEvents> implements C
120123
*/
121124
validateState(state: Partial<CameraState>): Partial<CameraState> {
122125
const validatedState: Partial<CameraState> = {};
123-
if (typeof state.x === "number") validatedState.x = state.x;
124-
if (typeof state.y === "number") validatedState.y = state.y;
126+
if (this.enabledPanning && typeof state.x === "number") validatedState.x = state.x;
127+
if (this.enabledPanning && typeof state.y === "number") validatedState.y = state.y;
128+
if (this.enabledZooming && typeof state.ratio === "number")
129+
validatedState.ratio = this.getBoundedRatio(state.ratio);
125130
if (this.enabledRotation && typeof state.angle === "number") validatedState.angle = state.angle;
126-
if (typeof state.ratio === "number") validatedState.ratio = this.getBoundedRatio(state.ratio);
127-
return validatedState;
131+
return this.clean ? this.clean({ ...this.getState(), ...validatedState }) : validatedState;
128132
}
129133

130134
/**
@@ -146,8 +150,8 @@ export default class Camera extends TypedEventEmitter<CameraEvents> implements C
146150
const validState = this.validateState(state);
147151
if (typeof validState.x === "number") this.x = validState.x;
148152
if (typeof validState.y === "number") this.y = validState.y;
149-
if (this.enabledRotation && typeof validState.angle === "number") this.angle = validState.angle;
150153
if (typeof validState.ratio === "number") this.ratio = validState.ratio;
154+
if (typeof validState.angle === "number") this.angle = validState.angle;
151155

152156
// Emitting
153157
if (!this.hasState(this.previousState)) this.emit("updated", this.getState());

packages/sigma/src/settings.ts

+12-2
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
drawDiscNodeLabel,
2121
drawStraightEdgeLabel,
2222
} from "./rendering";
23-
import { EdgeDisplayData, NodeDisplayData } from "./types";
23+
import { AtLeastOne, EdgeDisplayData, NodeDisplayData } from "./types";
2424
import { assign } from "./utils";
2525

2626
/**
@@ -38,6 +38,7 @@ export interface Settings<
3838
renderLabels: boolean;
3939
renderEdgeLabels: boolean;
4040
enableEdgeEvents: boolean;
41+
4142
// Component rendering
4243
defaultNodeColor: string;
4344
defaultNodeType: string;
@@ -89,7 +90,13 @@ export interface Settings<
8990
zIndex: boolean;
9091
minCameraRatio: null | number;
9192
maxCameraRatio: null | number;
93+
enableCameraZooming: boolean;
94+
enableCameraPanning: boolean;
9295
enableCameraRotation: boolean;
96+
cameraPanBoundaries:
97+
| null
98+
| true
99+
| AtLeastOne<{ tolerance: number; boundaries: { x: [number, number]; y: [number, number] } }>;
93100

94101
// Lifecycle
95102
allowInvalidContainer: boolean;
@@ -159,7 +166,10 @@ export const DEFAULT_SETTINGS: Settings<Attributes, Attributes, Attributes> = {
159166
zIndex: false,
160167
minCameraRatio: null,
161168
maxCameraRatio: null,
169+
enableCameraZooming: true,
170+
enableCameraPanning: true,
162171
enableCameraRotation: true,
172+
cameraPanBoundaries: null,
163173

164174
// Lifecycle
165175
allowInvalidContainer: false,
@@ -201,7 +211,7 @@ export function resolveSettings<
201211
E extends Attributes = Attributes,
202212
G extends Attributes = Attributes,
203213
>(settings: Partial<Settings<N, E, G>>): Settings<N, E, G> {
204-
const resolvedSettings = assign({}, DEFAULT_SETTINGS, settings);
214+
const resolvedSettings = assign({}, DEFAULT_SETTINGS as Settings<N, E, G>, settings);
205215

206216
resolvedSettings.nodeProgramClasses = assign({}, DEFAULT_NODE_PROGRAM_CLASSES, resolvedSettings.nodeProgramClasses);
207217
resolvedSettings.edgeProgramClasses = assign({}, DEFAULT_EDGE_PROGRAM_CLASSES, resolvedSettings.edgeProgramClasses);

packages/sigma/src/sigma.ts

+96
Original file line numberDiff line numberDiff line change
@@ -878,7 +878,20 @@ export default class Sigma<
878878

879879
this.camera.minRatio = settings.minCameraRatio;
880880
this.camera.maxRatio = settings.maxCameraRatio;
881+
this.camera.enabledZooming = settings.enableCameraZooming;
882+
this.camera.enabledPanning = settings.enableCameraPanning;
881883
this.camera.enabledRotation = settings.enableCameraRotation;
884+
if (settings.cameraPanBoundaries) {
885+
this.camera.clean = (state) =>
886+
this.cleanCameraState(
887+
state,
888+
settings.cameraPanBoundaries && typeof settings.cameraPanBoundaries === "object"
889+
? settings.cameraPanBoundaries
890+
: {},
891+
);
892+
} else {
893+
this.camera.clean = null;
894+
}
882895
this.camera.setState(this.camera.validateState(this.camera.getState()));
883896

884897
if (oldSettings) {
@@ -920,6 +933,73 @@ export default class Sigma<
920933
return this;
921934
}
922935

936+
private cleanCameraState(
937+
state: CameraState,
938+
{ tolerance = 0, boundaries }: { tolerance?: number; boundaries?: Record<"x" | "y", [number, number]> } = {},
939+
): CameraState {
940+
const newState = { ...state };
941+
942+
// Extract necessary properties
943+
const {
944+
x: [xMinGraph, xMaxGraph],
945+
y: [yMinGraph, yMaxGraph],
946+
} = boundaries || this.nodeExtent;
947+
948+
// Transform the four corners of the graph rectangle using the provided camera state
949+
const corners = [
950+
this.graphToViewport({ x: xMinGraph, y: yMinGraph }, { cameraState: state }),
951+
this.graphToViewport({ x: xMaxGraph, y: yMinGraph }, { cameraState: state }),
952+
this.graphToViewport({ x: xMinGraph, y: yMaxGraph }, { cameraState: state }),
953+
this.graphToViewport({ x: xMaxGraph, y: yMaxGraph }, { cameraState: state }),
954+
];
955+
956+
// Look for new extents, based on these four corners
957+
let xMin = Infinity,
958+
xMax = -Infinity,
959+
yMin = Infinity,
960+
yMax = -Infinity;
961+
corners.forEach(({ x, y }) => {
962+
xMin = Math.min(xMin, x);
963+
xMax = Math.max(xMax, x);
964+
yMin = Math.min(yMin, y);
965+
yMax = Math.max(yMax, y);
966+
});
967+
968+
// For each dimension, constraint the smaller element (camera or graph) to fit in the larger one:
969+
const graphWidth = xMax - xMin;
970+
const graphHeight = yMax - yMin;
971+
const { width, height } = this.getDimensions();
972+
let dx = 0;
973+
let dy = 0;
974+
975+
if (graphWidth >= width) {
976+
if (xMax < width - tolerance) dx = xMax - (width - tolerance);
977+
else if (xMin > tolerance) dx = xMin - tolerance;
978+
} else {
979+
if (xMax > width + tolerance) dx = xMax - (width + tolerance);
980+
else if (xMin < -tolerance) dx = xMin + tolerance;
981+
}
982+
if (graphHeight >= height) {
983+
if (yMax < height - tolerance) dy = yMax - (height - tolerance);
984+
else if (yMin > tolerance) dy = yMin - tolerance;
985+
} else {
986+
if (yMax > height + tolerance) dy = yMax - (height + tolerance);
987+
else if (yMin < -tolerance) dy = yMin + tolerance;
988+
}
989+
990+
if (dx || dy) {
991+
// Transform [dx, dy] from viewport to graph (using two different point to transform that vector):
992+
const origin = this.viewportToFramedGraph({ x: 0, y: 0 }, { cameraState: state });
993+
const delta = this.viewportToFramedGraph({ x: dx, y: dy }, { cameraState: state });
994+
dx = delta.x - origin.x;
995+
dy = delta.y - origin.y;
996+
newState.x += dx;
997+
newState.y += dy;
998+
}
999+
1000+
return newState;
1001+
}
1002+
9231003
/**
9241004
* Method used to render labels.
9251005
*
@@ -1575,6 +1655,7 @@ export default class Sigma<
15751655
* Function used to create a canvas context and add the relevant DOM elements.
15761656
*
15771657
* @param {string} id - Context's id.
1658+
* @param options
15781659
* @return {Sigma}
15791660
*/
15801661
createCanvasContext(id: string, options: { style?: Partial<CSSStyleDeclaration> } = {}): this {
@@ -1875,6 +1956,21 @@ export default class Sigma<
18751956
return this;
18761957
}
18771958

1959+
/**
1960+
* Method setting multiple settings at once.
1961+
*
1962+
* @param {Partial<Settings>} settings - The settings to set.
1963+
* @return {Sigma}
1964+
*/
1965+
setSettings(settings: Partial<Settings<N, E, G>>): this {
1966+
const oldValues = { ...this.settings };
1967+
this.settings = { ...this.settings, ...settings };
1968+
validateSettings(this.settings);
1969+
this.handleSettingsUpdate(oldValues);
1970+
this.scheduleRefresh();
1971+
return this;
1972+
}
1973+
18781974
/**
18791975
* Method used to resize the renderer.
18801976
*

packages/sigma/src/types.ts

+5
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ export type PlainObject<T = any> = { [k: string]: T };
2121
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2222
export type PartialButFor<T, K extends keyof T> = Pick<T, K> & Partial<Omit<T, K>> & { [others: string]: any };
2323

24+
/**
25+
* Returns a type similar to Partial<T>, but with at least one key set.
26+
*/
27+
export type AtLeastOne<T, U = { [K in keyof T]: Pick<T, K> }> = Partial<T> & U[keyof U];
28+
2429
export type NonEmptyArray<T> = [T, ...T[]];
2530

2631
export interface Coordinates {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<style>
2+
html,
3+
body,
4+
#storybook-root,
5+
#sigma-container {
6+
width: 100%;
7+
height: 100%;
8+
margin: 0 !important;
9+
padding: 0 !important;
10+
overflow: hidden;
11+
font-family: sans-serif;
12+
}
13+
#controls {
14+
position: absolute;
15+
top: 10px;
16+
right: 10px;
17+
background: #ffffffcc;
18+
padding: 10px;
19+
}
20+
21+
input[type="number"] {
22+
width: 5em;
23+
}
24+
h4 {
25+
margin: 0;
26+
}
27+
fieldset {
28+
border: none;
29+
}
30+
h4,
31+
fieldset > div {
32+
margin-bottom: 0.2em;
33+
}
34+
button {
35+
margin-right: 0.1em;
36+
display: inline-block;
37+
text-align: center;
38+
background: white;
39+
outline: none;
40+
border: 1px solid dimgrey;
41+
border-radius: 2px;
42+
cursor: pointer;
43+
}
44+
</style>
45+
<div id="sigma-container"></div>
46+
<form id="controls" action="#">
47+
<fieldset>
48+
<h4>Interactions</h4>
49+
<div>
50+
<input type="checkbox" id="enable-panning" name="enable-panning" checked />
51+
<label for="enable-panning">Enable panning</label>
52+
</div>
53+
<div>
54+
<input type="checkbox" id="enable-zooming" name="enable-zooming" checked />
55+
<label for="enable-zooming">Enable zooming</label>
56+
</div>
57+
<div>
58+
<input type="checkbox" id="enable-rotation" name="enable-rotation" checked />
59+
<label for="enable-rotation">Enable camera rotations <br /><small>(for multitouch device only)</small></label>
60+
</div>
61+
</fieldset>
62+
<fieldset>
63+
<h4>Camera</h4>
64+
<div>
65+
<input type="number" id="min-ratio" name="min-ratio" value="0.08" min="0.001" step="0.001" />
66+
<label for="min-ratio">Minimum camera zoom ratio <br /><small>(leave empty for no limit)</small></label>
67+
</div>
68+
<div>
69+
<input type="number" id="max-ratio" name="max-ratio" value="3" min="0.001" step="0.001" />
70+
<label for="max-ratio">Maximum camera zoom ratio <br /><small>(leave empty for no limit)</small></label>
71+
</div>
72+
<div>
73+
<input type="checkbox" id="is-camera-bound" name="is-camera-bound" checked />
74+
<label for="is-camera-bound">Bound camera</label>
75+
</div>
76+
<div>
77+
<input type="number" id="tolerance" name="tolerance" min="0" step="1" value="500" />
78+
<label for="tolerance">Tolerance (in pixels)</label>
79+
</div>
80+
</fieldset>
81+
<fieldset>
82+
<button type="submit" disabled>Update sigma settings</button>
83+
</fieldset>
84+
</form>

0 commit comments

Comments
 (0)