Développement
Applications métier sur mesure, du backend au frontend, compatibles Windows, Linux et Android, conçus pour allier performance, ergonomie et adaptabilité aux besoins spécifiques.
import numpy as np
from activation_functions import ActivationFunction
class NeuronLayer:
def __init__(self, n_input, n_neurons, activation='sigmoid', learning_rate=0.01):
"""
Initialise une couche de neurones
Parameters:
- n_input: nombre d'entrées
- n_neurons: nombre de neurones dans cette couche
- activation: fonction d'activation ('sigmoid', 'tanh', 'relu', 'leaky_relu')
- learning_rate: taux d'apprentissage
"""
self.n_input = n_input
self.n_neurons = n_neurons
self.activation_name = activation
self.learning_rate = learning_rate
# Initialisation Xavier/Glorot pour éviter l'explosion/disparition des gradients
if activation == 'relu' or activation == 'leaky_relu':
# Initialisation He pour ReLU
limit = np.sqrt(2 / n_input)
else:
# Initialisation Xavier pour sigmoid/tanh
limit = np.sqrt(6 / (n_input + n_neurons))
self.weights = np.random.uniform(-limit, limit, (n_neurons, n_input))
self.bias = np.zeros((n_neurons, 1))
# Variables pour stocker les valeurs lors de la propagation
self.last_input = None
self.last_z = None
self.last_activation = None
# Fonction d'activation
self.activation_func = ActivationFunction(activation)
def forward(self, X):
"""
Propagation avant
X: matrice d'entrée (n_features, n_samples)
"""
if X.ndim == 1:
X = X.reshape(-1, 1)
# Stocker les valeurs intermédiaires pour la rétropropagation
self.last_input = X
# Combinaison linéaire: z = W*x + b
self.last_z = np.dot(self.weights, X) + self.bias
# Application de la fonction d'activation
self.last_activation = self.activation_func.forward(self.last_z)
return self.last_activation
def backward(self, gradient_from_next_layer):
"""
Rétropropagation
gradient_from_next_layer: gradient venant de la couche suivante
"""
if gradient_from_next_layer.ndim == 1:
gradient_from_next_layer = gradient_from_next_layer.reshape(-1, 1)
# Nombre d'échantillons
m = self.last_input.shape[1]
# Gradient par rapport à la fonction d'activation
grad_activation = gradient_from_next_layer * self.activation_func.derivative(self.last_z)
# Gradient par rapport aux poids
grad_weights = (1/m) * np.dot(grad_activation, self.last_input.T)
# Gradient par rapport aux biais
grad_bias = (1/m) * np.sum(grad_activation, axis=1, keepdims=True)
# Gradient à propager vers la couche précédente
grad_input = np.dot(self.weights.T, grad_activation)
# Mise à jour des paramètres
self.weights -= self.learning_rate * grad_weights
self.bias -= self.learning_rate * grad_bias
return grad_input
class MultiLayerPerceptron:
def __init__(self, architecture, learning_rate=0.01, activation='sigmoid'):
"""
architecture: liste des tailles de couches [input_size, hidden1, hidden2, ..., output_size]
"""
self.architecture = architecture
self.learning_rate = learning_rate
self.activation = activation
self.couches = []
self.history = {'loss': [], 'val_loss': [], 'accuracy': [], 'val_accuracy': []}
# Validation de l'architecture
if len(architecture) < 2:
raise ValueError("L'architecture doit contenir au moins 2 couches (entrée et sortie)")
# Création des couches
for i in range(len(architecture) - 1):
# La dernière couche utilise généralement sigmoid pour la classification binaire
activation_couche = activation
if i == len(architecture) - 2: # Dernière couche
activation_couche = 'sigmoid' # ou 'softmax' pour multi-classes
couche = NeuronLayer(
n_input=architecture[i],
n_neurons=architecture[i+1],
activation=activation_couche,
learning_rate=learning_rate
)
self.couches.append(couche)
def forward(self, X):
"""
Propagation avant à travers tout le réseau
"""
if X.ndim == 1:
X = X.reshape(1, -1)
current_input = X.T # Transposer pour avoir (n_features, n_samples)
for couche in self.couches:
current_input = couche.forward(current_input)
return current_input.T # Retransposer pour avoir (n_samples, n_output)
def backward(self, X, y_true, y_pred):
"""
Rétropropagation à travers tout le réseau
"""
# Calcul du gradient initial (dérivée de la fonction de coût)
# Pour l'erreur quadratique : gradient = (y_pred - y_true)
gradient = (y_pred - y_true).T # Transposer pour la cohérence
# Propager le gradient vers l'arrière
for couche in reversed(self.couches):
gradient = couche.backward(gradient)
def train_epoch(self, X, y):
"""
Une époque d'entraînement
"""
# Validation des entrées
if X.shape[0] != y.shape[0]:
raise ValueError("Le nombre d'échantillons dans X et y doit être identique")
# Propagation avant
y_pred = self.forward(X)
# Calcul de la perte
loss = self.compute_loss(y, y_pred)
# Rétropropagation
self.backward(X, y, y_pred)
return loss, y_pred
def compute_loss(self, y_true, y_pred):
"""
Calcule la fonction de coût (erreur quadratique moyenne)
"""
return np.mean((y_true - y_pred) ** 2)
def compute_binary_crossentropy(self, y_true, y_pred):
"""
Calcule l'entropie croisée binaire (meilleure pour la classification)
"""
# Éviter log(0) en clippant les valeurs
y_pred = np.clip(y_pred, 1e-15, 1 - 1e-15)
return -np.mean(y_true * np.log(y_pred) + (1 - y_true) * np.log(1 - y_pred))
def fit(self, X, y, X_val=None, y_val=None, epochs=100, verbose=True, use_bce=False):
"""
Entraîne le réseau
"""
for epoch in range(epochs):
# Entraînement
loss, y_pred = self.train_epoch(X, y)
# Utiliser l'entropie croisée binaire si demandé
if use_bce:
loss = self.compute_binary_crossentropy(y, y_pred)
accuracy = self.compute_accuracy(y, y_pred)
self.history['loss'].append(loss)
self.history['accuracy'].append(accuracy)
# Validation si données fournies
if X_val is not None and y_val is not None:
y_val_pred = self.predict(X_val)
if use_bce:
val_loss = self.compute_binary_crossentropy(y_val, y_val_pred)
else:
val_loss = self.compute_loss(y_val, y_val_pred)
val_accuracy = self.compute_accuracy(y_val, y_val_pred)
self.history['val_loss'].append(val_loss)
self.history['val_accuracy'].append(val_accuracy)
if verbose and epoch % (epochs // 10) == 0:
if X_val is not None:
print(f"Époque {epoch:3d} - Loss: {loss:.4f} - Acc: {accuracy:.4f} - Val Loss: {val_loss:.4f} - Val Acc: {val_accuracy:.4f}")
else:
print(f"Époque {epoch:3d} - Loss: {loss:.4f} - Acc: {accuracy:.4f}")
def predict(self, X):
"""
Prédiction sur de nouvelles données
"""
return self.forward(X)
def predict_classes(self, X, threshold=0.5):
"""
Prédiction des classes (classification binaire)
"""
probabilities = self.predict(X)
return (probabilities > threshold).astype(int)
def compute_accuracy(self, y_true, y_pred, threshold=0.5):
"""
Calcule l'accuracy pour la classification binaire
"""
predictions = (y_pred > threshold).astype(int)
return np.mean(predictions.flatten() == y_true.flatten())
def get_weights(self):
"""
Retourne les poids de toutes les couches
"""
weights = []
for i, couche in enumerate(self.couches):
weights.append({
'couche': i,
'weights': couche.weights.copy(),
'bias': couche.bias.copy()
})
return weights
def set_weights(self, weights):
"""
Définit les poids de toutes les couches
"""
for i, weight_dict in enumerate(weights):
self.couches[i].weights = weight_dict['weights'].copy()
self.couches[i].bias = weight_dict['bias'].copy()
import React from "react";
import { Graph, GraphFactory } from './v2/graph.js';
import { exportToTikz } from './v2/export_tikz.js';
import { SquareNode, CircleNode, SwitchNode } from './v2/nodes.js';
import { SequentialNode, RouterNode } from './v2/nodes/flow.js';
import * as Nodes from './v2/nodes/index.js';
import { Renderer } from './v2/renderer.js';
import { Grid, World } from './v2/world.js';
import { drawArrow } from './v2/utils.js';
import { Profiler } from './v2/profiler.js';
import { NodeProperties } from './v2/node_properties.js';
import { SequentialMenu } from './v2/sequential_menu.js';
import { NodeSearchPopup } from './v2/node_search.js';
(function () {
const canvas = document.getElementById('diagram');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const zoomLabel = document.getElementById('zoomLabel');
const exportTikzBtn = document.getElementById('exportTikz');
const resetViewBtn = document.getElementById('resetView');
const toggleDragNodes = document.getElementById('toggleDragNodes');
const saveGraphBtn = document.getElementById('saveGraph');
const restoreGraphBtn = document.getElementById('restoreGraph');
let devicePixelRatioScale = Math.max(1, window.devicePixelRatio || 1);
function resizeCanvasToDisplaySize() {
const { clientWidth, clientHeight } = canvas;
const displayWidth = Math.floor(clientWidth * devicePixelRatioScale);
const displayHeight = Math.floor(clientHeight * devicePixelRatioScale);
if (canvas.width !== displayWidth || canvas.height !== displayHeight) {
canvas.width = displayWidth;
canvas.height = displayHeight;
}
}
function fitCanvasElement() {
const rect = canvas.getBoundingClientRect();
const width = window.innerWidth;
const height = window.innerHeight;
if (rect.width !== width || rect.height !== height) {
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
}
resizeCanvasToDisplaySize();
}
class AppController {
constructor(ctx, world, renderer, graph) {
this.ctx = ctx;
this.world = world;
this.renderer = renderer;
this.graph = graph;
this.isPanning = false;
this.panStart = { x: 0, y: 0 };
this.panOrigin = { x: 0, y: 0 };
this.draggingNode = null;
this.dragOffset = { x: 0, y: 0 };
this.draggingEdge = null;
this.draggingGroup = null;
this.resizingPackage = null; // { node, startX, startY, startW, startH }
this.selectionAddMode = false;
this.nodeProperties = new NodeProperties(this);
this.nodeSearchPopup = new NodeSearchPopup(this);
// Handle label display state
this.showAllHandleLabels = false; // Space bar pressed
this.hoveredHandle = null; // Currently hovered handle
this.hoveredEdge = null; // Currently hovered edge for connected handles
// Performance optimizations
this.lastDrawTime = 0;
this.drawThrottleMs = 16; // ~60fps
this.pendingDraw = false;
this.mousePos = { x: 0, y: 0 };
this.lastMousePos = { x: 0, y: 0 };
this.mouseDelta = { x: 0, y: 0 };
// Object pools for frequently created objects
this.tempPoint = { x: 0, y: 0 };
this.tempRect = { x: 0, y: 0, width: 0, height: 0 };
this.installEvents();
}
installEvents() {
window.addEventListener('resize', () => {
fitCanvasElement();
this.draw();
});
document.addEventListener('keydown', (e) => {
if(this.nodeProperties.activeNodeForProps) return;
if (this.nodeSearchPopup.visible) return;
if (e.code === 'Tab') {
e.preventDefault();
this.showAllHandleLabels = true;
this.draw();
}
if (e.code === 'F1') {
e.preventDefault();
Profiler.toggleEnabled();
this.draw();
}
if (e.key && e.key.toLowerCase() === 'a') {
this.renderer.animateEdges = !this.renderer.animateEdges;
if (this.renderer.animateEdges) this.startEdgeAnimationLoop();
}
if (e.key.toLowerCase() === 'r') {
this.resetView();
}
if (e.key === 'Delete' || e.key === 'x') {
this.deleteSelection();
this.draw();
}
if (e.key && e.key.toLowerCase() === 's') {
if (this.renderer.selectedEdges.size === 1) {
const [edge] = Array.from(this.renderer.selectedEdges);
const a = this.graph.nodeMap.get(edge.from);
const b = this.graph.nodeMap.get(edge.to);
if (a && b) {
const { from, to } = this.renderer.edgeEndpointsByIndex(a, edge.fromIndex || 0, b, edge.toIndex || 0);
const mx = (from.x + to.x) / 2;
const my = (from.y + to.y) / 2;
const id = 'n' + Math.random().toString(36).slice(2, 8);
const split = new Nodes.SplitNode({ id, x: mx - 6, y: my - 6, width: 12, height: 12, kind: 'gray' });
split.ensureSize(this.ctx);
// Center precisely after ensureSize
split.x = Math.round(mx - split.width / 2);
split.y = Math.round(my - split.height / 2);
// Replace edge with two edges via split
this.graph.edges = this.graph.edges.filter(e2 => e2 !== edge);
this.graph.addNode(split, this.ctx);
this.graph.addEdge({ from: edge.from, fromIndex: edge.fromIndex || 0, to: id, toIndex: 0 });
this.graph.addEdge({ from: id, fromIndex: 0, to: edge.to, toIndex: edge.toIndex || 0 });
this.renderer.selectedEdges.clear();
this.draw();
}
}
}
// Edge renderer shortcuts
if (e.key && e.key.toLowerCase() === '1') {
this.renderer.setDefaultEdgeRenderer('sigmoid');
this.draw();
}
if (e.key && e.key.toLowerCase() === '2') {
this.renderer.setDefaultEdgeRenderer('straight');
this.draw();
}
if (e.key && e.key.toLowerCase() === '3') {
this.renderer.setDefaultEdgeRenderer('stair');
this.draw();
}
if (e.key && e.key.toLowerCase() === '4') {
this.renderer.setDefaultEdgeRenderer('orthogonal');
this.draw();
}
// Per-edge renderer switching (when edge is selected)
if (e.key && e.key.toLowerCase() === 'e') {
if (this.renderer.selectedEdges.size === 1) {
const [edge] = Array.from(this.renderer.selectedEdges);
const edgeId = edge.id || `${edge.from}-${edge.to}`;
const currentRenderer = this.renderer.getEdgeRenderer(edgeId);
const renderers = ['sigmoid', 'straight', 'stair'];
const currentIndex = renderers.indexOf(currentRenderer);
const nextIndex = (currentIndex + 1) % renderers.length;
this.renderer.setEdgeRenderer(edgeId, renderers[nextIndex]);
this.draw();
}
}
if (e.key && e.key.toLowerCase() === 'w') {
if (this.renderer.selectedEdges.size === 1) {
const [edge] = Array.from(this.renderer.selectedEdges);
const a = this.graph.nodeMap.get(edge.from);
const b = this.graph.nodeMap.get(edge.to);
if (a && b) {
const { from, to } = this.renderer.edgeEndpointsByIndex(a, edge.fromIndex || 0, b, edge.toIndex || 0);
const warpId = 'w' + Math.random().toString(36).slice(2, 6);
const srcId = 'n' + Math.random().toString(36).slice(2, 8);
const dstId = 'n' + Math.random().toString(36).slice(2, 8);
const src = new Nodes.WarpNode({ id: srcId, x: from.x + 8, y: from.y - 12, width: 70, height: 24, title: 'Warp', dir: 'src', warpId });
const dst = new Nodes.WarpNode({ id: dstId, x: to.x - 78, y: to.y - 12, width: 70, height: 24, title: 'Warp', dir: 'dst', warpId });
src.ensureSize(this.ctx); dst.ensureSize(this.ctx);
this.graph.edges = this.graph.edges.filter(e2 => e2 !== edge);
this.graph.addNode(src, this.ctx);
this.graph.addNode(dst, this.ctx);
this.graph.addEdge({ from: edge.from, fromIndex: edge.fromIndex || 0, to: srcId, toIndex: 0 });
this.graph.addEdge({ from: dstId, fromIndex: 0, to: edge.to, toIndex: edge.toIndex || 0 });
this.renderer.selectedEdges.clear();
this.draw();
}
}
}
});
document.addEventListener('keyup', (e) => {
if (e.code === 'Tab') {
this.showAllHandleLabels = false;
this.draw();
}
if (e.code === 'Space') {
// Only trigger if not in an input field or textarea
const activeElement = document.activeElement;
const isInputFocused = activeElement && (
activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
activeElement.isContentEditable
);
if (!isInputFocused) {
e.preventDefault();
// Show node search popup with filtered nodes based on editor type
// Only show if not already visible and not in node properties mode
if (!this.nodeSearchPopup.visible && !this.nodeProperties.activeNodeForProps) {
this.nodeSearchPopup.showWithFilter().catch(err => {
console.error('Error showing node search popup:', err);
});
}
}
}
});
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const delta = -e.deltaY;
const zoomFactor = Math.exp(delta * 0.001);
const prevScale = this.world.scale;
let nextScale = prevScale * zoomFactor;
nextScale = Math.max(
this.world.minScale, Math.min(this.world.maxScale, nextScale));
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const before = this.world.screenToWorld(mx, my);
this.world.scale = nextScale;
const after = this.world.screenToWorld(mx, my);
this.world.translateX += (after.x - before.x) * this.world.scale;
this.world.translateY += (after.y - before.y) * this.world.scale;
this.draw();
}, { passive: false });
canvas.addEventListener('mousedown', (e) => this.onMouseDown(e));
canvas.addEventListener('mousemove', (e) => this.onMouseMove(e));
canvas.addEventListener('mouseup', () => this.endInteractions());
canvas.addEventListener('mouseleave', () => this.endInteractions());
canvas.addEventListener('dblclick', (e) => this.onDoubleClick(e));
if (resetViewBtn)
resetViewBtn.addEventListener('click', () => this.resetView());
if (saveGraphBtn)
saveGraphBtn.addEventListener('click', () => this.saveToLocalStorage());
if (restoreGraphBtn)
restoreGraphBtn.addEventListener('click', () => this.restoreFromLocalStorage());
if (exportTikzBtn)
exportTikzBtn.addEventListener('click', () => this.exportTikz());
const palette = document.getElementById('palette');
if (palette) {
palette.addEventListener('dragstart', (e) => {
const el = e.target && e.target.closest ? e.target.closest('.palette-item') : null;
if (!(el && el.getAttribute)) return;
const kind = el.getAttribute('data-kind') || 'node';
e.dataTransfer.setData('application/x-node-kind', kind);
// Store quark_id or atom_id if present
const quarkId = el.getAttribute('data-quark-id');
const atomId = el.getAttribute('data-atom-id');
if (quarkId) {
e.dataTransfer.setData('application/x-quark-id', quarkId);
}
if (atomId) {
e.dataTransfer.setData('application/x-atom-id', atomId);
}
e.dataTransfer.effectAllowed = 'copy';
});
canvas.addEventListener('dragover', (e) => {
e.preventDefault();
});
canvas.addEventListener('drop', (e) => this.onDrop(e));
}
}
onDoubleClick(e) {
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const w = this.world.screenToWorld(mouseX, mouseY);
const hitNode = this.renderer.nodeAtPoint(w.x, w.y);
if (hitNode) {
this.nodeProperties.open(hitNode, e.clientX, e.clientY);
}
}
exportTikz() {
try {
const data = this.factory.serializeGraph(this.world, this.graph);
const tikz = exportToTikz({ nodes: data.nodes, edges: data.edges }, { scale: 0.12, includeTheme: true });
const blob = new Blob([tikz], { type: 'text/x-tex;charset=UTF-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'diagram.tex';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
if (zoomLabel) {
const prev = zoomLabel.textContent;
zoomLabel.textContent = 'Exported';
setTimeout(() => { if (zoomLabel.textContent === 'Exported') zoomLabel.textContent = prev; }, 900);
}
} catch (err) {
console.error('Export failed', err);
window.alert('Failed to export TikZ. See console for details.');
}
}
toSerializableNode(n) {
// Ensure shape is set (default to 'rect')
const shape = n.shape || 'rect';
// Only include type if it's actually a node type (not a shape value)
const shapeValues = ['rect', 'circle', 'rounded'];
const nodeType = (n.type && !shapeValues.includes(n.type)) ? n.type : null;
const base = {
id: n.id,
shape: shape,
x: n.x,
y: n.y,
width: n.width,
height: n.height,
kind: n.kind,
title: n.title,
sublabel: n.sublabel,
parentId: n.parentId || null,
children: Array.isArray(n.children) ? n.children.slice() : []
};
// Only add type field if it's a valid node type
if (nodeType) {
base.type = nodeType;
}
if (n.type === 'sequential' || n.type === 'router') {
base.ops = Array.isArray(n.ops) ? n.ops : [];
}
if (n.type === 'warp') {
base.dir = n.dir;
base.warpId = n.warpId;
}
if (n.type === 'operator' && n.fontScale) {
base.fontScale = n.fontScale;
}
return base;
}
getCtorForType(type) {
return this.factory.getCtor(type);
}
serializeGraph() { return this.factory.serializeGraph(this.world, this.graph); }
async loadGraphData(data) {
if (!data || !Array.isArray(data.nodes) || !Array.isArray(data.edges)) throw new Error('Invalid graph data');
this.renderer.selectedNodeIds.clear();
this.renderer.selectedEdges.clear();
await this.factory.loadGraphData(this.ctx, this.graph, data);
if (data.camera) {
if (typeof data.camera.scale === 'number') this.world.scale = data.camera.scale;
if (typeof data.camera.translateX === 'number') this.world.translateX = data.camera.translateX;
if (typeof data.camera.translateY === 'number') this.world.translateY = data.camera.translateY;
}
this.draw();
}
saveToLocalStorage() {
try {
const payload = this.serializeGraph();
const json = JSON.stringify(payload);
window.localStorage.setItem('diagramGraphV2', json);
// lightweight UX feedback
if (zoomLabel) {
const prev = zoomLabel.textContent;
zoomLabel.textContent = 'Saved';
setTimeout(() => { if (zoomLabel.textContent === 'Saved') zoomLabel.textContent = prev; }, 900);
}
} catch (err) {
console.error('Save failed', err);
window.alert('Failed to save the graph. See console for details.');
}
}
restoreFromLocalStorage() {
try {
const json = window.localStorage.getItem('diagramGraphV2');
if (!json) {
window.alert('No saved graph found in this browser.');
return;
}
const data = JSON.parse(json);
this.loadGraphData(data);
if (zoomLabel) {
const prev = zoomLabel.textContent;
zoomLabel.textContent = 'Restored';
setTimeout(() => { if (zoomLabel.textContent === 'Restored') zoomLabel.textContent = prev; }, 900);
}
} catch (err) {
console.error('Restore failed', err);
window.alert('Failed to restore the graph. The saved data may be invalid.');
}
this.resetView()
}
deleteSelection() {
if (this.renderer.selectedNodeIds.size > 0) {
for (let i = this.graph.nodes.length - 1; i >= 0; i--)
if (this.renderer.selectedNodeIds.has(this.graph.nodes[i].id))
this.graph.nodes.splice(i, 1);
this.graph.nodeMap.clear();
for (const n of this.graph.nodes) this.graph.nodeMap.set(n.id, n);
for (let i = this.graph.edges.length - 1; i >= 0; i--)
if (this.renderer.selectedNodeIds.has(this.graph.edges[i].from) ||
this.renderer.selectedNodeIds.has(this.graph.edges[i].to))
this.graph.edges.splice(i, 1);
this.renderer.selectedNodeIds.clear();
}
if (this.renderer.selectedEdges.size > 0) {
this.graph.edges =
this.graph.edges.filter(e => !this.renderer.selectedEdges.has(e));
this.renderer.selectedEdges.clear();
}
}
onMouseDown(e) {
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const w = this.world.screenToWorld(mouseX, mouseY);
// Click on edge selects it (with tolerance), but ignore if near any handle to avoid accidental selection near endpoints
const hitEdge = (!this.renderer.isNearAnyHandle(w.x, w.y, this.renderer.edgeNearHandleExcludeRadius)) ? this.renderer.hitTestEdge(w.x, w.y) : null;
if (hitEdge) {
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
this.renderer.selectedNodeIds.clear();
this.renderer.selectedEdges.clear();
}
this.renderer.selectedEdges.add(hitEdge);
this.draw();
return;
}
// Check for menu clicks on any ListBlockNode
for (const node of this.graph.nodes) {
if (node.hitTestMenu && node.hitTestMenu(w.x, w.y)) {
const hit = node.hitTestMenu(w.x, w.y);
if (hit) {
node.addOperation(hit.item);
node.ensureSize(this.ctx);
node.hideMenu();
this.draw();
return;
}
}
}
// Hide any open menus
for (const node of this.graph.nodes) {
if (node.hideMenu) {
node.hideMenu();
}
}
if (e.button === 2) {
this.isPanning = true;
this.panStart = {
x: mouseX * devicePixelRatioScale,
y: mouseY * devicePixelRatioScale
};
this.panOrigin = { x: this.world.translateX, y: this.world.translateY };
canvas.classList.add('cursor-grabbing');
return;
}
const handle = this.renderer.hitTestHandle(w.x, w.y);
if (handle && handle.type === 'output') {
this.draggingEdge = {
fromNode: handle.node,
fromIndex: handle.index,
toWorld: { x: w.x, y: w.y }
};
return;
}
if (handle && handle.type === 'input' && (e.shiftKey || e.altKey)) {
const nodeId = handle.node.id;
const toNode = this.graph.nodeMap.get(nodeId);
// Handle infinite inputs for ScreenNode
if (toNode && toNode.type === 'screen') {
// For infinite inputs, we need to find the edge by the handle's actual index
const idx = handle.index;
if (typeof idx === 'number') {
// Find the edge connected to this specific input index
const edgeIndex = this.graph.edges.findIndex(e =>
e.to === nodeId && e.toIndex === idx
);
if (edgeIndex >= 0) {
const edge = this.graph.edges[edgeIndex];
// Use the renderer's disconnect method to properly update the ScreenNode
this.renderer.disconnectFromInfiniteInput(edge.from, nodeId, idx);
}
}
} else {
// Standard finite input handling
const idx = handle.index;
for (let i = this.graph.edges.length - 1; i >= 0; i--) {
if (this.graph.edges[i].to === nodeId &&
this.graph.edges[i].toIndex === idx) {
this.graph.edges.splice(i, 1);
break;
}
}
}
this.draw();
return;
}
let hitNode = this.renderer.nodeAtPoint(w.x, w.y);
// Package bottom-right resize handle hit-test (10x10 square) - require Shift
if (hitNode && hitNode.type === 'package' && e.shiftKey) {
const hs = 10;
const hx = hitNode.x + hitNode.width - hs;
const hy = hitNode.y + hitNode.height - hs;
if (w.x >= hx && w.x <= hx + hs && w.y >= hy && w.y <= hy + hs) {
this.resizingPackage = {
node: hitNode,
startX: w.x,
startY: w.y,
startW: hitNode.width,
startH: hitNode.height
};
this.renderer.selectedNodeIds.add(hitNode.id);
this.draw();
return;
}
}
// Selecting/moving a package requires Shift. If Shift not held, ignore package as a hit.
if (hitNode && hitNode.type === 'package' && !e.shiftKey) {
hitNode = null;
}
// Check for plus button clicks on ListBlockNodes
if (hitNode && hitNode.hitPlus && hitNode.hitPlus(w.x, w.y)) {
hitNode.showMenu();
this.draw();
return;
}
if (toggleDragNodes && toggleDragNodes.checked) {
if (hitNode) {
if (!e.shiftKey && !e.ctrlKey && !e.metaKey &&
!this.renderer.selectedNodeIds.has(hitNode.id)) {
this.renderer.selectedEdges.clear();
this.renderer.selectedNodeIds.clear();
}
// If the hit is a package but Shift is not pressed, treat as if nothing hit
if (hitNode.type === 'package' && !e.shiftKey) {
hitNode = null;
}
if (hitNode) {
this.renderer.selectedNodeIds.add(hitNode.id);
// Bring selected node to front (reorder in data structure)
this.graph.bringToFront(hitNode.id);
this.draggingNode = hitNode;
this.dragOffset = { x: w.x - hitNode.x, y: w.y - hitNode.y };
}
if (this.renderer.selectedNodeIds.size > 1) {
const offsets = new Map();
for (const id of this.renderer.selectedNodeIds) {
const n = this.graph.nodeMap.get(id);
if (!n) continue;
offsets.set(id, { dx: n.x - hitNode.x, dy: n.y - hitNode.y });
}
this.draggingGroup = { anchorId: hitNode.id, offsets };
} else {
this.draggingGroup = null;
}
this.draw();
return;
}
}
if (e.button === 0) {
this.renderer.isSelecting = true;
this.renderer.selectionStart = { x: w.x, y: w.y };
this.renderer.selectionRect = { x1: w.x, y1: w.y, x2: w.x, y2: w.y };
this.selectionAddMode = !!(e.shiftKey || e.ctrlKey || e.metaKey);
this.draw();
return;
}
}
onMouseMove(e) {
const rect = canvas.getBoundingClientRect();
this.mousePos.x = e.clientX - rect.left;
this.mousePos.y = e.clientY - rect.top;
// Calculate delta for smooth interactions
this.mouseDelta.x = this.mousePos.x - this.lastMousePos.x;
this.mouseDelta.y = this.mousePos.y - this.lastMousePos.y;
const w = this.world.screenToWorld(this.mousePos.x, this.mousePos.y);
// Hover feedback on edges (skip when near handles)
const he = this.renderer.hitTestEdge(w.x, w.y);
if (this.renderer.hoverEdge !== he) {
this.renderer.hoverEdge = he;
this.hoveredEdge = he; // Track for handle label display
this.draw();
}
// Handle hover detection for labels
const handle = this.renderer.hitTestHandle(w.x, w.y);
if (this.hoveredHandle !== handle) {
this.hoveredHandle = handle;
this.draw();
}
// Handle sequential menu hover
let menuRedrawNeeded = false;
for (const node of this.graph.nodes) {
if (node.sequentialMenu && node.sequentialMenu.visible) {
if (node.sequentialMenu.updateHover(w.x, w.y)) {
menuRedrawNeeded = true;
}
}
}
if (menuRedrawNeeded) {
this.draw();
}
// Update last mouse position for next frame
this.lastMousePos.x = this.mousePos.x;
this.lastMousePos.y = this.mousePos.y;
if (this.isPanning) {
const dx = this.mousePos.x * devicePixelRatioScale - this.panStart.x;
const dy = this.mousePos.y * devicePixelRatioScale - this.panStart.y;
this.world.translateX = this.panOrigin.x + dx;
this.world.translateY = this.panOrigin.y + dy;
this.draw();
return;
}
if (this.resizingPackage) {
const s = this.resizingPackage;
const dx = w.x - s.startX;
const dy = w.y - s.startY;
// Enforce minimums to keep label visible
const minW = 160;
const minH = 100;
s.node.width = Math.max(minW, Math.round(s.startW + dx));
s.node.height = Math.max(minH, Math.round(s.startH + dy));
// Do not change parentage while resizing; only visual
canvas.style.cursor = 'nwse-resize';
this.draw();
return;
}
if (this.renderer.isSelecting && this.renderer.selectionRect) {
this.renderer.selectionRect.x2 = w.x;
this.renderer.selectionRect.y2 = w.y;
this.draw();
return;
}
if (this.draggingEdge) {
this.draggingEdge.toWorld = { x: w.x, y: w.y };
this.draw();
return;
}
if (this.draggingNode) {
if (this.draggingGroup && this.draggingGroup.offsets &&
this.draggingGroup.offsets.size > 0) {
const anchorX = w.x - this.dragOffset.x;
const anchorY = w.y - this.dragOffset.y;
// Ensure finite values to prevent gradient errors
if (!Number.isFinite(anchorX) || !Number.isFinite(anchorY)) {
console.warn('Non-finite group drag coordinates detected:', { anchorX, anchorY, wx: w.x, wy: w.y, dragOffset: this.dragOffset });
return;
}
// Track previous positions of packages to calculate deltas for children
const packagePreviousPositions = new Map();
for (const id of this.renderer.selectedNodeIds) {
const n = this.graph.nodeMap.get(id);
if (!n) continue;
if (n.type === 'package') {
packagePreviousPositions.set(id, { x: n.x, y: n.y });
}
}
// Move all selected nodes
for (const id of this.renderer.selectedNodeIds) {
const n = this.graph.nodeMap.get(id);
if (!n) continue;
const off = this.draggingGroup.offsets.get(id) || { dx: 0, dy: 0 };
const newX = anchorX + off.dx;
const newY = anchorY + off.dy;
n.x = newX;
n.y = newY;
}
// Move children of packages that were moved
for (const [pkgId, prevPos] of packagePreviousPositions) {
const pkg = this.graph.nodeMap.get(pkgId);
if (!pkg) continue;
const dx = pkg.x - prevPos.x;
const dy = pkg.y - prevPos.y;
if (dx !== 0 || dy !== 0) {
const kids = this.graph.getChildren(pkg.id);
for (const child of kids) {
// Only move child if it's not also selected (to avoid double-moving)
if (!this.renderer.selectedNodeIds.has(child.id)) {
child.x += dx;
child.y += dy;
}
}
}
}
} else {
const nx = w.x - this.dragOffset.x;
const ny = w.y - this.dragOffset.y;
// Ensure finite values to prevent gradient errors
if (!Number.isFinite(nx) || !Number.isFinite(ny)) {
console.warn('Non-finite drag coordinates detected:', { nx, ny, wx: w.x, wy: w.y, dragOffset: this.dragOffset });
return;
}
if (this.draggingNode.type === 'package') {
const pkg = this.draggingNode;
const dx = nx - pkg.x;
const dy = ny - pkg.y;
// Only move current children of the package while dragging
const kids = this.graph.getChildren(pkg.id);
pkg.x = nx;
pkg.y = ny;
for (const child of kids) { child.x += dx; child.y += dy; }
} else {
this.draggingNode.x = nx;
this.draggingNode.y = ny;
}
}
this.draw();
return;
}
// Node hover indication: prefer node under cursor over edge hover
const prevHoverNode = this.renderer.hoverNodeId;
const nodeUnder = this.renderer.nodeAtPoint(w.x, w.y);
// Check if hovering over package resize handle with Shift
if (nodeUnder && nodeUnder.type === 'package' && e.shiftKey) {
const hs = 10;
const hx = nodeUnder.x + nodeUnder.width - hs;
const hy = nodeUnder.y + nodeUnder.height - hs;
if (w.x >= hx && w.x <= hx + hs && w.y >= hy && w.y <= hy + hs) {
canvas.style.cursor = 'nwse-resize';
} else {
canvas.style.cursor = nodeUnder ? 'move' : 'default';
}
} else if (nodeUnder && nodeUnder.type === 'package' && !e.shiftKey) {
// Package but Shift not held - ignore it
canvas.style.cursor = 'default';
this.renderer.hoverNodeId = null;
} else {
canvas.style.cursor = nodeUnder ? 'move' : 'default';
this.renderer.hoverNodeId = nodeUnder ? nodeUnder.id : null;
}
if (this.renderer.hoverNodeId !== prevHoverNode) this.draw();
}
endInteractions() {
if (this.isPanning) {
this.isPanning = false;
canvas.classList.remove('cursor-grabbing');
}
if (this.draggingNode) {
const s = 24;
// Snap helper: choose the nearest anchor among center and four corners
const snapByAnchors = (x, y, w, h, spacing) => {
const anchors = [
// center
{ ax: x + w / 2, ay: y + h / 2 },
// top-left
{ ax: x, ay: y },
// top-right
{ ax: x + w, ay: y },
// bottom-left
{ ax: x, ay: y + h },
// bottom-right
{ ax: x + w, ay: y + h },
];
let bestX = x, bestY = y;
let bestD2 = Infinity;
for (const a of anchors) {
const snappedAx = Math.round(a.ax / spacing) * spacing;
const snappedAy = Math.round(a.ay / spacing) * spacing;
const dx = snappedAx - a.ax;
const dy = snappedAy - a.ay;
const nx = x + dx;
const ny = y + dy;
const d2 = dx * dx + dy * dy;
if (d2 < bestD2) { bestD2 = d2; bestX = nx; bestY = ny; }
}
return { x: bestX, y: bestY };
};
if (this.draggingGroup && this.draggingGroup.offsets &&
this.draggingGroup.offsets.size > 0) {
const nodesToSnap = [];
const packageSnapDeltas = new Map(); // Track snap deltas for packages
for (const id of this.renderer.selectedNodeIds) {
const n = this.graph.nodeMap.get(id);
if (!n) continue;
if (n.type === 'package') {
// Store package position before snapping
packageSnapDeltas.set(id, { x: n.x, y: n.y });
continue; // keep behavior: don't snap packages directly
}
nodesToSnap.push(n);
}
if (nodesToSnap.length > 0) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of nodesToSnap) {
minX = Math.min(minX, n.x);
minY = Math.min(minY, n.y);
maxX = Math.max(maxX, n.x + n.width);
maxY = Math.max(maxY, n.y + n.height);
}
const bboxW = maxX - minX;
const bboxH = maxY - minY;
const snapped = snapByAnchors(minX, minY, bboxW, bboxH, s);
const dx = snapped.x - minX;
const dy = snapped.y - minY;
for (const n of nodesToSnap) {
n.x += dx;
n.y += dy;
}
// Apply same snap delta to packages in selection
for (const [pkgId, prevPos] of packageSnapDeltas) {
const pkg = this.graph.nodeMap.get(pkgId);
if (!pkg) continue;
pkg.x += dx;
pkg.y += dy;
// Move package children by the same delta
const kids = this.graph.getChildren(pkg.id);
for (const child of kids) {
// Only move child if it's not also selected (to avoid double-moving)
if (!this.renderer.selectedNodeIds.has(child.id)) {
child.x += dx;
child.y += dy;
}
}
}
}
} else {
if (this.draggingNode.type === 'package') {
// Do not snap the package; snap only its inner children
const kids = this.graph.getChildren(this.draggingNode.id);
for (const child of kids) {
const p = snapByAnchors(child.x, child.y, child.width, child.height, s);
child.x = p.x;
child.y = p.y;
}
} else {
const p = snapByAnchors(this.draggingNode.x, this.draggingNode.y, this.draggingNode.width, this.draggingNode.height, s);
this.draggingNode.x = p.x;
this.draggingNode.y = p.y;
}
}
// After drag ends, evaluate package membership changes for all moved nodes
const movedNodes = (this.draggingGroup && this.draggingGroup.offsets && this.draggingGroup.offsets.size > 0)
? Array.from(this.renderer.selectedNodeIds).map(id => this.graph.nodeMap.get(id)).filter(Boolean)
: [this.draggingNode];
for (const moved of movedNodes) {
if (!moved || moved.type === 'package') continue;
const cx = moved.x + moved.width / 2;
const cy = moved.y + moved.height / 2;
// find top-most package containing the center
let targetPkg = null;
for (let i = this.graph.nodes.length - 1; i >= 0; i--) {
const n = this.graph.nodes[i];
if (n.type !== 'package') continue;
if (cx >= n.x && cx <= n.x + n.width && cy >= n.y && cy <= n.y + n.height) { targetPkg = n; break; }
}
if (targetPkg && moved.parentId !== targetPkg.id) {
this.graph.attach(moved.id, targetPkg.id);
} else if (!targetPkg && moved.parentId) {
this.graph.detach(moved.id);
}
}
this.draw();
}
this.draggingNode = null;
this.draggingGroup = null;
if (this.resizingPackage) {
this.resizingPackage = null;
canvas.style.cursor = 'default';
this.draw();
}
if (this.draggingEdge) {
const to = this.draggingEdge.toWorld;
if (to) {
const h = this.renderer.hitTestHandle(to.x, to.y);
if (h && h.type === 'input' &&
h.node.id !== this.draggingEdge.fromNode.id) {
const toId = h.node.id;
const toNode = this.graph.nodeMap.get(toId);
// Handle infinite inputs for ScreenNode
if (toNode && toNode.type === 'screen') {
try {
// Use the renderer's infinite input connection method
this.renderer.connectToInfiniteInput(
this.draggingEdge.fromNode.id,
toId
);
} catch (error) {
console.warn('Failed to connect to infinite input:', error.message);
}
} else {
// Standard finite input handling
const toIndex = h.index;
const newEdge = {
from: this.draggingEdge.fromNode.id,
fromIndex: this.draggingEdge.fromIndex || 0,
to: toId,
toIndex
};
// Prevent connecting to an already-connected input elsewhere: do not replace
const inputOccupied = this.graph.edges.some(e => e.to === toId && e.toIndex === toIndex);
const duplicate = this.graph.edges.some(
e => e.from === newEdge.from &&
e.fromIndex === newEdge.fromIndex &&
e.to === newEdge.to && e.toIndex === newEdge.toIndex);
if (!inputOccupied && !duplicate) this.graph.edges.push(newEdge);
}
}
}
this.draggingEdge = null;
this.draw();
}
if (this.renderer.isSelecting && this.renderer.selectionRect) {
const r = this.renderer.normalizeRect(this.renderer.selectionRect);
if (!this.selectionAddMode) {
this.renderer.selectedNodeIds.clear();
this.renderer.selectedEdges.clear();
}
for (const n of this.graph.nodes) {
const cx = n.x + n.width / 2;
const cy = n.y + n.height / 2;
if (this.renderer.rectContainsPoint(r, cx, cy))
this.renderer.selectedNodeIds.add(n.id);
}
for (const e of this.graph.edges) {
const a = this.graph.nodeMap.get(e.from);
const b = this.graph.nodeMap.get(e.to);
if (!a || !b) continue;
const { from, to } = this.renderer.edgeEndpointsByIndex(
a, e.fromIndex || 0, b, e.toIndex || 0);
if (this.renderer.edgeIntersectsRect(r, from, to))
this.renderer.selectedEdges.add(e);
}
// Bring all selected nodes to front (reorder in data structure)
for (const nodeId of this.renderer.selectedNodeIds) {
this.graph.bringToFront(nodeId);
}
this.renderer.isSelecting = false;
this.renderer.selectionStart = null;
this.renderer.selectionRect = null;
this.selectionAddMode = false;
this.draw();
}
}
onDrop(e) {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const w = this.world.screenToWorld(mouseX, mouseY);
const type = e.dataTransfer.getData('application/x-node-kind') ||
e.dataTransfer.getData('text/plain');
const quarkId = e.dataTransfer.getData('application/x-quark-id');
const atomId = e.dataTransfer.getData('application/x-atom-id');
const id = 'n' + Math.random().toString(36).slice(2, 8);
let node;
// Handle quark node (from atom editor)
if (type === 'quark' && quarkId) {
// Import API dynamically to avoid circular dependencies
import('./v2/api/quarks.js').then(({ QuarksAPI }) => {
return QuarksAPI.getById(quarkId);
}).then(quark => {
node = new Nodes.QuarkNode({
id,
x: w.x,
y: w.y,
quark_id: quark.id,
quark_inputs_count: quark.inputs_count,
quark_outputs_count: quark.outputs_count,
title: quark.name,
});
this.graph.addNode(node, this.ctx);
this.draw();
}).catch(err => console.error('Error loading quark:', err));
return;
}
// Handle atom node (from molecule editor)
if (type === 'atom' && atomId) {
// Import API dynamically to avoid circular dependencies
import('./v2/api/atoms.js').then(({ AtomsAPI }) => {
return AtomsAPI.getById(atomId);
}).then(atom => {
node = new Nodes.AtomNode({
id,
x: w.x,
y: w.y,
atom_id: atom.id,
atom_inputs_count: atom.inputs_count,
atom_outputs_count: atom.outputs_count,
title: atom.name,
});
this.graph.addNode(node, this.ctx);
this.draw();
}).catch(err => console.error('Error loading atom:', err));
return;
}
// Legacy and special kinds
if (type === 'switch') {
node = new SwitchNode({ id, x: w.x, y: w.y, kind: 'switch', title: 'Switch' });
} else if (type === 'input') {
node = new Nodes.InputNode({ id, x: w.x, y: w.y });
} else if (type === 'output') {
node = new Nodes.OutputNode({ id, x: w.x, y: w.y });
} else if (type === 'linear') {
node = new SquareNode({ id, x: w.x, y: w.y, kind: 'blue', title: 'Linear', sublabel: '64', shape: 'rect' });
} else if (type === 'bmm') {
node = new SquareNode({ id, x: w.x, y: w.y, kind: 'gray', title: 'BMM', sublabel: '121', shape: 'rect' });
} else if (type === 'sequential') {
node = new SequentialNode({ id, x: w.x, y: w.y, kind: 'gray', title: 'Sequential', ops: [] });
} else if (type === 'router') {
node = new RouterNode({ id, x: w.x, y: w.y, kind: 'gray', title: 'Router', ops: [] });
} else {
// Try dynamic mapping to new node classes by kind
const map = {
scene: Nodes.SceneNode,
object: Nodes.ObjectNode,
fuel: Nodes.FuelNode,
rotation: Nodes.RotationNode,
temp: Nodes.TempNode,
flow: Nodes.FlowNode,
vibration: Nodes.VibrationNode,
position: Nodes.PositionNode,
volt: Nodes.VoltNode,
not: Nodes.NotNode,
and: Nodes.AndNode,
or: Nodes.OrNode,
equals: Nodes.EqualsNode,
empty: Nodes.EmptyNode,
constant: Nodes.ConstantNode,
add: Nodes.AddNode,
sub: Nodes.SubNode,
mul: Nodes.MulNode,
div: Nodes.DivNode,
abs: Nodes.AbsNode,
prediction: Nodes.PredictionNode,
database: Nodes.DatabaseNode,
plot: Nodes.PlotNode,
alert: Nodes.AlertNode,
text: Nodes.TextNode,
screen: Nodes.ScreenNode,
package: Nodes.PackageNode,
split: Nodes.SplitNode,
// test shapes
test_left_rounded: Nodes.LeftRoundedNode,
test_right_rounded: Nodes.RightRoundedNode,
test_left_triangle: Nodes.LeftTriangleNode,
test_right_triangle: Nodes.RightTriangleNode,
test_edge_triangles: Nodes.EdgeTrianglesNode,
test_param_border: Nodes.ParametricBorderNode,
};
const Ctor = map[type];
if (Ctor) {
if (type === 'warp') {
node = new Ctor({ id, x: w.x - 35, y: w.y - 12, width: 70, height: 24, title: 'Warp', dir: 'src', warpId: 'w' + Math.random().toString(36).slice(2, 6) });
} else if (type === 'package') {
node = new Ctor({ id, x: w.x - 80, y: w.y - 50, width: 180, height: 120, title: 'Package' });
} else if (type === 'test_param_border') {
node = new Ctor({ id, x: w.x - 70, y: w.y - 28, width: 160, height: 56, kind: 'purple', title: 'Param Border', leftShape: 'circle_revert', rightShape: 'triangle', topShape: 'diagonal', bottomShape: 'squared' });
} else {
node = new Ctor({ id, x: w.x - 60, y: w.y - 25, width: 120, height: 50 });
}
} else {
node = new SquareNode({ id, x: w.x - 50, y: w.y - 25, width: 100, height: 50, kind: 'gray', title: 'Node', sublabel: '', shape: 'rect' });
}
}
this.graph.addNode(node, this.ctx);
this.draw();
}
resetView() {
if (this.graph.nodes.length === 0) {
// No nodes to fit, reset to default view
this.world.scale = 1;
this.world.translateX = 0;
this.world.translateY = 0;
this.draw();
return;
}
// Compute world bounds from all nodes
let minX = Infinity, minY = Infinity;
let maxX = -Infinity, maxY = -Infinity;
for (const node of this.graph.nodes) {
minX = Math.min(minX, node.x);
minY = Math.min(minY, node.y);
maxX = Math.max(maxX, node.x + node.width);
maxY = Math.max(maxY, node.y + node.height);
}
// Add padding around the content
const padding = 50;
minX -= padding;
minY -= padding;
maxX += padding;
maxY += padding;
// Get canvas dimensions
const canvas = document.getElementById('diagram');
const canvasWidth = canvas.clientWidth;
const canvasHeight = canvas.clientHeight;
// Calculate content dimensions
const contentWidth = maxX - minX;
const contentHeight = maxY - minY;
// Calculate scale to fit content in viewport
const scaleX = canvasWidth / contentWidth;
const scaleY = canvasHeight / contentHeight;
const optimalScale = Math.min(scaleX, scaleY, this.world.maxScale);
// Ensure scale is within bounds
const finalScale = Math.max(optimalScale, this.world.minScale) * 0.5;
// Calculate translation to center the content
const scaledContentWidth = contentWidth * finalScale;
const scaledContentHeight = contentHeight * finalScale;
const translateX = (canvasWidth - scaledContentWidth) / 2 - minX * finalScale;
const translateY = (canvasHeight - scaledContentHeight) / 2 - minY * finalScale;
// Apply the new view settings
this.world.scale = finalScale;
this.world.translateX = translateX;
this.world.translateY = translateY;
this.draw();
}
draw() {
const now = performance.now();
if (now - this.lastDrawTime < this.drawThrottleMs) {
if (!this.pendingDraw) {
this.pendingDraw = true;
requestAnimationFrame(() => {
this.pendingDraw = false;
this.drawImmediate();
});
}
return;
}
this.drawImmediate();
}
drawImmediate() {
Profiler.startFrame();
this.lastDrawTime = performance.now();
fitCanvasElement();
this.world.reset(this.ctx);
this.ctx.clearRect(0, 0, canvas.width, canvas.height);
this.world.apply(this.ctx);
this.renderer.drawGrid();
this.renderer.drawEdges();
// Pass handle label display state to renderer
this.renderer.showAllHandleLabels = this.showAllHandleLabels;
this.renderer.hoveredHandle = this.hoveredHandle;
this.renderer.hoveredEdge = this.hoveredEdge;
// Draw dragging edge preview if any
if (this.draggingEdge && this.draggingEdge.toWorld) {
const startNode = this.draggingEdge.fromNode;
const pos = startNode.computeHandles().outputs;
const p = pos[Math.min(pos.length - 1, this.draggingEdge.fromIndex)] || {
x: startNode.x + startNode.width,
y: startNode.y + startNode.height / 2
};
this.ctx.save();
// world transform already applied above
drawArrow(this.ctx, { x: p.x, y: p.y }, this.draggingEdge.toWorld, { color: '#a4a6ab' });
this.ctx.restore();
}
this.renderer.drawNodes();
// Draw menus for all ListBlockNodes
for (const node of this.graph.nodes) {
if (node.drawMenu) {
node.drawMenu(this.ctx, this.world);
}
}
this.renderer.drawSelection();
this.world.reset(this.ctx);
// Minimap overlays are drawn in screen space
this.renderer.drawMinimap();
if (zoomLabel)
zoomLabel.textContent = Math.round(this.world.scale * 100) + '%';
Profiler.endFrame();
}
startEdgeAnimationLoop() {
if (!this.renderer.animateEdges) return;
this.draw();
window.requestAnimationFrame(() => this.startEdgeAnimationLoop());
}
}
fitCanvasElement();
const world = new World(devicePixelRatioScale);
const grid = new Grid();
const graph = new Graph();
const factory = new GraphFactory({
// base + legacy
rect: SquareNode,
switch: SwitchNode,
sequential: SequentialNode,
router: RouterNode,
// I/O nodes
input: Nodes.InputNode,
output: Nodes.OutputNode,
// Quark and Atom nodes
quark: Nodes.QuarkNode,
atom: Nodes.AtomNode,
// dynamic kinds
scene: Nodes.SceneNode,
object: Nodes.ObjectNode,
fuel: Nodes.FuelNode,
rotation: Nodes.RotationNode,
temp: Nodes.TempNode,
flow: Nodes.FlowNode,
vibration: Nodes.VibrationNode,
position: Nodes.PositionNode,
volt: Nodes.VoltNode,
not: Nodes.NotNode,
and: Nodes.AndNode,
or: Nodes.OrNode,
equals: Nodes.EqualsNode,
empty: Nodes.EmptyNode,
constant: Nodes.ConstantNode,
add: Nodes.AddNode,
sub: Nodes.SubNode,
mul: Nodes.MulNode,
div: Nodes.DivNode,
abs: Nodes.AbsNode,
prediction: Nodes.PredictionNode,
database: Nodes.DatabaseNode,
plot: Nodes.PlotNode,
alert: Nodes.AlertNode,
text: Nodes.TextNode,
screen: Nodes.ScreenNode,
package: Nodes.PackageNode,
warp: Nodes.WarpNode,
split: Nodes.SplitNode,
// test shapes
test_left_rounded: Nodes.LeftRoundedNode,
test_right_rounded: Nodes.RightRoundedNode,
test_left_triangle: Nodes.LeftTriangleNode,
test_right_triangle: Nodes.RightTriangleNode,
test_edge_triangles: Nodes.EdgeTrianglesNode,
test_param_border: Nodes.ParametricBorderNode,
});
const renderer = new Renderer(ctx, world, grid, graph);
const app = new AppController(ctx, world, renderer, graph);
app.factory = factory;
// Expose app controller globally for integration
window.appController = app;
// Start with an empty graph
app.draw();
})();
