Konva is easier to use and performs better than older fabrics, thanks to its internal design. This paper analyzes Konva from the following aspects:
- Extensions to the base element and context
- Graphics transformation processing (transformation calculation and independent graphics controller)
- Cursor interaction processing (Pixel-based target detection)
- Hierarchical rendering processing
Series directory
- A brief analysis of the Canvas2D rendering library: Fabric
- Brief analysis of Canvas2D rendering library :(2) Konva
- Canvas2D rendering library brief analysis :(3) Pixi
Konva.js
Konva describes itself as a Canvas library that extends the 2D context to make its functionality interactive on both desktop and mobile, including high performance animations, transforms, node nesting, event handling, layering, and more.
Konva, which originated from Eric’s KineticJS project and was a bit younger than Fabric, was partially rewritten in TypeScript in early 19 and was on its way to modernization. Now it looks like it’s written in TS, but because of the consistency of the API, you can see the history in some strange places.
Konva.js version 4.1.0 is used in this article
Base elements and context extensions
Element use and customization
Here’s an example of how it can be used
See the Pen konva-base-and-custom-elements by yrq110 (@yrq110) on CodePen.
You can use some of the built-in graphics elements, such as rectangles, circles, and so on, as well as custom graphics.
When you customize a graph, you implement its drawing method, sceneFunc, and you can customize its collision detection region by implementing hitFunc, which is not present in the fabric.
Basic elements
Konva has a number of different base elements designed to manage the canvas hierarchy and graphics, which can be used to form a nested layer tree.
Among them:
- Stages contain multiple drawing layers
- You can add Shape or Group elements to Layer
- Shape is the most fine-grained element, that is, a concrete graphic object
- Groups are container elements used to manage multiple Shapes or other groups
- eachLayerContains two internally
<canvas>
Elements, Scene Graph and Hit Graph- The scene layer contains the graphics that are drawn, that is, the graphics that are actually seen
- The interaction layer is used for high performance interaction event detection
- The base classes of the above elements are Node
The structure of a Konva graph tree is as follows:
Stage ├ ─ ─ Layer | ├ ─ ─ Group | └ ─ ─ Shape | └ ─ ─ Group | ├ ─ ─ Shape | └ ─ ─ Group | └ ─ ─ Shape └ ─ ─ Layer └ ─ ─ ShapeCopy the code
Context extension
You can use the CANVAS’s 2D context to manipulate the stack of states containing properties such as styles, transforms, and clipping. Konva encapsulates context objects, including API compatibility and parameter handling, property Settings for a given scene, and so on.
API handling:
// Use it directly
moveTo(a0, a1) {
this._context.moveTo(a0, a1);
}
// Simply check parameters
createImageData(a0, a1) {
var a = arguments;
if (a.length === 2) {
return this._context.createImageData(a0, a1);
} else if (a.length === 1) {
return this._context.createImageData(a0); }}// Compatibility processing
setLineDash(a0) {
// works for Chrome and IE11
if (this._context.setLineDash) {
this._context.setLineDash(a0);
} else if ('mozDash' in this._context) {
// verified that this works in firefox
(this._context['mozDash']) = a0;
} else if ('webkitLineDash' in this._context) {
// does not currently work for Safari
(this._context['webkitLineDash']) = a0;
}
// no support for IE9 and IE10
}
Copy the code
Prepare special Context for SceneCanvas and HitCanvas: SceneContext and HitContext
Both are Context objects bound to Layer SceneCanvas and HitCanvas, which inherit from Context and implement their respective _fill() and _stroke() methods. As HitContext:
export class HitContext extends Context {
_fill(shape) {
this.save();
this.setAttr('fillStyle', shape.colorKey);
shape._fillFuncHit(this);
this.restore();
}
_stroke(shape) {
if (shape.hasHitStroke()) {
this._applyLineCap(shape);
var hitStrokeWidth = shape.hitStrokeWidth();
var strokeWidth =
hitStrokeWidth === 'auto' ? shape.strokeWidth() : hitStrokeWidth;
this.setAttr('lineWidth', strokeWidth);
this.setAttr('strokeStyle', shape.colorKey);
shape._strokeFuncHit(this);
if(! strokeScaleEnabled) {this.restore(); }}}}Copy the code
Canvas extension and Layer use:
export class HitCanvas extends Canvas {
hitCanvas = true;
constructor(config: ICanvasConfig = { width: 0.height: 0{})super(config);
this.context = new HitContext(this);
this.setSize(config.width, config.height); }}export class Layer extends BaseLayer {
hitCanvas = new HitCanvas({
pixelRatio: 1
});
}
Copy the code
Graphic transformation processing
Transform properties, operations and matrix processing
Similar to Fabric, transformation properties are modified by explicitly invoking Node transformation methods or controller, and then the transformation matrix is computed and re-rendered. The Trasnform class is used to manage the relationship between operations and matrices.
The process of converting a transform attribute into a transform matrix in Konva: Attribute => Transform operation => Transform matrix
Transform attribute => Transform operation
_getTransform(): Transform {
var m = new Transform();
if(x ! = =0|| y ! = =0) {
m.translate(x, y);
}
if(rotation ! = =0) {
m.rotate(rotation);
}
if(scaleX ! = =1|| scaleY ! = =1) {
m.scale(scaleX, scaleY);
}
// ...
return m;
}
Copy the code
Transform operation => Transform matrix
export class Transform {
m: Array<number>;
constructor(m = [1, 0, 0, 1, 0, 0]) {
this.m = (m && m.slice()) || [1.0.0.1.0.0];
}
translate(x: number, y: number) {
this.m[4] + =this.m[0] * x + this.m[2] * y;
this.m[5] + =this.m[1] * x + this.m[3] * y;
return this;
}
scale(sx: number, sy: number) {
this.m[0] *= sx;
this.m[1] *= sx;
this.m[2] *= sy;
this.m[3] *= sy;
return this;
}
// ...
}
Copy the code
Graphic controller transformation processing
The controller uses a Transformer implementation separate from the Node element
See the Pen konva-control by yrq110 (@yrq110) on CodePen.
To do this, create a Transformer object and attach it to the Shape you want to control using **attachTo()**.
Compared with controllers in the Fabric, not only the usage method is different, but the internal processing is very different. The processing process is as follows:
The first is to bind the controller to the node
attachTo(node) {
this.setNode(node);
}
setNode(node) {
// Bind the node to clear the cache
this._node = node;
this._resetTransformCache();
// Listen for node properties to change, update controller in callback
const onChange = (a)= > {
this._resetTransformCache();
if (!this._transforming) {
this.update(); }}; node.on(additionalEvents, onChange); node.on(TRANSFORM_CHANGE_STR, onChange); } update() {// ...
// Update properties such as the location of each controller
this.findOne('.top-left').setAttrs({
x: -padding,
y: -padding,
scale: invertedScale,
visible: resizeEnabled && enabledAnchors.indexOf('top-left') > =0
});
// ...
}
Copy the code
The second is the process of event monitoring and transformation
-
Add mouseDown event listeners on each controller during initialization
_createAnchor(name) { var anchor = newRect({... });var self = this; anchor.on('mousedown touchstart'.function(e) { self._handleMouseDown(e); }); } Copy the code
-
Add mousemove event listening when the callback is triggered
_handleMouseDown(e) { window.addEventListener('mousemove'.this._handleMouseMove); window.addEventListener('touchmove'.this._handleMouseMove); } Copy the code
-
Calculate the change of movement and update the controller position to be changed
_handleMouseMove(e) { // ... if (this._movingAnchorName === 'bottom-center') { this.findOne('.bottom-right').y(anchorNode.y()); } else if (this._movingAnchorName === 'bottom-right') { if (keepProportion) { newHypotenuse = Math.sqrt( Math.pow(this.findOne('.bottom-right').x() - padding, 2) + Math.pow(this.findOne('.bottom-right').y() - padding, 2)); var reverseX = this.findOne('.top-left').x() > this.findOne('.bottom-right').x() ? - 1 : 1; var reverseY = this.findOne('.top-left').y() > this.findOne('.bottom-right').y() ? - 1 : 1; x = newHypotenuse * this.cos * reverseX; y = newHypotenuse * this.sin * reverseY; this.findOne('.bottom-right').x(x + padding); this.findOne('.bottom-right').y(y + padding); }}else if (this._movingAnchorName === 'rotater') { // ... } Copy the code
-
By calculating the region formed by the position of the controller after the change, the transformation region that the node needs to adapt to is obtained
_handleMouseMove(e) { // ... x = absPos.x; y = absPos.y; var width = this.findOne('.bottom-right').x() - this.findOne('.top-left').x(); var height = this.findOne('.bottom-right').y() - this.findOne('.top-left').y(); this._fitNodeInto( { x: x + this.offsetX(), y: y + this.offsetY(), width: width, height: height }, e ); } Copy the code
-
Calculate the size and position attributes of the node after the change according to this area
this.getNode().setAttrs({ scaleX: scaleX, scaleY: scaleY, x: newAttrs.x - (dx * Math.cos(rotation) + dy * Math.sin(-rotation)), y: newAttrs.y - (dy * Math.cos(rotation) + dx * Math.sin(rotation)) }); Copy the code
-
Redraw in the next rAF render
// src/shapes/Transformer.ts this.getLayer().batchDraw(); // src/BaseLayer.ts batchDraw() { if (!this._waitingForDraw) { this._waitingForDraw = true; Util.requestAnimFrame((a)= > { this.draw(); this._waitingForDraw = false; }); } return this; } Copy the code
Interactive event processing
Target detection
Konva uses a pixel-based approach to determine cursor collisions with graphics, not geometry.
The main process of target detection is as follows:
-
Stage::_mousedown => Stage::getIntersection
Listen for mouse events on the topmost Stage and look up the target graph from the topmost layer based on cursor position and the selector passed in
for (n = end; n >= 0; n--) { shape = layers[n].getIntersection(pos, selector); if (shape) { returnshape; }}Copy the code
-
Layer::getIntersection
// Use INTERSECTION_OFFSETS to extend the range of the cursor to make it easier to generate intersecting cases for (i = 0; i < INTERSECTION_OFFSETS_LEN; i++) { intersectionOffset = INTERSECTION_OFFSETS[i]; // Calculate the intersection object obj = this._getIntersection({ x: pos.x + intersectionOffset.x * spiralSearchDistance, y: pos.y + intersectionOffset.y * spiralSearchDistance }); shape = obj.shape; // If a graph exists and contains an element selector, look for its ancestor, such as 'Group', otherwise return the graph directly if (shape && selector) { return shape.findAncestor(selector, true); } else if (shape) { returnshape; }}Copy the code
-
Layer::_getInersection The core part of target detection is here
// Get the pixel value of the cursor position in the hitCanvas context var p = this.hitCanvas.context.getImageData(Math.round(pos.x * ratio), Math.round(pos.y * ratio), 1.1).data; // Convert RGA to HEX, compared with shape colorKey var colorKey = Util._rgbToHex(p[0], p[1], p[2]); // Shapes contains all the added graphics objects, each with a random HEX color for its key var shape = shapes[The '#' + colorKey]; // If the color of the current position in the Hit graph is the same as the color representing the graph, then that graph is the object that the cursor hit if (shape) { return { shape: shape }; } Copy the code
-
Stage::targetShape
Once you get the targetShape, various interactive events are triggered
this.targetShape._fireAndBubble(SOME_MOUSE_EVENT, { evt: evt, pointerId }); Copy the code
In order to determine whether a hit is hit by comparing the cursor position on the Hit graph with the pixel value representing the key of the graph, it is necessary to draw the Hit graph of the Shape object on the HitCanvas of the layer in advance. The following work is done in this part:
-
When you create a graph, you generate a unique key for that graph, which is a random color
// Generate a unique key while (true) { key = Util.getRandomColor(); if(key && ! (keyin shapes)) { break; }}// Save the color for later hit graph drawing this.colorKey = key; // This object is saved in shapes objects for query at target detection time shapes[key] = this; Copy the code
-
When a graphic is added to layer, its SceneCanvas and HitCanvas are drawn when layer.draw() is executed
// Layer::draw() => Node::draw() draw() { this.drawScene(); this.drawHit(); return this; } // Layer::drawHit() => Container::drawHit(). Container::drawHit(). this._drawChildren(canvas, 'drawHit', top, false, caching, caching); // Container::_drawChildren() this.children.each(function(child) { // drawHit() is executed on each child element, which is of type Shape or Group child[drawMethod](canvas, top, caching, skipBuffer); }); // Shape::drawHit drawHit(can) { // Get the _hitFunc or _sceneFunc implemented in the built-in or custom Shape object var drawFunc = this.hitFunc() || this.sceneFunc(); context.save(); // The context is a HitContext object layer._applyTransform(this, context, top); drawFunc.call(this, context, this); context.restore(); } Copy the code
There is also a problem in drawing the HitCanvas. It does not show that the color of colorKey is used to draw the HitCanvas. In fact, the fillStyle setting operation has occurred before, in the HitContext class:
export class HitContext extends Context {
_fill(shape) {
this.save();
// Set the fill style for hit Graph here
this.setAttr('fillStyle', shape.colorKey);
shape._fillFuncHit(this); // => this.fill()
this.restore(); }}Copy the code
Element rendering processing
use
Take the example of adding a Layer and a Shape to the Stage to see how hierarchical rendering is handled.
To display a graphic on the interface, use the following steps:
- Create a Stage
let stage = new Konva.Stage()
- Create a Layer
let layer = new Konva.Layer()
- Create a Shape
let box = new Konva.Rect()
- Add Shape to Layer
layer.add(box)
- Add Layer on Stage
stage.add(layer)
You will then see a rectangle displayed on the screen.
If a new shape is added to layer: layer.add(new_box), you can see that the new shape is not displayed and need to execute layer.draw() again. If you change the order based on the above steps, to achieve the same effect, it becomes:
- Create a Stage
let stage = new Konva.Stage()
- Create a Layer
let layer = new Konva.Layer()
- Add Layer on Stage
stage.add(layer)
- Create a Shape
let box = new Konva.Rect()
- Add Shape to Layer
layer.add(box)
- Perform Layer drawing
layer.draw()
The principle of
The Add method of Stage draws the layer content and inserts the layer SceneCanvas element into the DOM tree
add(layer) {
// Handle the current parent-child relationship of the layer in the parent class Container
super.add(layer);
// Set the current size
layer._setCanvasSize(this.width(), this.height());
// Draw the contents of the layer
layer.draw();
// Insert the Canvas element into the DOM tree
if (Konva.isBrowser) {
// Just add SceneCanvas, not HitCanvas
this.content.appendChild(layer.canvas._canvas); }}Copy the code
Layer does not implement its own add method. By default, the add method in Container is implemented
add(... children: ChildType[]) {var child = arguments[0];
// 1. If there are parents, adopt them.
if (child.getParent()) {
child.moveTo(this);
return this;
}
var _children = this.children;
// 2. Verify the availability of child
this._validateAdd(child);
child.index = _children.length;
child.parent = this;
// 3. Save to the children array
_children.push(child);
}
Copy the code
The Layer’s draw() method, as mentioned in the target detection section above, executes the related draw methods for each child in children in turn.
One thing to note: When draw() is executed on the Stage object, it empties and redraws the contents of all layers because Layer, as a child of the Stage, empties the contents according to its clearBeforeDraw property (default true) when executing its drawScene method. Then perform the drawing.
// src/Layer.ts
drawScene(can, top) {
var layer = this.getLayer(),
canvas = can || (layer && layer.getCanvas());
if (this.clearBeforeDraw()) {
canvas.getContext().clear();
}
Container.prototype.drawScene.call(this, canvas, top);
return this;
}
Copy the code
This should make it clear that drawing is not actually performed when adding graphics to the layer, so draw() needs to be executed manually when the graphics that the layer contains change, whereas drawing () of the layer object is automatically performed internally by the stage when the layer is added to the stage, so no explicit call is required.
The last
Konva’s main modules are also years old, but I personally feel that modularity is better than Fabric, both in terms of more flexible hierarchical management and component customization. Second, thanks to the TS rewrite, and thanks to editors and code AIDS, both the source code and use are easier to read.
Due to their own business is written in the original, some parts of the implementation of the framework and these ideas also happen to coincide, but more places or frameworks are designed well, worth learning from a lot of places.
reference
- Github.com/konvajs/kon…
- Github.com/ericdrowell…