1037 lines
29 KiB
JavaScript
1037 lines
29 KiB
JavaScript
// -------------------------- GRAPH VIEW --------------------------
|
|
var running = false;
|
|
let batchFraction = 1; // how much of the graph to update per frame
|
|
let minBatchFraction = 0.3; // batch fraction is updated dynamically, but never goes below this value
|
|
let dt = 1;
|
|
let targetFPS = 40;
|
|
let startingCameraRect = {minX: -1, minY: -1, maxX: 1, maxY: 1};
|
|
|
|
let mouseWorldPos = { x: undefined, y: undefined };
|
|
let scrollVelocity = 0;
|
|
let averageFPS = targetFPS * 2;
|
|
|
|
let pixiApp = undefined;
|
|
let graphRenderer = undefined;
|
|
|
|
class GraphAssembly
|
|
{
|
|
static nodeCount = 0;
|
|
static linkCount = 0;
|
|
static hoveredNode = -1;
|
|
|
|
static #positionsPtr = 0;
|
|
static #positionsByteLength = 0;
|
|
static #radiiPtr = 0;
|
|
static #linkSourcesPtr = 0;
|
|
static #linkTargetsPtr = 0;
|
|
|
|
static linkSources = new Int32Array(0);
|
|
static linkTargets = new Int32Array(0);
|
|
static radii = new Float32Array(0);
|
|
static maxRadius = 0;
|
|
static averageRadius = 0;
|
|
static minRadius = 0;
|
|
|
|
/**
|
|
* @param {{graphOptions: {attractionForce: number, linkLength: number, repulsionForce: number, centralForce: number, edgePruning: number, minNodeRadius: number, maxNodeRadius: number}, nodeCount: number, linkCount:number, radii: number[], labels: string[], paths: string[], linkSources: number[], linkTargets: number[], linkCounts: number[]}} graphData
|
|
*/
|
|
static init(graphData)
|
|
{
|
|
GraphAssembly.nodeCount = graphData.nodeCount;
|
|
GraphAssembly.linkCount = graphData.linkCount;
|
|
|
|
// create arrays for the data
|
|
let positions = new Float32Array(GraphAssembly.nodeCount * 2);
|
|
GraphAssembly.radii = new Float32Array(graphData.radii);
|
|
GraphAssembly.linkSources = new Int32Array(graphData.linkSources);
|
|
GraphAssembly.linkTargets = new Int32Array(graphData.linkTargets);
|
|
|
|
// allocate memory on the heap
|
|
GraphAssembly.#positionsPtr = Module._malloc(positions.byteLength);
|
|
GraphAssembly.#positionsByteLength = positions.byteLength;
|
|
GraphAssembly.#radiiPtr = Module._malloc(GraphAssembly.radii.byteLength);
|
|
GraphAssembly.#linkSourcesPtr = Module._malloc(GraphAssembly.linkSources.byteLength);
|
|
GraphAssembly.#linkTargetsPtr = Module._malloc(GraphAssembly.linkTargets.byteLength);
|
|
|
|
GraphAssembly.maxRadius = GraphAssembly.radii.reduce((a, b) => Math.max(a, b));
|
|
GraphAssembly.averageRadius = GraphAssembly.radii.reduce((a, b) => a + b) / GraphAssembly.radii.length;
|
|
GraphAssembly.minRadius = GraphAssembly.radii.reduce((a, b) => Math.min(a, b));
|
|
|
|
positions = this.loadState();
|
|
|
|
// copy the data to the heap
|
|
Module.HEAP32.set(new Int32Array(positions.buffer), GraphAssembly.#positionsPtr / positions.BYTES_PER_ELEMENT);
|
|
Module.HEAP32.set(new Int32Array(GraphAssembly.radii.buffer), GraphAssembly.#radiiPtr / GraphAssembly.radii.BYTES_PER_ELEMENT);
|
|
Module.HEAP32.set(new Int32Array(GraphAssembly.linkSources.buffer), GraphAssembly.#linkSourcesPtr / GraphAssembly.linkSources.BYTES_PER_ELEMENT);
|
|
Module.HEAP32.set(new Int32Array(GraphAssembly.linkTargets.buffer), GraphAssembly.#linkTargetsPtr / GraphAssembly.linkTargets.BYTES_PER_ELEMENT);
|
|
|
|
Module._Init(
|
|
GraphAssembly.#positionsPtr,
|
|
GraphAssembly.#radiiPtr,
|
|
GraphAssembly.#linkSourcesPtr,
|
|
GraphAssembly.#linkTargetsPtr,
|
|
GraphAssembly.nodeCount,
|
|
GraphAssembly.linkCount,
|
|
batchFraction,
|
|
dt,
|
|
graphData.graphOptions.attractionForce,
|
|
graphData.graphOptions.linkLength,
|
|
graphData.graphOptions.repulsionForce,
|
|
graphData.graphOptions.centralForce,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @returns {Float32Array}
|
|
*/
|
|
static get positions()
|
|
{
|
|
return Module.HEAP32.buffer.slice(GraphAssembly.#positionsPtr, GraphAssembly.#positionsPtr + GraphAssembly.#positionsByteLength);
|
|
}
|
|
|
|
/**
|
|
* @param {GraphRenderWorker} renderWorker
|
|
* */
|
|
static saveState(renderWorker)
|
|
{
|
|
// save all rounded to int
|
|
localStorage.setItem("positions", JSON.stringify(new Float32Array(GraphAssembly.positions).map(x => Math.round(x))));
|
|
}
|
|
|
|
/**
|
|
* @returns {Float32Array}
|
|
* */
|
|
static loadState()
|
|
{
|
|
let positionsLoad = localStorage.getItem("positions");
|
|
let positions = null;
|
|
if(positionsLoad) positions = new Float32Array(Object.values(JSON.parse(positionsLoad)));
|
|
if (!positions || !positionsLoad || positions.length != GraphAssembly.nodeCount * 2)
|
|
{
|
|
positions = new Float32Array(GraphAssembly.nodeCount * 2);
|
|
let spawnRadius = (GraphAssembly.averageRadius * Math.sqrt(GraphAssembly.nodeCount)) * 2;
|
|
for (let i = 0; i < GraphAssembly.nodeCount; i++)
|
|
{
|
|
let distance = (1 - GraphAssembly.radii[i] / GraphAssembly.maxRadius) * spawnRadius;
|
|
positions[i * 2] = Math.cos(i/GraphAssembly.nodeCount * 7.41 * 2 * Math.PI) * distance;
|
|
positions[i * 2 + 1] = Math.sin(i/GraphAssembly.nodeCount * 7.41 * 2 * Math.PI) * distance;
|
|
}
|
|
}
|
|
|
|
// fit view to positions
|
|
let minX = Infinity;
|
|
let maxX = -Infinity;
|
|
let minY = Infinity;
|
|
let maxY = -Infinity;
|
|
for (let i = 0; i < GraphAssembly.nodeCount-1; i+=2)
|
|
{
|
|
let pos = { x: positions[i], y: positions[i + 1] };
|
|
minX = Math.min(minX, pos.x);
|
|
maxX = Math.max(maxX, pos.x);
|
|
minY = Math.min(minY, pos.y);
|
|
maxY = Math.max(maxY, pos.y);
|
|
}
|
|
|
|
let margin = 50;
|
|
|
|
startingCameraRect = { minX: minX - margin, minY: minY - margin, maxX: maxX + margin, maxY: maxY + margin };
|
|
|
|
return positions;
|
|
}
|
|
|
|
/**
|
|
* @param {{x: number, y: number}} mousePosition
|
|
* @param {number} grabbedNode
|
|
*/
|
|
static update(mousePosition, grabbedNode, cameraScale)
|
|
{
|
|
GraphAssembly.hoveredNode = Module._Update(mousePosition.x, mousePosition.y, grabbedNode, cameraScale);
|
|
}
|
|
|
|
static free()
|
|
{
|
|
Module._free(GraphAssembly.#positionsPtr);
|
|
Module._free(GraphAssembly.#radiiPtr);
|
|
Module._free(GraphAssembly.#linkSourcesPtr);
|
|
Module._free(GraphAssembly.#linkTargetsPtr);
|
|
Module._FreeMemory();
|
|
}
|
|
|
|
/**
|
|
* @param {number} value
|
|
*/
|
|
static set batchFraction(value)
|
|
{
|
|
Module._SetBatchFractionSize(value);
|
|
}
|
|
|
|
/**
|
|
* @param {number} value
|
|
*/
|
|
static set attractionForce(value)
|
|
{
|
|
Module._SetAttractionForce(value);
|
|
}
|
|
|
|
/**
|
|
* @param {number} value
|
|
*/
|
|
static set repulsionForce(value)
|
|
{
|
|
Module._SetRepulsionForce(value);
|
|
}
|
|
|
|
/**
|
|
* @param {number} value
|
|
*/
|
|
static set centralForce(value)
|
|
{
|
|
Module._SetCentralForce(value);
|
|
}
|
|
|
|
/**
|
|
* @param {number} value
|
|
*/
|
|
static set linkLength(value)
|
|
{
|
|
Module._SetLinkLength(value);
|
|
}
|
|
|
|
/**
|
|
* @param {number} value
|
|
*/
|
|
static set dt(value)
|
|
{
|
|
Module._SetDt(value);
|
|
}
|
|
}
|
|
|
|
class GraphRenderWorker
|
|
{
|
|
#cameraOffset;
|
|
#cameraScale;
|
|
#hoveredNode;
|
|
#grabbedNode;
|
|
#colors;
|
|
#width;
|
|
#height;
|
|
|
|
|
|
constructor()
|
|
{
|
|
this.canvas = document.querySelector("#graph-canvas");
|
|
this.canvasSidebar = undefined;
|
|
|
|
try
|
|
{
|
|
this.canvasSidebar = document.querySelector(".sidebar:has(#graph-canvas)");
|
|
}
|
|
catch(e)
|
|
{
|
|
console.log("Error: " + e + "\n\n Using fallback.");
|
|
|
|
let rightSidebar = document.querySelector(".sidebar-right");
|
|
let leftSidebar = document.querySelector(".sidebar-left");
|
|
|
|
this.canvasSidebar = rightSidebar.querySelector("#graph-canvas") ? rightSidebar : leftSidebar;
|
|
}
|
|
|
|
this.view = this.canvas.transferControlToOffscreen();
|
|
|
|
this.worker = new Worker(new URL("./graph-render-worker.js", import.meta.url));
|
|
|
|
this.#cameraOffset = {x: 0, y: 0};
|
|
this.#cameraScale = 1;
|
|
this.#hoveredNode = -1;
|
|
this.#grabbedNode = -1;
|
|
this.#colors =
|
|
{
|
|
background: 0x000000,
|
|
link: 0x000000,
|
|
node: 0x000000,
|
|
outline: 0x000000,
|
|
text: 0x000000,
|
|
accent: 0x000000,
|
|
}
|
|
this.#width = 0;
|
|
this.#height = 0;
|
|
|
|
this.cameraOffset = {x: this.canvas.width / 2, y: this.canvas.height / 2};
|
|
this.cameraScale = 1;
|
|
this.hoveredNode = -1;
|
|
this.grabbedNode = -1;
|
|
this.resampleColors();
|
|
|
|
this.#pixiInit();
|
|
|
|
this.width = this.canvas.width;
|
|
this.height = this.canvas.height;
|
|
|
|
this.autoResizeCanvas();
|
|
|
|
this.fitToRect(startingCameraRect);
|
|
}
|
|
|
|
#pixiInit()
|
|
{
|
|
let { width, height } = this.view;
|
|
|
|
this.worker.postMessage(
|
|
{
|
|
type: 'init',
|
|
linkCount: GraphAssembly.linkCount,
|
|
linkSources: GraphAssembly.linkSources,
|
|
linkTargets: GraphAssembly.linkTargets,
|
|
nodeCount: GraphAssembly.nodeCount,
|
|
radii: GraphAssembly.radii,
|
|
labels: graphData.labels,
|
|
linkLength: graphData.graphOptions.linkLength,
|
|
edgePruning: graphData.graphOptions.edgePruning,
|
|
options: { width: width, height: height, view: this.view },
|
|
}, [this.view]);
|
|
}
|
|
|
|
fitToRect(rect) // {minX, minY, maxX, maxY}
|
|
{
|
|
let min = {x: rect.minX, y: rect.minY};
|
|
let max = {x: rect.maxX, y: rect.maxY};
|
|
|
|
let width = max.x - min.x;
|
|
let height = max.y - min.y;
|
|
|
|
let scale = 1/Math.min(width/this.width, height / this.height);
|
|
|
|
this.cameraScale = scale;
|
|
this.cameraOffset = { x: (this.width / 2) - ((rect.minX + width / 2) * scale), y: (this.height / 2) - ((rect.minY + height / 2) * scale) };
|
|
}
|
|
|
|
fitToNodes()
|
|
{
|
|
this.fitToRect(startingCameraRect);
|
|
}
|
|
|
|
sampleColor(variable)
|
|
{
|
|
let testEl = document.createElement('div');
|
|
document.body.appendChild(testEl);
|
|
testEl.style.setProperty('display', 'none');
|
|
testEl.style.setProperty('color', 'var(' + variable + ')');
|
|
|
|
let col = getComputedStyle(testEl).color;
|
|
let opacity = getComputedStyle(testEl).opacity;
|
|
|
|
testEl.remove();
|
|
|
|
function toColorObject(str)
|
|
{
|
|
var match = str.match(/rgb?\((\d+),\s*(\d+),\s*(\d+)\)/);
|
|
return match ? {
|
|
red: parseInt(match[1]),
|
|
green: parseInt(match[2]),
|
|
blue: parseInt(match[3]),
|
|
alpha: 1
|
|
} : null
|
|
}
|
|
|
|
let color = toColorObject(col);
|
|
let alpha = parseFloat(opacity);
|
|
let result =
|
|
{
|
|
a: (alpha * color?.alpha ?? 1) ?? 1,
|
|
rgb: (color?.red << 16 | color?.green << 8 | color?.blue) ?? 0x888888
|
|
};
|
|
|
|
return result;
|
|
};
|
|
|
|
resampleColors()
|
|
{
|
|
this.colors =
|
|
{
|
|
background: this.sampleColor('--background-secondary').rgb,
|
|
link: this.sampleColor('--graph-line').rgb,
|
|
node: this.sampleColor('--graph-node').rgb,
|
|
outline: this.sampleColor('--graph-line').rgb,
|
|
text: this.sampleColor('--graph-text').rgb,
|
|
accent: this.sampleColor('--interactive-accent').rgb,
|
|
};
|
|
}
|
|
|
|
draw(_positions)
|
|
{
|
|
this.worker.postMessage(
|
|
{
|
|
type: 'draw',
|
|
positions: _positions,
|
|
}, [_positions]);
|
|
}
|
|
|
|
resizeCanvas(width, height)
|
|
{
|
|
this.worker.postMessage(
|
|
{
|
|
type: "resize",
|
|
width: width,
|
|
height: height,
|
|
});
|
|
|
|
this.#width = width;
|
|
this.#height = height;
|
|
}
|
|
|
|
autoResizeCanvas()
|
|
{
|
|
if (this.width != this.canvas.offsetWidth || this.height != this.canvas.offsetHeight)
|
|
{
|
|
this.centerCamera();
|
|
this.resizeCanvas(this.canvas.offsetWidth, this.canvas.offsetHeight);
|
|
}
|
|
}
|
|
|
|
centerCamera()
|
|
{
|
|
this.cameraOffset = { x: this.width / 2, y: this.height / 2 };
|
|
}
|
|
|
|
#pixiSetInteraction(hoveredNodeIndex, grabbedNodeIndex)
|
|
{
|
|
let obj =
|
|
{
|
|
type: "update_interaction",
|
|
hoveredNode: hoveredNodeIndex,
|
|
grabbedNode: grabbedNodeIndex,
|
|
}
|
|
|
|
this.worker.postMessage(obj);
|
|
}
|
|
|
|
#pixiSetCamera(cameraOffset, cameraScale)
|
|
{
|
|
this.worker.postMessage(
|
|
{
|
|
type: "update_camera",
|
|
cameraOffset: cameraOffset,
|
|
cameraScale: cameraScale,
|
|
});
|
|
}
|
|
|
|
#pixiSetColors(colors)
|
|
{
|
|
this.worker.postMessage(
|
|
{
|
|
type: "update_colors",
|
|
colors: colors,
|
|
});
|
|
|
|
}
|
|
|
|
set cameraOffset(offset)
|
|
{
|
|
this.#cameraOffset = offset;
|
|
this.#pixiSetCamera(offset, this.cameraScale);
|
|
}
|
|
|
|
set cameraScale(scale)
|
|
{
|
|
this.#cameraScale = scale;
|
|
this.#pixiSetCamera(this.cameraOffset, scale);
|
|
}
|
|
|
|
get cameraOffset()
|
|
{
|
|
return this.#cameraOffset;
|
|
}
|
|
|
|
get cameraScale()
|
|
{
|
|
return this.#cameraScale;
|
|
}
|
|
|
|
/**
|
|
* @param {number} node
|
|
*/
|
|
set hoveredNode(node)
|
|
{
|
|
this.#hoveredNode = node;
|
|
this.#pixiSetInteraction(node, this.#grabbedNode);
|
|
}
|
|
|
|
/**
|
|
* @param {number} node
|
|
*/
|
|
set grabbedNode(node)
|
|
{
|
|
this.#grabbedNode = node;
|
|
this.#pixiSetInteraction(this.#hoveredNode, node);
|
|
}
|
|
|
|
/**
|
|
* @param {number} node
|
|
*/
|
|
set activeNode(node)
|
|
{
|
|
this.worker.postMessage(
|
|
{
|
|
type: 'set_active',
|
|
active: node,
|
|
});
|
|
}
|
|
|
|
get hoveredNode()
|
|
{
|
|
return this.#hoveredNode;
|
|
}
|
|
|
|
get grabbedNode()
|
|
{
|
|
return this.#grabbedNode;
|
|
}
|
|
|
|
/**
|
|
* @param {{ background: number; link: number; node: number; outline: number; text: number; accent: number; }} colors
|
|
*/
|
|
set colors(colors)
|
|
{
|
|
this.#colors = colors;
|
|
this.#pixiSetColors(colors);
|
|
}
|
|
|
|
get colors()
|
|
{
|
|
return this.#colors;
|
|
}
|
|
|
|
set width(width)
|
|
{
|
|
this.#width = width;
|
|
this.resizeCanvas(width, this.#height);
|
|
}
|
|
|
|
set height(height)
|
|
{
|
|
this.#height = height;
|
|
this.resizeCanvas(this.#width, height);
|
|
}
|
|
|
|
get height()
|
|
{
|
|
return this.#height;
|
|
}
|
|
|
|
get width()
|
|
{
|
|
return this.#width;
|
|
}
|
|
|
|
/**
|
|
* @param {number} x
|
|
* @param {number} y
|
|
* @param {boolean} floor
|
|
* @returns {{x: number; y: number;}}
|
|
*/
|
|
toScreenSpace(x, y, floor = true)
|
|
{
|
|
if (floor)
|
|
{
|
|
return {x: Math.floor((x * this.cameraScale) + this.cameraOffset.x), y: Math.floor((y * this.cameraScale) + this.cameraOffset.y)};
|
|
}
|
|
else
|
|
{
|
|
return {x: (x * this.cameraScale) + this.cameraOffset.x, y: (y * this.cameraScale) + this.cameraOffset.y};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {{x: number; y: number;}} vector
|
|
* @param {boolean} floor
|
|
* @returns {{x: number; y: number;}}
|
|
*/
|
|
vecToScreenSpace(vector, floor = true)
|
|
{
|
|
return this.toScreenSpace(vector.x, vector.y, floor);
|
|
}
|
|
|
|
/**
|
|
* @param {number} x
|
|
* @param {number} y
|
|
* @returns {{x: number; y: number;}}
|
|
*/
|
|
toWorldspace(x, y)
|
|
{
|
|
return {x: (x - this.cameraOffset.x) / this.cameraScale, y: (y - this.cameraOffset.y) / this.cameraScale};
|
|
}
|
|
|
|
/**
|
|
* @param {{x: number; y: number;}} vector
|
|
* @returns {{x: number; y: number;}}
|
|
*/
|
|
vecToWorldspace(vector)
|
|
{
|
|
return this.toWorldspace(vector.x, vector.y);
|
|
}
|
|
|
|
setCameraCenterWorldspace({x, y})
|
|
{
|
|
this.cameraOffset = {x: (this.width / 2) - (x * this.cameraScale), y: (this.height / 2) - (y * this.cameraScale)};
|
|
}
|
|
|
|
getCameraCenterWorldspace()
|
|
{
|
|
return this.toWorldspace(this.width / 2, this.height / 2);
|
|
}
|
|
}
|
|
|
|
async function initializeGraphView()
|
|
{
|
|
if(running) return;
|
|
running = true;
|
|
|
|
graphData.graphOptions.repulsionForce /= batchFraction; // compensate for batch fraction
|
|
pixiApp = new PIXI.Application();
|
|
|
|
console.log("Module Ready");
|
|
GraphAssembly.init(graphData); // graphData is a global variable set in another script
|
|
|
|
graphRenderer = new GraphRenderWorker();
|
|
window.graphRenderer = graphRenderer;
|
|
|
|
initializeGraphEvents();
|
|
|
|
pixiApp.ticker.maxFPS = targetFPS;
|
|
pixiApp.ticker.add(updateGraph);
|
|
|
|
setActiveDocument(new URL(window.location.href), false, false);
|
|
|
|
setInterval(() =>
|
|
{
|
|
function isHidden(el) {
|
|
var style = window.getComputedStyle(el);
|
|
return (style.display === 'none')
|
|
}
|
|
|
|
try
|
|
{
|
|
var hidden = (graphRenderer.canvasSidebar.classList.contains("is-collapsed"));
|
|
}
|
|
catch(e)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if(running && hidden)
|
|
{
|
|
running = false;
|
|
}
|
|
else if (!running && !hidden)
|
|
{
|
|
running = true;
|
|
graphRenderer.autoResizeCanvas();
|
|
graphRenderer.centerCamera();
|
|
}
|
|
|
|
}, 1000);
|
|
}
|
|
|
|
let firstUpdate = true;
|
|
function updateGraph()
|
|
{
|
|
if(!running) return;
|
|
|
|
if (graphRenderer.canvasSidebar.classList.contains("is-collapsed")) return;
|
|
|
|
if (firstUpdate)
|
|
{
|
|
setTimeout(() => graphRenderer?.canvas?.classList.remove("hide"), 500);
|
|
firstUpdate = false;
|
|
}
|
|
|
|
GraphAssembly.update(mouseWorldPos, graphRenderer.grabbedNode, graphRenderer.cameraScale);
|
|
|
|
if (GraphAssembly.hoveredNode != graphRenderer.hoveredNode)
|
|
{
|
|
graphRenderer.hoveredNode = GraphAssembly.hoveredNode;
|
|
graphRenderer.canvas.style.cursor = GraphAssembly.hoveredNode == -1 ? "default" : "pointer";
|
|
}
|
|
|
|
graphRenderer.autoResizeCanvas();
|
|
graphRenderer.draw(GraphAssembly.positions);
|
|
|
|
averageFPS = averageFPS * 0.95 + pixiApp.ticker.FPS * 0.05;
|
|
|
|
if (averageFPS < targetFPS * 0.8 && batchFraction > minBatchFraction)
|
|
{
|
|
batchFraction = Math.max(batchFraction - 0.5 * 1/targetFPS, minBatchFraction);
|
|
GraphAssembly.batchFraction = batchFraction;
|
|
GraphAssembly.repulsionForce = graphData.graphOptions.repulsionForce / batchFraction;
|
|
}
|
|
|
|
if (averageFPS > targetFPS * 1.2 && batchFraction < 1)
|
|
{
|
|
batchFraction = Math.min(batchFraction + 0.5 * 1/targetFPS, 1);
|
|
GraphAssembly.batchFraction = batchFraction;
|
|
GraphAssembly.repulsionForce = graphData.graphOptions.repulsionForce / batchFraction;
|
|
}
|
|
|
|
if (scrollVelocity != 0)
|
|
{
|
|
let cameraCenter = graphRenderer.getCameraCenterWorldspace();
|
|
|
|
if (Math.abs(scrollVelocity) < 0.001)
|
|
{
|
|
scrollVelocity = 0;
|
|
}
|
|
|
|
zoomGraphViewAroundPoint(mouseWorldPos, scrollVelocity);
|
|
|
|
scrollVelocity *= 0.65;
|
|
}
|
|
}
|
|
|
|
function zoomGraphViewAroundPoint(point, zoom, minScale = 0.15, maxScale = 15.0)
|
|
{
|
|
let cameraCenter = graphRenderer.getCameraCenterWorldspace();
|
|
|
|
graphRenderer.cameraScale = Math.max(Math.min(graphRenderer.cameraScale + zoom * graphRenderer.cameraScale, maxScale), minScale);
|
|
if(graphRenderer.cameraScale != minScale && graphRenderer.cameraScale != maxScale && scrollVelocity > 0 && mouseWorldPos.x != undefined && mouseWorldPos.y != undefined)
|
|
{
|
|
let aroundDiff = {x: point.x - cameraCenter.x, y: point.y - cameraCenter.y};
|
|
let movePos = {x: cameraCenter.x + aroundDiff.x * zoom, y: cameraCenter.y + aroundDiff.y * zoom};
|
|
graphRenderer.setCameraCenterWorldspace(movePos);
|
|
}
|
|
else graphRenderer.setCameraCenterWorldspace(cameraCenter);
|
|
}
|
|
|
|
function scaleGraphViewAroundPoint(point, scale, minScale = 0.15, maxScale = 15.0)
|
|
{
|
|
let cameraCenter = graphRenderer.getCameraCenterWorldspace();
|
|
|
|
let scaleBefore = graphRenderer.cameraScale;
|
|
graphRenderer.cameraScale = Math.max(Math.min(scale * graphRenderer.cameraScale, maxScale), minScale);
|
|
let diff = (scaleBefore - graphRenderer.cameraScale) / scaleBefore;
|
|
if(graphRenderer.cameraScale != minScale && graphRenderer.cameraScale != maxScale && scale != 0)
|
|
{
|
|
let aroundDiff = {x: point.x - cameraCenter.x, y: point.y - cameraCenter.y};
|
|
let movePos = {x: cameraCenter.x - aroundDiff.x * diff, y: cameraCenter.y - aroundDiff.y * diff};
|
|
graphRenderer.setCameraCenterWorldspace(movePos);
|
|
}
|
|
else graphRenderer.setCameraCenterWorldspace(cameraCenter);
|
|
}
|
|
|
|
function initializeGraphEvents()
|
|
{
|
|
window.addEventListener('beforeunload', () =>
|
|
{
|
|
running = false;
|
|
GraphAssembly.free();
|
|
});
|
|
|
|
let graphExpanded = false;
|
|
let lastCanvasWidth = graphRenderer.canvas.width;
|
|
window.addEventListener('resize', () =>
|
|
{
|
|
if(graphExpanded)
|
|
{
|
|
graphRenderer.autoResizeCanvas();
|
|
graphRenderer.centerCamera();
|
|
}
|
|
else
|
|
{
|
|
if (graphRenderer.canvas.width != lastCanvasWidth)
|
|
{
|
|
graphRenderer.autoResizeCanvas();
|
|
graphRenderer.centerCamera();
|
|
}
|
|
}
|
|
});
|
|
|
|
let container = document.querySelector(".graph-view-container");
|
|
|
|
function handleOutsideClick(event)
|
|
{
|
|
if (event.composedPath().includes(container))
|
|
{
|
|
return;
|
|
}
|
|
toggleExpandedGraph();
|
|
}
|
|
|
|
function toggleExpandedGraph()
|
|
{
|
|
let initialWidth = container.clientWidth;
|
|
let initialHeight = container.clientHeight;
|
|
|
|
// scale and fade out animation:
|
|
container.classList.add("scale-down");
|
|
let fadeOutAnimation = container.animate({ opacity: 0 }, {duration: 100, easing: "ease-in", fill: "forwards"});
|
|
fadeOutAnimation.addEventListener("finish", function()
|
|
{
|
|
container.classList.toggle("expanded");
|
|
|
|
graphRenderer.autoResizeCanvas();
|
|
graphRenderer.centerCamera();
|
|
|
|
let finalWidth = container.clientWidth;
|
|
let finalHeight = container.clientHeight;
|
|
graphRenderer.cameraScale *= ((finalWidth / initialWidth) + (finalHeight / initialHeight)) / 2;
|
|
|
|
container.classList.remove("scale-down");
|
|
container.classList.add("scale-up");
|
|
|
|
updateGraph();
|
|
|
|
let fadeInAnimation = container.animate({ opacity: 1 }, {duration: 200, easing: "ease-out", fill: "forwards"});
|
|
fadeInAnimation.addEventListener("finish", function()
|
|
{
|
|
container.classList.remove("scale-up");
|
|
});
|
|
});
|
|
|
|
graphExpanded = !graphExpanded;
|
|
|
|
if (graphExpanded) document.addEventListener("pointerdown", handleOutsideClick);
|
|
else document.removeEventListener("pointerdown", handleOutsideClick);
|
|
}
|
|
|
|
async function navigateToNode(nodeIndex)
|
|
{
|
|
if (!graphExpanded) GraphAssembly.saveState(graphRenderer);
|
|
else toggleExpandedGraph();
|
|
let url = graphData.paths[nodeIndex];
|
|
if(window.location.pathname.endsWith(graphData.paths[nodeIndex])) return;
|
|
await loadDocument(url, true, true);
|
|
}
|
|
|
|
// Get the mouse position relative to the canvas.
|
|
function getPointerPosOnCanvas(event)
|
|
{
|
|
var rect = graphRenderer.canvas.getBoundingClientRect();
|
|
let pos = getPointerPosition(event);
|
|
|
|
return {
|
|
x: pos.x - rect.left,
|
|
y: pos.y - rect.top
|
|
};
|
|
}
|
|
|
|
let startPointerPos = { x: 0, y: 0 };
|
|
let pointerPos = { x: 0, y: 0 };
|
|
let lastPointerPos = { x: 0, y: 0 };
|
|
let pointerDelta = { x: 0, y: 0 };
|
|
let dragDisplacement = { x: 0, y: 0 };
|
|
let startDragTime = 0;
|
|
let pointerDown = false;
|
|
let middleDown = false;
|
|
let pointerInside = false;
|
|
let graphContainer = document.querySelector(".graph-view-container");
|
|
let firstPointerDownId = -1;
|
|
|
|
function handlePointerEnter(enter)
|
|
{
|
|
let lastDistance = 0;
|
|
let startZoom = false;
|
|
|
|
function handleMouseMove(move)
|
|
{
|
|
pointerPos = getPointerPosOnCanvas(move);
|
|
mouseWorldPos = graphRenderer.vecToWorldspace(pointerPos);
|
|
pointerDelta = { x: pointerPos.x - lastPointerPos.x, y: pointerPos.y - lastPointerPos.y };
|
|
lastPointerPos = pointerPos;
|
|
|
|
if (graphRenderer.grabbedNode != -1) dragDisplacement = { x: pointerPos.x - startPointerPos.x, y: pointerPos.y - startPointerPos.y };
|
|
|
|
if (pointerDown && graphRenderer.hoveredNode != -1 && graphRenderer.grabbedNode == -1 && graphRenderer.hoveredNode != graphRenderer.grabbedNode)
|
|
{
|
|
graphRenderer.grabbedNode = graphRenderer.hoveredNode;
|
|
}
|
|
|
|
if ((pointerDown && graphRenderer.hoveredNode == -1 && graphRenderer.grabbedNode == -1) || middleDown)
|
|
{
|
|
graphRenderer.cameraOffset = { x: graphRenderer.cameraOffset.x + pointerDelta.x, y: graphRenderer.cameraOffset.y + pointerDelta.y };
|
|
}
|
|
else
|
|
{
|
|
if (graphRenderer.hoveredNode != -1) graphRenderer.canvas.style.cursor = "pointer";
|
|
else graphRenderer.canvas.style.cursor = "default";
|
|
}
|
|
}
|
|
|
|
function handleTouchMove(move)
|
|
{
|
|
if (move.touches?.length == 1)
|
|
{
|
|
if(startZoom)
|
|
{
|
|
lastPointerPos = getPointerPosOnCanvas(move);
|
|
startZoom = false;
|
|
}
|
|
|
|
handleMouseMove(move);
|
|
return;
|
|
}
|
|
|
|
// pinch zoom
|
|
if (move.touches?.length == 2)
|
|
{
|
|
let touch1 = getTouchPosition(move.touches[0]);
|
|
let touch2 = getTouchPosition(move.touches[1]);
|
|
|
|
pointerPos = getPointerPosOnCanvas(move);
|
|
pointerDelta = { x: pointerPos.x - lastPointerPos.x, y: pointerPos.y - lastPointerPos.y };
|
|
lastPointerPos = pointerPos;
|
|
|
|
let distance = Math.sqrt(Math.pow(touch1.x - touch2.x, 2) + Math.pow(touch1.y - touch2.y, 2));
|
|
|
|
if (!startZoom)
|
|
{
|
|
startZoom = true;
|
|
lastDistance = distance;
|
|
pointerDelta = { x: 0, y: 0 };
|
|
mouseWorldPos = { x: undefined, y: undefined};
|
|
graphRenderer.grabbedNode = -1;
|
|
graphRenderer.hoveredNode = -1;
|
|
}
|
|
|
|
let distanceDelta = distance - lastDistance;
|
|
let scaleDelta = distanceDelta / lastDistance;
|
|
|
|
scaleGraphViewAroundPoint(graphRenderer.vecToWorldspace(pointerPos), 1 + scaleDelta, 0.15, 15.0);
|
|
graphRenderer.cameraOffset = { x: graphRenderer.cameraOffset.x + pointerDelta.x, y: graphRenderer.cameraOffset.y + pointerDelta.y };
|
|
|
|
lastDistance = distance;
|
|
}
|
|
}
|
|
|
|
function handlePointerUp(up)
|
|
{
|
|
document.removeEventListener("pointerup", handlePointerUp);
|
|
|
|
let pointerUpTime = Date.now();
|
|
|
|
setTimeout(() =>
|
|
{
|
|
if (pointerDown && graphRenderer.hoveredNode != -1 && Math.abs(dragDisplacement.x) <= 4 && Math.abs(dragDisplacement.y) <= 4 && pointerUpTime - startDragTime < 300)
|
|
{
|
|
navigateToNode(graphRenderer.hoveredNode);
|
|
}
|
|
|
|
if (pointerDown && graphRenderer.grabbedNode != -1)
|
|
{
|
|
graphRenderer.grabbedNode = -1;
|
|
}
|
|
|
|
if (up.button == 0) pointerDown = false;
|
|
if (up.pointerType == "touch" && firstPointerDownId == up.pointerId)
|
|
{
|
|
firstPointerDownId = -1;
|
|
pointerDown = false;
|
|
}
|
|
if (up.button == 1) middleDown = false;
|
|
if (!pointerInside)
|
|
{
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
document.removeEventListener("touchmove", handleTouchMove);
|
|
}
|
|
}, 0);
|
|
}
|
|
|
|
function handlePointerDown(down)
|
|
{
|
|
document.addEventListener("pointerup", handlePointerUp);
|
|
mouseWorldPos = graphRenderer.vecToWorldspace(pointerPos);
|
|
dragDisplacement = { x: 0, y: 0 };
|
|
if (down.button == 0) pointerDown = true;
|
|
if (down.pointerType == "touch" && firstPointerDownId == -1)
|
|
{
|
|
firstPointerDownId = down.pointerId;
|
|
pointerDown = true;
|
|
}
|
|
if (down.button == 1) middleDown = true;
|
|
|
|
startPointerPos = pointerPos;
|
|
startDragTime = Date.now();
|
|
|
|
if (pointerDown && graphRenderer.hoveredNode != -1)
|
|
{
|
|
graphRenderer.grabbedNode = graphRenderer.hoveredNode;
|
|
}
|
|
}
|
|
|
|
function handlePointerLeave(leave)
|
|
{
|
|
setTimeout(() =>
|
|
{
|
|
pointerInside = false;
|
|
if (!pointerDown)
|
|
{
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
document.removeEventListener("touchmove", handleTouchMove);
|
|
mouseWorldPos = { x: undefined, y: undefined };
|
|
}
|
|
graphContainer.removeEventListener("pointerdown", handlePointerDown);
|
|
graphContainer.removeEventListener("pointerleave", handlePointerLeave);
|
|
}, 1);
|
|
}
|
|
|
|
pointerPos = getPointerPosOnCanvas(enter);
|
|
mouseWorldPos = graphRenderer.vecToWorldspace(pointerPos);
|
|
lastPointerPos = getPointerPosOnCanvas(enter);
|
|
pointerInside = true;
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
document.addEventListener("touchmove", handleTouchMove);
|
|
graphContainer.addEventListener("pointerdown", handlePointerDown);
|
|
graphContainer.addEventListener("pointerleave", handlePointerLeave);
|
|
}
|
|
|
|
graphContainer.addEventListener("pointerenter", handlePointerEnter);
|
|
|
|
document.querySelector(".graph-expand.graph-icon")?.addEventListener("click", event =>
|
|
{
|
|
event.stopPropagation();
|
|
|
|
toggleExpandedGraph();
|
|
});
|
|
|
|
graphContainer.addEventListener("wheel", function(e)
|
|
{
|
|
let startingScrollVelocity = 0.09;
|
|
let delta = e.deltaY;
|
|
if (delta > 0)
|
|
{
|
|
if(scrollVelocity >= -startingScrollVelocity)
|
|
{
|
|
scrollVelocity = -startingScrollVelocity;
|
|
}
|
|
scrollVelocity *= 1.4;
|
|
}
|
|
else
|
|
{
|
|
if(scrollVelocity <= startingScrollVelocity)
|
|
{
|
|
scrollVelocity = startingScrollVelocity;
|
|
}
|
|
scrollVelocity *= 1.4;
|
|
}
|
|
});
|
|
|
|
// recenter the graph on double click
|
|
graphContainer.addEventListener("dblclick", function(e)
|
|
{
|
|
graphRenderer.fitToNodes();
|
|
});
|
|
|
|
document.querySelector(".theme-toggle-input")?.addEventListener("change", event =>
|
|
{
|
|
setTimeout(() => graphRenderer.resampleColors(), 0);
|
|
});
|
|
}
|
|
|
|
window.addEventListener("load", () =>
|
|
{
|
|
waitLoadScripts(["pixi", "graph-data", "graph-render-worker", "graph-wasm"], () =>
|
|
{
|
|
Module['onRuntimeInitialized'] = initializeGraphView;
|
|
setTimeout(() => Module['onRuntimeInitialized'](), 300);
|
|
});
|
|
});
|