When we exchange position and size information with Konva, multiple coordinate systems come into play. We normally use the canvas or stage coordinates but others are available. Knowing which to use will save a lot of frustration later.
In this article we're going to look why transforms could be an issue, what the HTML5 canvas element can give us in terms of mouse positions, then I'll give you a list of the Konva methods and tell you what coordinate system they use.
TLDR: There's a CodePen demo here. All the action takes place in the console where I log the output of some of the more interesting methods.
The demo uses a setup with the stage, a single layer and a single rect on that layer. Using the Konva setAttrs() method as shorthand, the setup looks like this:
stage.setAttrs({x: 10, y: 10, scaleX: 2, scaleY: 2})
layer.setAttrs({x: 15, y: 15, scaleX: 3, scaleY: 3})
rect.setAttrs({x: 5, y: 5, width: 100, height: 80, scaleX: 3, scaleY: 3})So the stage is at position {x: 10, y: 10} with a scale of 2, the layer is at x: 15, y: 15} and scale 3, and the rect is at x: 5, y: 5} with size 100 x 80, and scale 3.
We'll see later how Konva gives us the position and size back depending on which methods we use.
A quick note about transformation and why they matter
In this article we are discussing shape co-ordinates. But one of the underlying aspects that make this trickier than you might expect is transformation. A transformation is a mathematical description of how a shape's position and size can be altered, and they are used extensively under the covers of 2-D vector graphics such as we find in the HTML5 canvas. A transformation can move a shape in the x or y directions (aka translate), it can rotate the shape, scale it and skew it.
You may also have come across transformations when dealing with CSS - it's the same concept.
The important point about transformations is not that we need to formulate them, but to know that they are at work. In our Konva drawings we have the stage, layers, groups and nodes. Each of these can be transformed by position, scale, rotation, etc. The combined transformations operating on a node can mean it is not, in mathematical terms, sitting in the position we think we see on the canvas!
Ok - transformations complicate our understanding of position - that's all we need to know for now. Lets move on...
HTML5 Canvas position
Why is this even available if Konva handles its own co-ordinates? To know this we have to understand that Konva is a wrapper for the HTML5 Canvas element. The canvas element is very good at drawing operations, but does not provide an object model and most of the other goodies that Konva gives us. The canvas is an HTML5 element, and like all elements we can get the position of a mouse click via code something like this:
<script type="text/javascript">
function getMousePos(element, event) {
let rect = element.getBoundingClientRect();
let x = event.clientX - rect.left;
let y = event.clientY - rect.top;
console.log("Position is " + x + ", " + y);
}
let canvasEle = document.querySelector("canvas");
canvasEle.addEventListener("mousedown", function (e) {
getMousePos(canvasEle, e);
});
</script>Tedious code to get the mouse position - Konva does this for us!
What this does is add a click event listener to a canvas element, then execute the getMousePos() function when a click happens on the canvas element. Inside the function we use the element.getBoundingClientRect() function to get the position of the canvas on the page, and we deduct that from the click point to get the position over the canvas element. So, for example, if we clicked at the very top-left of the canvas we would see a position of {x: 0, y: 0} reported.
That is interesting, as far as it goes, but using that to reliably refer to some position on our canvas drawing is more complex. We could, for example, have applied scaling to the drawing, and we could have panned it so that the drawing is now offset in the x or y direction. To convert the point on the canvas element to a meaningful point on our drawing requires some potentially complex but definitely tedious code. Fortunately Konva does all that for us.
So why is the position on the canvas useful? Some of the Konva methods use it - notably those that do anything with the pixel data such as toBlob() and toImage().
Konva Stage / layer / group position
As we saw above, the native canvas does not provide a means to convert a click on its element to a position on the surface. Fortunately Konva does. All of the objects that inherit from Konva's container class have the getRelativePointerPosition() method which gives the co-ordinates relative to that container. Here's what happens inside that method which I'm posting here to show you the relevance of transformations to this endeavour.
Side note - Konva uses the term transform for transformation in its code base.
/**
* Takes node and returns its relative cursor position (For example when clicked)
* @param node Typically it's the stage node
* @returns x, y that are relative to scale and offset of node (stage).
* @example
* const pos = getRelativePointerPosition(e.target.getStage())
*/
export const getRelativePointerPosition = (
node: any
): { x: number; y: number } => {
const transform = node.getAbsoluteTransform().copy()
transform.invert()
return transform.point(node.getStage().getPointerPosition())
}So the function gets the absolute transform of the node - meaning it combines the transformations of all of that nodes parent nodes and their transformations - then inverts it and then applies that inverse transformation to the canvas pointer position.
Enough already - what sense can we make of this?
Ok, ok! Thanks for staying awake this far. All of the preamble above was to get you to accept that there's a lot going on and we can mostly float above it except that ultimately we need to know which coordinate space we will get back from Konva or need to give to Konva.
Yes I mentioned the coordinate spaces in the intro. Mostly we work with either the canvas or the stage coordinate spaces. The canvas version gives us point and size data in relation to the HTML Canvas element surface. This always has it's {x:0, y: 0} point at the top-left of the element.
The Konva coordinates of a shape come from its ancestor containers. A container is a class that is inherited by the stage, layer and group classes. Just like shapes, containers can be transformed, so scaled, translated, rotated, and skewed. In a typical Konva diagram we have one stage, one or two layers, and any number of groups. And those groups can be nested.
Against this backdrop of potential complexity, Konva can give us usable coordinates and sizes regardless of the transformations in play in any shapes container hierarchy.
The best way to understand this is to use the demo as an example - we're now going to go through the commonly used position or size-giving Konva methods and look at what we get based on which object we ask. For example, we can ask for the client rect of the rect from the perspective of the canvas, the stage, the layer, and even the rect itself.
node.absolutePosition(pos)
Get's the position of the node in canvas coordinate space. Value is affected by scaling of parent containers. Can also be used to set the position. Here is the result for our sample rect.
stage.absolutePosition() = {'x':10,'y':10}
layer.absolutePosition() = {'x':40,'y':40}
rect.absolutePosition() = {'x':70,'y':70}
/*
For the rect this is made up from
+ 10 stage.x
+ (15 * 2) = 30 layer.x * stage.scaleX
+ (5 * 3 * 2) = 30 rect.x * layer.scaleX * stage.scaleX
= 70node.dragBoundFunc(dragBoundFunc)
Here the dragBoundFunc receives {x, y} position as canvas coordinate space and must return the same. The value is affected by scaling of parent containers.
node.getAbsolutePosition(Ancestor)
When Ancestor is undefined it returns absolute position in canvas coordinate space.
With Ancestor set to stage, returns node position in stage coordinate space, etc.
Value is not affected by scaling of parent containers from the node used as the argument. So if you pass in stage then stage scaling has no impact.
rect.getAbsolutePosition(udefined) = {'x':70,'y':70}
rect.getAbsolutePosition(stage) = {'x':30,'y':30}
rect.getAbsolutePosition(layer) = {'x':5,'y':5}
rect.getAbsolutePosition(rect) = {'x':0,'y':0}container.getAllIntersections(pos)
Returns a list of all shapes overlapping the given point which is in canvas co-ordinates. Typically we would use this as
const pos = stage.getPointerPosition()
const shapeList = stage.getAllIntersections(pos)Note that this is an expensive operation as Konva uses an off-screen canvas which it clears and runs a hit test for each shape in turn. Use getIntersection() to find the shape under the pointer.
node.getClientRect(config)
When config.relativeTo is undefined then returns the minimal bounding rect of the node in canvas coordinates with absolute scale applied.
When we add {relativeTo: stage | layer | group | node} we are asking for the client rect from the perspective of the relativeTo object. For example, if we use layer we are saying 'hey layer - what is the client rect of this node from your perspective?' This will give us the minimal bounding rect of the node in layer coordinates and size without layer scaling applied (if any is set).
Using our example above, this gives the results for getClientRect() as follows:
getClientRect relativeTo: udef = {'x':70,'y':70,'width':1200,'height':960}
getClientRect relativeTo: stage = {'x':30,'y':30,'width':600,'height':480}
getClientRect relativeTo: layer = {'x':5,'y':5,'width':200,'height':160}
getClientRect relativeTo: target = {'x':0,'y':0,'width':100,'height':80}Here the result for relativeTo: stage gives a width of 600 because the absolute scale from the perspective of the stage is layer (x3) and rect (x2) = 6 * 100 = 600.
container.getIntersection(pos)
Returns the first shape in descending z-order of shapes, that overlaps given point which is in canvas co-ordinates.
const pos = stage.getPointerPosition()
const shape = stage.getIntersection(pos)container.getPointerPosition()
Returns mouse pointer position in canvas co-ordinates.
node.getRelativePointerPosition()
Returns pointer position relative to local coordinates of current node.
For example, if we clicked at the top-left corner of the rect:
stage.getRelativePointerPosition = {'x': 30,'y': 30}
layer.getRelativePointerPosition = {'x': 5,'y': 5}
rect.getRelativePointerPosition = {'x': 0,'y': 0}node.x(), node.y(), node.position(), node.size(), node.width(), node.height()
Get: All of the above return the attribute of the node in pixels. No scaling is applied. To get the size with scaling multiply by node.scaleX() or node.scaleY().
Set: the given values should be in unscaled pixels.
node.move(change)
Requires a change object formed as {x: deltaX, y: deltaY} where deltaX & Y are the amount of pixels to move by.
node.offsetX(x), node.offsetY(y)
Get and set the node offset. Values are required as pixels with no scaling applied.
node.toBlob(config), node.toCanvas(), node.toImage
These functions all have a config parameter including the description of a rect via x, y, width and height values. These values are in canvas co-ordinates.
Transformer.boundBoxFunc()
This function is used to restrict the transformer to a specific size or position. You inspect the oldBoundBox argument with whatever logic you need for your case, and return the newBoundBox value with the box that you prefer.
The boundBondFunc operates in absolute coordinates.
const MAX_WIDTH = 200
const tr = new Konva.Transformer({
boundBoxFunc: function (oldBoundBox, newBoundBox) {
// "boundBox" is an object with
// x, y, width, height and rotation properties
// transformer tool will try to fit nodes into that box
// the logic is simple, if new width is too big
// we will return previous state
if (Math.abs(newBoundBox.width) > MAX_WIDTH) {
return oldBoundBox;
}
return newBoundBox;
},
});
Transformer.anchorDragBoundFunc()
A function used to control the allowed movement of a transformers anchor.
The boundBondFunc operates in absolute coordinates.
const MAX_X = 500
const tr = new Konva.Transformer({
anchorDragBoundFunc: function(oldAbsPos, newAbsPos, event) {
// "oldAbsPos" is an object with x, y properties
// "newAbsPos" is another object with x, y properties
// the logic is simple, if you don't like the newAbsPos then
// return oldAbsPos.
if (newAbsPos.x > MAX_X) {
return oldAbsPos;
}
return newAbsPos
},
});Summary
We've looked at why getting position and size information is complicated by matrix transforms, and that Konva can simplify these concerns and give us the positions and sizes we need if we know how to ask appropriately.
We also have a crib sheet giving hints about which coordinate space is expected and returned from the common Konva methods.
Thanks for reading
VW May 2024.