Connect objects HTML5 canvas with Konva
How to connect two objects with a line or arrow?
Konva
can't connect two objects with a line and update its position automatically. You have to update a line manually as you need it. Usually we need to update line position when a user drag one of the connected objects. In simple cases it can be done like this:
const obj1 = new Konva.Circle({ ...obj1Props })
const obj2= new Konva.Circle({ ...obj2Props });
const line = new Konva.Line({ ...lineProps });
obj1.on('dragmove', updateLine);
obj2.on('dragmove', updateLine);
function updateLine() {
line.points([obj1.x(), obj1.y(), obj2.x(), obj2.y]);
layer.batchDraw();
}
But in this demo we will create a more complex case with the state of the app and many connected objects.
Instructions: try to drag circles to see how the connections update.
- Vanilla
- React
- Vue
import Konva from 'konva'; const width = window.innerWidth; const height = window.innerHeight; const stage = new Konva.Stage({ container: 'container', width: width, height: height, }); const layer = new Konva.Layer(); stage.add(layer); // function to generate a list of "targets" (circles) function generateTargets() { const number = 10; const result = []; while (result.length < number) { result.push({ id: 'target-' + result.length, x: stage.width() * Math.random(), y: stage.height() * Math.random(), }); } return result; } const targets = generateTargets(); // function to generate arrows between targets function generateConnectors() { const number = 10; const result = []; while (result.length < number) { const from = 'target-' + Math.floor(Math.random() * targets.length); const to = 'target-' + Math.floor(Math.random() * targets.length); if (from === to) { continue; } result.push({ id: 'connector-' + result.length, from: from, to: to, }); } return result; } function getConnectorPoints(from, to) { const dx = to.x - from.x; const dy = to.y - from.y; let angle = Math.atan2(-dy, dx); const radius = 50; return [ from.x + -radius * Math.cos(angle + Math.PI), from.y + radius * Math.sin(angle + Math.PI), to.x + -radius * Math.cos(angle), to.y + radius * Math.sin(angle), ]; } const connectors = generateConnectors(); // update all objects on the canvas from the state of the app function updateObjects() { targets.forEach((target) => { const node = layer.findOne('#' + target.id); node.x(target.x); node.y(target.y); }); connectors.forEach((connect) => { const line = layer.findOne('#' + connect.id); const fromNode = layer.findOne('#' + connect.from); const toNode = layer.findOne('#' + connect.to); const points = getConnectorPoints( fromNode.position(), toNode.position() ); line.points(points); }); } // generate nodes for the app connectors.forEach((connect) => { const line = new Konva.Arrow({ stroke: 'black', id: connect.id, fill: 'black', }); layer.add(line); }); targets.forEach((target) => { const node = new Konva.Circle({ id: target.id, fill: Konva.Util.getRandomColor(), radius: 20 + Math.random() * 20, shadowBlur: 10, draggable: true, }); layer.add(node); node.on('dragmove', () => { // mutate the state target.x = node.x(); target.y = node.y(); // update nodes from the new state updateObjects(); }); }); updateObjects();
import { useState, useEffect } from 'react'; import { Stage, Layer, Circle, Arrow } from 'react-konva'; const App = () => { // Generate initial targets const generateTargets = () => { const number = 10; const result = []; while (result.length < number) { result.push({ id: 'target-' + result.length, x: window.innerWidth * Math.random(), y: window.innerHeight * Math.random(), radius: 20 + Math.random() * 20, fill: '#' + Math.floor(Math.random()*16777215).toString(16), }); } return result; }; // Generate connectors between targets const generateConnectors = (targets) => { const number = 10; const result = []; while (result.length < number) { const from = 'target-' + Math.floor(Math.random() * targets.length); const to = 'target-' + Math.floor(Math.random() * targets.length); if (from === to) { continue; } result.push({ id: 'connector-' + result.length, from, to, }); } return result; }; const [targets, setTargets] = useState([]); const [connectors, setConnectors] = useState([]); useEffect(() => { const initialTargets = generateTargets(); setTargets(initialTargets); setConnectors(generateConnectors(initialTargets)); }, []); const getConnectorPoints = (from, to) => { const dx = to.x - from.x; const dy = to.y - from.y; let angle = Math.atan2(-dy, dx); const radius = 50; return [ from.x + -radius * Math.cos(angle + Math.PI), from.y + radius * Math.sin(angle + Math.PI), to.x + -radius * Math.cos(angle), to.y + radius * Math.sin(angle), ]; }; const handleDragMove = (e) => { const id = e.target.id(); setTargets( targets.map((target) => target.id === id ? { ...target, x: e.target.x(), y: e.target.y() } : target ) ); }; return ( <Stage width={window.innerWidth} height={window.innerHeight}> <Layer> {connectors.map((connector) => { const fromNode = targets.find((t) => t.id === connector.from); const toNode = targets.find((t) => t.id === connector.to); if (!fromNode || !toNode) return null; const points = getConnectorPoints(fromNode, toNode); return ( <Arrow key={connector.id} id={connector.id} points={points} fill="black" stroke="black" /> ); })} {targets.map((target) => ( <Circle key={target.id} id={target.id} x={target.x} y={target.y} radius={target.radius} fill={target.fill} shadowBlur={10} draggable onDragMove={handleDragMove} /> ))} </Layer> </Stage> ); }; export default App;
<template> <v-stage :config="stageConfig"> <v-layer> <v-arrow v-for="connector in connectors" :key="connector.id" :config="getArrowConfig(connector)" /> <v-circle v-for="target in targets" :key="target.id" :config="getCircleConfig(target)" @dragmove="handleDragMove" /> </v-layer> </v-stage> </template> <script setup> import { ref, onMounted } from 'vue'; const stageConfig = { width: window.innerWidth, height: window.innerHeight }; // Generate initial targets const generateTargets = () => { const number = 10; const result = []; while (result.length < number) { result.push({ id: 'target-' + result.length, x: window.innerWidth * Math.random(), y: window.innerHeight * Math.random(), radius: 20 + Math.random() * 20, fill: '#' + Math.floor(Math.random()*16777215).toString(16), }); } return result; }; // Generate connectors between targets const generateConnectors = (targets) => { const number = 10; const result = []; while (result.length < number) { const from = 'target-' + Math.floor(Math.random() * targets.length); const to = 'target-' + Math.floor(Math.random() * targets.length); if (from === to) { continue; } result.push({ id: 'connector-' + result.length, from, to, }); } return result; }; const targets = ref([]); const connectors = ref([]); onMounted(() => { const initialTargets = generateTargets(); targets.value = initialTargets; connectors.value = generateConnectors(initialTargets); }); const getConnectorPoints = (from, to) => { const dx = to.x - from.x; const dy = to.y - from.y; let angle = Math.atan2(-dy, dx); const radius = 50; return [ from.x + -radius * Math.cos(angle + Math.PI), from.y + radius * Math.sin(angle + Math.PI), to.x + -radius * Math.cos(angle), to.y + radius * Math.sin(angle), ]; }; const getArrowConfig = (connector) => { const fromNode = targets.value.find((t) => t.id === connector.from); const toNode = targets.value.find((t) => t.id === connector.to); if (!fromNode || !toNode) return { points: [0, 0, 0, 0] }; return { id: connector.id, points: getConnectorPoints(fromNode, toNode), fill: 'black', stroke: 'black', }; }; const getCircleConfig = (target) => ({ id: target.id, x: target.x, y: target.y, radius: target.radius, fill: target.fill, shadowBlur: 10, draggable: true, }); const handleDragMove = (e) => { const id = e.target.id(); targets.value = targets.value.map((target) => target.id === id ? { ...target, x: e.target.x(), y: e.target.y() } : target ); }; </script>