@gravity-ui/graph
Version:
Modern graph editor component
292 lines (291 loc) • 12.3 kB
JavaScript
import intersects from "intersects";
import { Emitter } from "../../utils/Emitter";
import { clamp } from "../../utils/functions/clamp";
export var ECameraScaleLevel;
(function (ECameraScaleLevel) {
ECameraScaleLevel[ECameraScaleLevel["Minimalistic"] = 100] = "Minimalistic";
ECameraScaleLevel[ECameraScaleLevel["Schematic"] = 200] = "Schematic";
ECameraScaleLevel[ECameraScaleLevel["Detailed"] = 300] = "Detailed";
})(ECameraScaleLevel || (ECameraScaleLevel = {}));
export const getInitCameraState = () => {
return {
/**
* Viewport of camera in canvas space
* x,y - center of camera(may be negative)
* width, height - size of viewport, equals to canvas w/h
* */
x: 0,
y: 0,
width: 0,
height: 0,
/**
* Viewport of camera in camera space
* relativeX, relativeY - center of camera
* relativeWidth, relativeHeight - size of viewport
*
* In easy words, it's a scale-aware viewport
*/
relativeX: 0,
relativeY: 0,
relativeWidth: 0,
relativeHeight: 0,
scale: 0.5,
scaleMax: 1,
scaleMin: 0.01,
viewportInsets: { left: 0, right: 0, top: 0, bottom: 0 },
autoPanningEnabled: false,
};
};
export class CameraService extends Emitter {
constructor(graph, state = getInitCameraState()) {
super();
this.graph = graph;
this.state = state;
}
resize(newState) {
const diffX = newState.width - this.state.width;
const diffY = newState.height - this.state.height;
this.set(newState);
this.move(diffX, diffY);
}
set(newState) {
this.graph.executеDefaultEventAction("camera-change", Object.assign({}, this.state, newState), () => {
this.state = Object.assign(this.state, newState);
this.updateRelative();
});
}
updateRelative() {
// Relative coordinates are based on full canvas viewport (ignore insets)
this.state.relativeX = this.getRelative(this.state.x) | 0;
this.state.relativeY = this.getRelative(this.state.y) | 0;
this.state.relativeWidth = this.getRelative(this.state.width) | 0;
this.state.relativeHeight = this.getRelative(this.state.height) | 0;
}
getCameraRect() {
const { x, y, width, height } = this.state;
return { x, y, width, height };
}
/**
* Returns the visible camera rect in screen space that accounts for the viewport insets.
* @returns {TRect} Visible rectangle inside the canvas after applying insets
*/
getVisibleCameraRect() {
const { x, y, width, height, viewportInsets } = this.state;
const visibleWidth = Math.max(0, width - viewportInsets.left - viewportInsets.right);
const visibleHeight = Math.max(0, height - viewportInsets.top - viewportInsets.bottom);
return {
x: x + viewportInsets.left,
y: y + viewportInsets.top,
width: visibleWidth,
height: visibleHeight,
};
}
/**
* Returns camera viewport rectangle in camera-relative space.
* By default returns full canvas-relative viewport (ignores insets).
* When options.respectInsets is true, returns viewport of the visible area (with insets applied).
* @param {Object} [options]
* @param {boolean} [options.respectInsets]
* @returns {TRect} Relative viewport rectangle
*/
getRelativeViewportRect(options) {
const useVisible = Boolean(options?.respectInsets);
if (!useVisible) {
return {
x: this.getRelative(this.state.x) | 0,
y: this.getRelative(this.state.y) | 0,
width: this.getRelative(this.state.width) | 0,
height: this.getRelative(this.state.height) | 0,
};
}
const insets = this.state.viewportInsets;
const visibleWidth = Math.max(0, this.state.width - insets.left - insets.right);
const visibleHeight = Math.max(0, this.state.height - insets.top - insets.bottom);
return {
x: this.getRelative(this.state.x + insets.left) | 0,
y: this.getRelative(this.state.y + insets.top) | 0,
width: this.getRelative(visibleWidth) | 0,
height: this.getRelative(visibleHeight) | 0,
};
}
getCameraScale() {
return this.state.scale;
}
getCameraBlockScaleLevel(cameraScale = this.getCameraScale()) {
const scales = this.graph.graphConstants.block.SCALES;
let scaleLevel = ECameraScaleLevel.Minimalistic;
if (cameraScale >= scales[1]) {
scaleLevel = ECameraScaleLevel.Schematic;
}
if (cameraScale >= scales[2]) {
scaleLevel = ECameraScaleLevel.Detailed;
}
return scaleLevel;
}
getCameraState() {
return this.state;
}
move(dx = 0, dy = 0) {
const x = (this.state.x + dx) | 0;
const y = (this.state.y + dy) | 0;
this.set({
x,
y,
});
}
getRelative(n, scale = this.state.scale) {
return n / scale;
}
getRelativeXY(x, y) {
return [(x - this.state.x) / this.state.scale, (y - this.state.y) / this.state.scale];
}
/**
* Converts relative coordinate to absolute (screen space).
* Inverse of getRelative.
* @param {number} n Relative coordinate
* @param {number} [scale=this.state.scale] Scale to use for conversion
* @returns {number} Absolute coordinate in screen space
*/
getAbsolute(n, scale = this.state.scale) {
return n * scale;
}
/**
* Converts relative coordinates to absolute (screen space).
* Inverse of getRelativeXY.
* @param {number} x Relative x
* @param {number} y Relative y
* @returns {number[]} Absolute [x, y] in screen space
*/
getAbsoluteXY(x, y) {
return [x * this.state.scale + this.state.x, y * this.state.scale + this.state.y];
}
/**
* Zoom to a screen point.
* @param {number} x Screen x where zoom anchors
* @param {number} y Screen y where zoom anchors
* @param {number} scale Target scale value
* @returns {void}
*/
zoom(x, y, scale) {
const normalizedScale = clamp(scale, this.state.scaleMin, this.state.scaleMax);
const dx = this.getRelative(x - this.state.x);
const dy = this.getRelative(y - this.state.y);
const dxInNextScale = this.getRelative(x - this.state.x, normalizedScale);
const dyInNextScale = this.getRelative(y - this.state.y, normalizedScale);
const nextX = this.state.x + (dxInNextScale - dx) * normalizedScale;
const nextY = this.state.y + (dyInNextScale - dy) * normalizedScale;
this.set({
scale: normalizedScale,
x: nextX,
y: nextY,
});
}
getScaleRelativeDimensionsBySide(size, axis, options) {
const useVisible = Boolean(options?.respectInsets);
const insets = this.state.viewportInsets;
let viewportSize;
if (axis === "width") {
viewportSize = useVisible ? Math.max(0, this.state.width - insets.left - insets.right) : this.state.width;
}
else {
viewportSize = useVisible ? Math.max(0, this.state.height - insets.top - insets.bottom) : this.state.height;
}
return clamp(Number(viewportSize / size), this.state.scaleMin, this.state.scaleMax);
}
getScaleRelativeDimensions(width, height, options) {
return Math.min(this.getScaleRelativeDimensionsBySide(width, "width", options), this.getScaleRelativeDimensionsBySide(height, "height", options));
}
getXYRelativeCenterDimensions(dimensions, scale, options) {
const useVisible = Boolean(options?.respectInsets);
const insets = this.state.viewportInsets;
const centerX = useVisible
? insets.left + Math.max(0, this.state.width - insets.left - insets.right) / 2
: this.state.width / 2;
const centerY = useVisible
? insets.top + Math.max(0, this.state.height - insets.top - insets.bottom) / 2
: this.state.height / 2;
const x = 0 - dimensions.x * scale - (dimensions.width / 2) * scale + centerX;
const y = 0 - dimensions.y * scale - (dimensions.height / 2) * scale + centerY;
return { x, y };
}
isRectVisible(x, y, w, h) {
// Shift by relative viewport origin (full viewport, without insets). Insets are irrelevant for visibility.
return intersects.boxBox(x + this.state.relativeX, y + this.state.relativeY, w, h, 0, 0, this.state.relativeWidth, this.state.relativeHeight);
}
isLineVisible(x1, y1, x2, y2) {
// because the camera coordinates are inverted
return intersects.lineBox(-x1, -y1, -x2, -y2, this.state.relativeX - this.state.relativeWidth, this.state.relativeY - this.state.relativeHeight, this.state.relativeWidth, this.state.relativeHeight);
}
applyToPoint(x, y) {
return [(this.getRelative(x) - this.state.relativeX) | 0, (this.getRelative(y) - this.state.relativeY) | 0];
}
applyToRect(x, y, w, h) {
return this.applyToPoint(x, y).concat(Math.floor(this.getRelative(w)), Math.floor(this.getRelative(h)));
}
/**
* Update viewport insets (screen-space paddings inside canvas) and optionally keep the
* world point under the visible center unchanged.
* @param {Object} insets Partial insets to update
* @param {number} [insets.left]
* @param {number} [insets.right]
* @param {number} [insets.top]
* @param {number} [insets.bottom]
* @param {string} [maintain=center] Preserve visual anchor; allowed values: center or none. "center" keeps the
* same world point under visible center
* @returns {void}
*/
setViewportInsets(insets, params) {
const currentInsets = this.state.viewportInsets;
const nextInsets = {
left: insets.left ?? currentInsets.left,
right: insets.right ?? currentInsets.right,
top: insets.top ?? currentInsets.top,
bottom: insets.bottom ?? currentInsets.bottom,
};
if (params?.maintain === "center") {
const oldVisibleWidth = Math.max(0, this.state.width - currentInsets.left - currentInsets.right);
const oldVisibleHeight = Math.max(0, this.state.height - currentInsets.top - currentInsets.bottom);
const oldCenterX = currentInsets.left + oldVisibleWidth / 2;
const oldCenterY = currentInsets.top + oldVisibleHeight / 2;
const [anchorWorldX, anchorWorldY] = this.getRelativeXY(oldCenterX, oldCenterY);
const newVisibleWidth = Math.max(0, this.state.width - nextInsets.left - nextInsets.right);
const newVisibleHeight = Math.max(0, this.state.height - nextInsets.top - nextInsets.bottom);
const newCenterX = nextInsets.left + newVisibleWidth / 2;
const newCenterY = nextInsets.top + newVisibleHeight / 2;
const nextX = newCenterX - anchorWorldX * this.state.scale;
const nextY = newCenterY - anchorWorldY * this.state.scale;
this.set({ viewportInsets: nextInsets, x: nextX, y: nextY });
return;
}
this.set({ viewportInsets: nextInsets });
}
/**
* Returns current viewport insets.
* @returns {{left: number, right: number, top: number, bottom: number}} Current insets of visible viewport
*/
getViewportInsets() {
return this.state.viewportInsets;
}
/**
* Enable auto-panning mode.
* When enabled, the camera will automatically pan when the mouse is near the viewport edges.
* @returns {void}
*/
enableAutoPanning() {
this.set({ autoPanningEnabled: true });
}
/**
* Disable auto-panning mode.
* @returns {void}
*/
disableAutoPanning() {
this.set({ autoPanningEnabled: false });
}
/**
* Check if auto-panning mode is enabled.
* @returns {boolean} True if auto-panning is enabled
*/
isAutoPanningEnabled() {
return this.state.autoPanningEnabled;
}
}