@scidian/osui
Version:
Lightweight JavaScript UI library.
759 lines (697 loc) • 32.4 kB
JavaScript
import { Canvas } from '../core/Canvas.js';
import { ColorScheme } from '../utils/ColorScheme.js';
import { Css } from '../utils/Css.js';
import { Div } from '../core/Div.js';
import { Interaction } from '../utils/Interaction.js';
import { Iris } from '../utils/Iris.js';
import { Panel } from '../panels/Panel.js';
import { GRAPH_GRID_TYPES, GRAPH_LINE_TYPES, GRID_SIZE, NODE_TYPES, RESIZERS, TRAIT } from '../constants.js';
const MIN_W = 100;
const MIN_H = 100;
const MAP_BUFFER = 100;
const MIN_MAP_SIZE = 1200;
const ANIMATE_INTERVAL = 4; /* ms */
const ZOOM_MAX = 4;
const ZOOM_MIN = 0.1;
const _color = new Iris();
class Graph extends Panel {
#scale = 1;
#offset = { x: 0, y: 0 };
#previous = { x: 0, y: 0 };
#drawWidth = 0;
#drawHeight = 0;
constructor({
snapToGrid = true,
curveType = GRAPH_LINE_TYPES.CURVE,
gridType = GRAPH_GRID_TYPES.LINES,
} = {}) {
super();
const self = this;
// Properties
this.activeItem = undefined; // Item user is trying to connect from
this.connectItem = undefined; // Item user is trying to connect to
this.activePoint = { x: 0, y: 0 };
this.curveType = curveType;
this.gridType = gridType;
this.snapToGrid = snapToGrid;
// Elements
this.input = new Div().setClass('osui-graph-input');
this.grid = new Div().setClass('osui-graph-grid');
this.nodes = new Div().setClass('osui-graph-nodes');
this.lines = new Canvas(2048, 2048).setClass('osui-graph-lines');
this.bandbox = new Div().setClass('osui-graph-band-box');
this.minimap = new Div().setClass('osui-mini-map');
this.add(this.input, this.grid, this.lines, this.nodes, this.bandbox, this.minimap);
// MiniMap
this.mapCanvas = new Canvas(1024, 1024).setClass('osui-mini-map-canvas');
const mapResizers = new Div().addClass('osui-mini-map-resizers');
this.minimap.add(this.mapCanvas, mapResizers);
// Draw Grid Image
this.changeGridType(gridType);
// Mouse Wheel Zoom
function graphMouseZoom(event) {
event.preventDefault();
const delta = (event.deltaY * 0.001);
self.stopAnimation();
self.zoomTo(self.#scale - delta, event.clientX, event.clientY);
};
this.onWheel(graphMouseZoom);
// Window Resize
function onWindowResize() {
self.stopAnimation();
self.zoomTo();
}
window.addEventListener('resize', onWindowResize);
// Key Events
let grabbing = false, selecting = false;
let spaceKey = false;
function graphKeyDown(event) {
if (self.isHidden()) return;
if (event.key === ' ') {
spaceKey = true;
self.dom.style.cursor = (grabbing) ? 'grabbing' : 'grab';
self.input.setStyle('z-index', '100');
}
}
function graphKeyUp(event) {
if (self.isHidden()) return;
if (event.key === ' ') {
spaceKey = false;
self.dom.style.cursor = 'auto';
self.input.setStyle('z-index', '-1');
}
}
document.addEventListener('keydown', graphKeyDown);
document.addEventListener('keyup', graphKeyUp);
// Pointer Events (Translate / Select / RubberbandBox)
let offset = { x: 0, y: 0 };
let startPoint = { x: 0, y: 0 };
function inputPointerDown(event) {
self.stopAnimation();
startPoint.x = event.clientX;
startPoint.y = event.clientY;
if (event.button === 2 || (event.button === 0 && spaceKey)) {
grabbing = true;
self.dom.style.cursor = 'grabbing';
offset.x = self.#offset.x;
offset.y = self.#offset.y;
} else if (event.button === 0) {
selecting = true;
const selected = document.querySelectorAll(`.osui-node-selected`);
selected.forEach(el => el.classList.remove('osui-node-selected'));
self.bandbox.setStyle('display', 'block');
updateRubberbandBox(event.clientX, event.clientY);
}
if (grabbing || selecting) {
self.dom.setPointerCapture(event.pointerId);
self.dom.ownerDocument.addEventListener('pointermove', inputPointerMove);
self.dom.ownerDocument.addEventListener('pointerup', inputPointerUp);
}
}
function inputPointerUp(event) {
event.stopPropagation();
event.preventDefault();
self.dom.releasePointerCapture(event.pointerId);
if (grabbing) {
self.dom.style.cursor = (spaceKey) ? 'grab' : 'auto';
grabbing = false;
}
if (selecting) {
self.dom.dispatchEvent(new Event('selected'));
self.bandbox.setStyle('display', 'none');
selecting = false;
}
self.dom.ownerDocument.removeEventListener('pointermove', inputPointerMove);
self.dom.ownerDocument.removeEventListener('pointerup', inputPointerUp);
}
function inputPointerMove(event) {
event.stopPropagation();
event.preventDefault();
if (grabbing) {
const diffX = (event.clientX - startPoint.x) * (1 / self.#scale);
const diffY = (event.clientY - startPoint.y) * (1 / self.#scale);
self.#offset.x = (offset.x + diffX);
self.#offset.y = (offset.y + diffY);
self.zoomTo();
} else if (selecting) {
updateRubberbandBox(event.clientX, event.clientY);
}
}
function updateRubberbandBox(toX, toY) {
// Set rubberband box size
const inputRect = self.input.dom.getBoundingClientRect();
const left = Math.min(startPoint.x, toX) - inputRect.left;
const top = Math.min(startPoint.y, toY) - inputRect.top;
const width = Math.abs(startPoint.x - toX);
const height = Math.abs(startPoint.y - toY);
self.bandbox.setStyle(
'left', `${left}px`,
'top', `${top}px`,
'width', `${width}px`,
'height', `${height}px`,
);
// Translate to node coordinates
const rect = self.dom.getBoundingClientRect();
const centerX = rect.left + ((rect.right - rect.left) / 2);
const centerY = rect.top + ((rect.bottom - rect.top) / 2);
const percentX = (centerX - left) / centerX;
const percentY = (centerY - top) / centerY;
const xMin = (centerX - (((rect.width / self.#scale) / 2) * percentX)) - self.#offset.x;
const yMin = (centerY - (((rect.height / self.#scale) / 2) * percentY)) - self.#offset.y;
const xMax = xMin + (width / self.#scale);
const yMax = yMin + (height / self.#scale);
function rubberbandIntersect(node) {
return xMax >= node.left && xMin <= node.right && yMin <= node.bottom && yMax >= node.top;
}
// Select
const selected = [];
const nodes = self.getNodes();
nodes.forEach((node) => { if (rubberbandIntersect(node)) selected.push(node); });
nodes.forEach((node) => {
if (selected.includes(node)) node.addClass('osui-node-selected');
else node.removeClass('osui-node-selected');
});
}
this.input.onPointerDown(inputPointerDown);
// Minimap Resizers
let rect = {};
function resizerDown() {
rect = self.minimap.dom.getBoundingClientRect();
}
function resizerMove(resizer, diffX, diffY) {
if (resizer.hasClassWithString('left')) {
const newLeft = Math.max(0, Math.min(rect.right - MIN_W, rect.left + diffX));
const newWidth = rect.right - newLeft;
self.minimap.setStyle('left', `${newLeft}px`);
self.minimap.setStyle('width', `${newWidth}px`);
}
if (resizer.hasClassWithString('top')) {
const newTop = Math.max(0, Math.min(rect.bottom - MIN_H, rect.top + diffY));
const newHeight = rect.bottom - newTop;
self.minimap.setStyle('top', `${newTop}px`);
self.minimap.setStyle('height', `${newHeight}px`);
}
if (resizer.hasClassWithString('right')) {
const newWidth = Math.min(Math.max(MIN_W, rect.width + diffX), window.innerWidth - rect.left);
self.minimap.setStyle('width', `${newWidth}px`);
}
if (resizer.hasClassWithString('bottom')) {
const newHeight = Math.min(Math.max(MIN_H, rect.height + diffY), window.innerHeight - rect.top);
self.minimap.setStyle('height', `${newHeight}px`);
}
self.drawMiniMap();
self.drawLines();
}
Interaction.makeResizeable(this.minimap, mapResizers, [ RESIZERS.LEFT, RESIZERS.TOP ], resizerDown, resizerMove);
// Minimap Pointer Events
let translating = false;
function calculateOffset(clientX, clientY) {
const mapRect = self.minimap.dom.getBoundingClientRect();
const nodesRect = self.nodes.dom.getBoundingClientRect();
const percentX = ((mapRect.width / 2) - (clientX - mapRect.left)) / (mapRect.width / 2);
const percentY = ((mapRect.height / 2) - (clientY - mapRect.top)) / (mapRect.height / 2);
const bounds = self.nodeBounds(MAP_BUFFER, self.nodes.children, MIN_MAP_SIZE);
if (!bounds.isFinite) return;
// Empty space on top and bottom
if (self.#drawWidth > self.#drawHeight) {
const ratio = (self.#drawWidth / self.#drawHeight);
const x = bounds.center().x - ((bounds.width() / 2) * percentX);
const y = bounds.center().y - ((bounds.height() / 2) * ratio * percentY);
self.#offset.x = ((nodesRect.width / 2) / self.#scale) - x;
self.#offset.y = ((nodesRect.height / 2) / self.#scale) - y;
// Empty space on sides
} else {
const ratio = (self.#drawHeight / self.#drawWidth);
const x = bounds.center().x - ((bounds.width() / 2) * ratio * percentX);
const y = bounds.center().y - ((bounds.height() / 2) * percentY);
self.#offset.x = ((nodesRect.width / 2) / self.#scale) - x;
self.#offset.y = ((nodesRect.height / 2) / self.#scale) - y;
}
self.zoomTo();
}
function mapPointerDown(event) {
self.stopAnimation();
self.minimap.dom.setPointerCapture(event.pointerId);
self.minimap.setStyle('cursor', 'grabbing');
calculateOffset(event.clientX, event.clientY);
translating = true;
}
function mapPointerUp(event) {
self.minimap.dom.releasePointerCapture(event.pointerId);
self.minimap.setStyle('cursor', 'grab');
translating = false
}
function mapPointerMove(event) {
if (!translating) return;
calculateOffset(event.clientX, event.clientY);
}
this.minimap.onPointerDown(mapPointerDown);
this.minimap.onPointerUp(mapPointerUp);
this.minimap.onPointerMove(mapPointerMove);
} // end ctor
/******************** GET / SET ********************/
getScale() {
return this.#scale;
}
/******************** NODES ********************/
addNode(/* node, node, node, ... */) {
for (let i = 0, l = arguments.length; i < l; i++) {
const node = arguments[i];
if (this.nodes) {
node.graph = this;
this.nodes.add(node);
node.setStyle('zIndex', this.nodes.children.length);
}
}
// Redraw
this.drawMiniMap();
this.drawLines();
}
getNodes() {
return this.nodes.children;
}
removeNode(nodeToRemove) {
if (!nodeToRemove || !nodeToRemove.isElement) return;
const currentZ = nodeToRemove.zIndex;
const lengthBefore = this.nodes.children.length;
this.nodes.remove(nodeToRemove);
const lengthAfter = this.nodes.children.length;
if (lengthBefore === lengthAfter) return;
// Adjust z stack
this.nodes.children.forEach((node) => {
if (node.zIndex > currentZ) node.setStyle('zIndex', `${node.zIndex - 1}`);
});
// Redraw
this.drawMiniMap();
this.drawLines();
}
nodeBounds(buffer = 0, nodes = this.nodes.children, minSize = undefined) {
const bounds = {
x: { min: Infinity, max: -Infinity },
y: { min: Infinity, max: -Infinity },
isFinite: false,
center: function() { return { x: 0, y: 0 }; },
};
nodes.forEach((node) => {
bounds.x.min = Math.min(bounds.x.min, node.left);
bounds.x.max = Math.max(bounds.x.max, node.right);
bounds.y.min = Math.min(bounds.y.min, node.top);
bounds.y.max = Math.max(bounds.y.max, node.bottom);
});
if ((bounds.x.max > bounds.x.min) && (bounds.y.max > bounds.y.min)) {
bounds.isFinite = true;
bounds.center = function() {
const x = bounds.x.min + ((bounds.x.max - bounds.x.min) / 2);
const y = bounds.y.min + ((bounds.y.max - bounds.y.min) / 2);
return { x, y };
};
bounds.width = () => { return (bounds.x.max - bounds.x.min); };
bounds.height = () => { return (bounds.y.max - bounds.y.min); };
bounds.x.min -= buffer; bounds.x.max += buffer;
bounds.y.min -= buffer; bounds.y.max += buffer;
if (minSize) {
if (bounds.width() < minSize) {
const addX = (minSize - bounds.width()) / 2;
bounds.x.min -= addX;
bounds.x.max += addX;
}
if (bounds.height() < minSize) {
const addY = (minSize - bounds.height()) / 2;
bounds.y.min -= addY;
bounds.y.max += addY;
}
}
}
return bounds;
}
traverseNodes(callback) {
if (typeof callback !== 'function') return;
if (!this.nodes) return;
const nodes = this.nodes.children;
// Sort by zIndex, low to high
nodes.sort((x, y) => x.zIndex - y.zIndex);
// Traverse
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (node && node.isNode) callback(node);
}
}
/******************** DRAW: GRID ********************/
changeGridType(type = GRAPH_GRID_TYPES.LINES) {
const SIZE = GRID_SIZE * 4;
const HALF = SIZE / 2;
const BORDER = 0;
const B2 = BORDER * 2;
this.gridType = type;
if (type === GRAPH_GRID_TYPES.LINES) {
const squares = new Canvas(SIZE, SIZE);
const ctx = squares.ctx;
ctx.clearRect(0, 0, squares.width, squares.height); /* background: darkness */
ctx.globalAlpha = 0.55;
ctx.fillStyle = _color.set(ColorScheme.color(TRAIT.BUTTON_LIGHT)).cssString();
ctx.fillRect(0 + BORDER, 0 + BORDER, HALF - B2, HALF - B2);
ctx.fillRect(HALF + BORDER, HALF + BORDER, HALF - B2, HALF - B2);
ctx.globalAlpha = 0.45;
ctx.fillStyle = _color.set(ColorScheme.color(TRAIT.BUTTON_LIGHT)).cssString();
ctx.fillRect(HALF + BORDER, 0 + BORDER, HALF - B2, HALF - B2);
ctx.fillRect(0 + BORDER, HALF + BORDER, HALF - B2, HALF - B2);
ctx.globalAlpha = 1;
ctx.lineWidth = 0;
ctx.strokeStyle = _color.set(ColorScheme.color(TRAIT.BACKGROUND_LIGHT)).cssString();
ctx.strokeRect(0, 0, HALF, HALF);
ctx.strokeRect(HALF, HALF, HALF, HALF);
ctx.strokeRect(HALF, 0, HALF, HALF);
ctx.strokeRect(0, HALF, HALF, HALF);
this.grid.setStyle('background-image', `url('${squares.dom.toDataURL()}')`);
this.grid.setStyle('background-size', `${(GRID_SIZE * this.#scale * 2)}px`);
} else if (type === GRAPH_GRID_TYPES.DOTS) {
const radius = SIZE / 25;
const dots = new Canvas(SIZE, SIZE);
const ctx = dots.ctx;
ctx.globalAlpha = 0.5;
ctx.fillStyle = _color.set(ColorScheme.color(TRAIT.BUTTON_LIGHT)).cssString();
ctx.fillRect(0, 0, dots.width, dots.height);
ctx.fillStyle = _color.set(ColorScheme.color(TRAIT.BUTTON_LIGHT)).cssString();
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
ctx.beginPath();
ctx.ellipse(HALF * i, HALF * j, radius, radius, 0, 0, Math.PI * 2);
ctx.fill();
}
}
this.grid.setStyle('background-image', `url('${dots.dom.toDataURL()}')`);
this.grid.setStyle('background-size', `${(GRID_SIZE * this.#scale * 2)}px`);
}
}
/******************** DRAW: LINES ********************/
connect() {
if (this.activeItem && this.connectItem) {
const active = this.activeItem;
const connect = this.connectItem;
if (active.type === NODE_TYPES.OUTPUT) {
active.connect(connect);
} else if (connect.type === NODE_TYPES.OUTPUT) {
connect.connect(active);
}
// // DEBUG: Node names
// console.log(`${active.node.name}:${active.type} to ${connect.node.name}:${connect.type}`);
}
this.activeItem = undefined;
this.connectItem = undefined;
this.drawLines();
}
disconnect(item) {
this.traverseNodes((node) => {
node.outputList.children.forEach((output) => {
const index = output.connections.indexOf(item);
if (index > -1) {
output.connections.splice(index, 1);
item.reduceIncoming();
}
if (output.connections.length === 0) {
output.removeClass('osui-item-connected');
}
});
});
}
drawLines() {
if (!this.lines) return;
if (this.isHidden()) return;
const LINE_THICKNESS = 4;
const self = this;
const lines = this.lines;
const linesRect = lines.dom.getBoundingClientRect();
const xMin = linesRect.left;
const xMax = linesRect.right;
const yMin = linesRect.top;
const yMax = linesRect.bottom;
const ctx = lines.ctx;
ctx.clearRect(0, 0, lines.width, lines.height);
function scaleX(x) { return (x / linesRect.width) * lines.width; }
function scaleY(y) { return (y / linesRect.height) * lines.height; }
function drawLine(x1, y1, x2, y2, color1, color2 = color1) {
if (!Number.isFinite(x1) || Number.isNaN(x1)) return;
if (!Number.isFinite(x2) || Number.isNaN(x2)) return;
if (!Number.isFinite(y1) || Number.isNaN(y1)) return;
if (!Number.isFinite(y2) || Number.isNaN(y2)) return;
ctx.strokeStyle = color1;
if (color2 != null && color1 !== color2) {
const gradient = ctx.createLinearGradient(x1, y1, x2, y2);
gradient.addColorStop(0, color1);
gradient.addColorStop(1, color2);
ctx.strokeStyle = gradient;
}
ctx.lineWidth = LINE_THICKNESS * self.#scale;
ctx.beginPath();
ctx.moveTo(x1, y1);
switch (self.curveType) {
case GRAPH_LINE_TYPES.STRAIGHT:
ctx.lineTo(x2, y2);
break;
case GRAPH_LINE_TYPES.ZIGZAG:
const xOffset = Math.abs((x2 - x1) * 0.25);
ctx.lineTo(x1 + xOffset, y1);
ctx.lineTo(x2 - xOffset, y2);
ctx.lineTo(x2, y2);
break;
case GRAPH_LINE_TYPES.CURVE:
default:
const curveOffset = Math.abs((x2 - x1) * 0.5);
ctx.bezierCurveTo(x1 + curveOffset, y1, x2 - curveOffset, y2, x2, y2);
break;
}
ctx.stroke();
}
function drawConnection(x1, y1, x2, y2, radius = 10, color1 = '#ffffff', color2 = color1, drawPoints = false) {
// Check that line will show
const left = (x1 < x2) ? x1 : x2;
const right = (x1 < x2) ? x2 : x1;
const top = (y1 < y2) ? y1 : y2;
const bottom = (y1 < y2) ? y2 : y1;
if (! (xMax >= left && xMin <= right && yMin <= bottom && yMax >= top)) return;
// Scale points
x1 = scaleX(x1);
y1 = scaleY(y1);
x2 = scaleX(x2);
y2 = scaleY(y2);
// Draw
ctx.globalAlpha = 1.0;
if (drawPoints) {
const radiusX = scaleX(radius);
const radiusY = scaleY(radius);
ctx.fillStyle = color1;
ctx.beginPath();
ctx.ellipse(x1, y1, radiusX, radiusY, 0 /* rotation */, 0, 2 * Math.PI);
ctx.fill();
ctx.fillStyle = color2;
ctx.beginPath();
ctx.ellipse(x2, y2, radiusX, radiusY, 0 /* rotation */, 0, 2 * Math.PI);
ctx.fill();
}
drawLine(x1, y1, x2, y2, color1, color2);
}
// Point Size
const pointSize = parseFloat(Css.toPx('0.21429em', this.dom)) * this.#scale;
// Node lines
this.traverseNodes((node) => {
if (!node.outputList) return;
node.outputList.children.forEach((item) => {
const rectOut = item.point.dom.getBoundingClientRect();
const x1 = rectOut.left + (rectOut.width / 2);
const y1 = rectOut.top + (rectOut.height / 2);
const color1 = item.node.colorString();
item.connections.forEach((input) => {
const rectIn = input.point.dom.getBoundingClientRect();
const x2 = rectIn.left + (rectIn.width / 2);
const y2 = rectIn.top + (rectIn.height / 2);
const color2 = input.node.colorString();
drawConnection(x1, y1, x2, y2, pointSize, color1, color2);
})
});
});
// Active line
if (this.activeItem) {
const rect = this.activeItem.point.dom.getBoundingClientRect();
const x1 = rect.left + (rect.width / 2);
const y1 = rect.top + (rect.height / 2);
const x2 = this.activePoint.x;
const y2 = this.activePoint.y;
const color = this.activeItem.node.colorString();
const forward = this.activeItem.type === NODE_TYPES.OUTPUT;
const drawPoints = !this.connectItem;
if (forward) drawConnection(x1, y1, x2, y2, pointSize, color, color, drawPoints);
else drawConnection(x2, y2, x1, y1, pointSize, color, color, drawPoints);
}
}
/******************** DRAW: MINI MAP ********************/
drawMiniMap() {
if (!this.mapCanvas) return;
if (this.isHidden()) return;
// Clear
const map = this.mapCanvas;
const ctx = map.ctx;
ctx.clearRect(0, 0, map.width, map.height);
// Bounds
const bounds = this.nodeBounds(MAP_BUFFER, this.nodes.children, MIN_MAP_SIZE);
if (!bounds.isFinite) return;
// Aspect Ratio
this.#drawWidth = map.width;
this.#drawHeight = map.height;
let adjustX = 0, adjustY = 0;
const ratioX = map.width / bounds.width();
const ratioY = (map.height / bounds.height()) * this.mapCanvas.ratio();
if (ratioX > ratioY) {
this.#drawWidth *= (ratioY / ratioX);
adjustX = (this.#drawWidth - map.width) / 2;
} else {
this.#drawHeight *= (ratioX / ratioY);
adjustY = (this.#drawHeight - map.height) / 2;
}
// Draw View
const rect = this.dom.getBoundingClientRect();
const scaled = {};
const centerX = rect.left + ((rect.right - rect.left) / 2);
const centerY = rect.top + ((rect.bottom - rect.top) / 2);
scaled.left = (centerX - ((rect.width / this.#scale) / 2)) - this.#offset.x;
scaled.top = (centerY - ((rect.height / this.#scale) / 2)) - this.#offset.y;
scaled.width = rect.width / this.#scale;
scaled.height = rect.height / this.#scale;
const x = (this.#drawWidth * ((scaled.left - bounds.x.min) / bounds.width())) - adjustX;
const y = (this.#drawHeight * ((scaled.top - bounds.y.min) / bounds.height())) - adjustY;
const w = this.#drawWidth * (scaled.width / bounds.width());
const h = this.#drawHeight * (scaled.height / bounds.height());
ctx.globalAlpha = 0.5;
ctx.fillStyle = _color.set(ColorScheme.color(TRAIT.BUTTON_LIGHT)).cssString();
ctx.fillRect(x, y, w, h);
const widthScale = this.minimap.getWidth() / this.mapCanvas.width;
const heightScale = this.minimap.getHeight() / this.mapCanvas.height;
ctx.globalAlpha = 0.75;
ctx.strokeStyle = _color.set(ColorScheme.color(TRAIT.TEXT)).cssString();
ctx.lineWidth = 2 / widthScale;
ctx.beginPath(); ctx.moveTo(x + 0, y); ctx.lineTo(x + 0, y + h); ctx.stroke();
ctx.beginPath(); ctx.moveTo(x + w, y); ctx.lineTo(x + w, y + h); ctx.stroke();
ctx.lineWidth = 2 / heightScale;
ctx.beginPath(); ctx.moveTo(x, y + 0); ctx.lineTo(x + w, y + 0); ctx.stroke();
ctx.beginPath(); ctx.moveTo(x, y + h); ctx.lineTo(x + w, y + h); ctx.stroke();
ctx.globalAlpha = 0.5;
// Draw Nodes
ctx.globalAlpha = 0.75;
this.traverseNodes((node) => {
ctx.fillStyle = node.colorString();
const x = this.#drawWidth * ((node.left - bounds.x.min) / bounds.width());
const w = this.#drawWidth * (node.width / bounds.width());
const y = this.#drawHeight * ((node.top - bounds.y.min) / bounds.height());
const h = this.#drawHeight * (node.height / bounds.height());
ctx.beginPath();
ctx.roundRect(x - adjustX, y - adjustY, w, h, 0);
ctx.fill();
});
}
/******************** TRANSFORM ********************/
/**
* Centers on Selected nodes, if there are nodes selected. Otherwise centers on all nodes.
*
* @param {*} resetZoom reset scale to 1.0?
*/
centerView(resetZoom = true, animate = true) {
const selected = [];
this.traverseNodes((node) => { if (node.hasClass('osui-node-selected')) selected.push(node); });
const bounds = this.nodeBounds(0, (selected.length > 0) ? selected : this.nodes.children);
this.focusView(bounds.center().x, bounds.center().y, resetZoom, animate);
}
focusView(targetX, targetY, resetZoom = false, animate = true) {
if (targetX == null || targetY == null) return;
const rect = this.nodes.dom.getBoundingClientRect();
this.#targetX = ((rect.width / 2) / this.#scale) - targetX;
this.#targetY = ((rect.height / 2) / this.#scale) - targetY;
this.#targetZoom = ((resetZoom) ? 1.0 : this.#scale) * 1000;
if (animate) {
const self = this;
this.#animateStart = performance.now();
this.#animateLast = performance.now();
this.#startZoom = this.#scale * 1000;
this.#animateTimer = setInterval(() => {
this.#animating = true;
function damp(x, y, lambda, dt) { return lerp(x, y, 1 - Math.exp(- lambda * dt)); }
function lerp(x, y, t) { return (1 - t) * x + t * y; }
const elapsed = (performance.now() - self.#animateStart) / 1000;
const dt = (performance.now() - self.#animateLast) / 1000;
self.#offset.x = damp(self.#offset.x, self.#targetX, 15, dt);
self.#offset.y = damp(self.#offset.y, self.#targetY, 15, dt);
self.#startZoom = damp(self.#startZoom, self.#targetZoom, 15 * (elapsed + 0.5), dt);
const diffX = Math.abs(self.#offset.x - self.#targetX);
const diffY = Math.abs(self.#offset.y - self.#targetY);
const diffZ = Math.abs(self.#startZoom - self.#targetZoom);
if (diffX < 0.5 && diffY < 0.5 && diffZ < 0.01) self.stopAnimation();
if (elapsed > 2.5) self.stopAnimation();
self.zoomTo(self.#startZoom / 1000);
self.#animateLast = performance.now();
}, ANIMATE_INTERVAL);
} else {
this.#offset.x = this.#targetX;
this.#offset.y = this.#targetY;
this.zoomTo(this.#targetZoom / 1000);
}
}
#animating = false;
#animateTimer = undefined;
#animateStart = 0;
#animateLast = 0;
#targetX = 0;
#targetY = 0;
#startZoom = 0;
#targetZoom = 0;
stopAnimation() {
clearInterval(this.#animateTimer);
if (this.#animating) {
this.#animating = false;
this.#offset.x = this.#targetX;
this.#offset.y = this.#targetY;
this.zoomTo(this.#targetZoom / 1000);
}
}
zoomTo(zoom, clientX, clientY) {
if (zoom === undefined) zoom = this.#scale;
zoom = Math.round(Math.min(Math.max(zoom, ZOOM_MIN), ZOOM_MAX) * 100) / 100;
const nodes = this.nodes;
const grid = this.grid;
// Scroll To
if (clientX != undefined && clientY != undefined) {
const before = nodes.dom.getBoundingClientRect();
nodes.setStyle('transform', `scale(${zoom}) translate(${this.#offset.x}px, ${this.#offset.y}px)`);
const after = nodes.dom.getBoundingClientRect();
clientX -= before.left;
clientY -= before.top;
const shiftW = after.left - before.left;
const shiftH = after.top - before.top;
const dw = clientX - ((clientX / this.#scale) * zoom);
const dh = clientY - ((clientY / this.#scale) * zoom);
this.#offset.x -= ((shiftW - dw) / zoom);
this.#offset.y -= ((shiftH - dh) / zoom);
}
// Set Scale
nodes.setStyle('transform', `scale(${zoom}) translate(${this.#offset.x}px, ${this.#offset.y}px)`);
this.#scale = zoom;
this.#previous.x = this.#offset.x;
this.#previous.y = this.#offset.y;
// Align Grid
const rect = this.dom.getBoundingClientRect();
const diffX = (rect.width - (rect.width * zoom)) / 2;
const diffY = (rect.height - (rect.height * zoom)) / 2;
const ox = this.#offset.x * zoom;
const oy = this.#offset.y * zoom;
grid.setStyle('background-position', `left ${diffX + ox}px top ${diffY + oy}px`);
grid.setStyle('background-size', `${(GRID_SIZE * this.#scale * 2)}px`);
grid.setStyle('opacity', (this.#scale < 1) ? (this.#scale * this.#scale) : '1');
// Hide Resizers
const resizeables = document.querySelectorAll(`.osui-node`);
resizeables.forEach(el => {
if (zoom < 0.5) el.classList.add('osui-too-small');
else el.classList.remove('osui-too-small');
});
// Redraw
this.drawMiniMap();
this.drawLines();
}
}
export { Graph };