Related
Suppose I have a map of the world.
And I'd like each continent to be an area where I could attach shapes to and drag/reshape them, while always being clipped by the continent's shape borders/limits.
Here's what I have so far:
const stage = new Konva.Stage({
container: 'stage',
width: window.innerWidth,
height: window.innerHeight
});
const layer = new Konva.Layer();
const group = new Konva.Group({
clipFunc: function (ctx) {
ctx.arc(250, 120, 50, 0, Math.PI * 2, false);
ctx.arc(150, 120, 60, 0, Math.PI * 2, false);
},
});
const shape = new Konva.Rect({
x: 150,
y: 70,
width: 100,
height: 50,
fill: "green",
stroke: "black",
strokeWidth: 4,
draggable: true,
});
group.add(shape);
layer.add(group);
stage.add(layer);
body {
margin: 0;
padding: 0;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/konva/8.4.0/konva.min.js"></script>
<div id="stage"></div>
My question is, how could I use the clipFunc to draw a continent's limits? Could I use and image? svg path? I can't seem to find the answer in the docs.
[Edit: Added a new option 2, added demos for options 2 & 3 codepen + snippets.]
TLDR: Nothing totally automatic but two possible options.
Just to confirm - based on
And I'd like each continent to be an area where I could attach shapes
to and drag/reshape them, while always being clipped by the
continent's shape borders/limits.
I think you are asking how to limit the boundaries for dragging a shape to an 'arbitrary' region. I say arbitrary because it is a non-geometric region (not a square, circle, pentagon etc).
It would be fabulous to have a baked-in function to achieve this but sadly I am not aware that it is possible. Here's why:
Dragbounds limits: In terms of what you get 'out of the box', how Konva handles constraining drag position is via the node.dragBoundFunc(). Here is the example from the Konva docs which is straightforward.
// get drag bound function
var dragBoundFunc = node.dragBoundFunc();
// create vertical drag and drop
node.dragBoundFunc(function(pos){
// important pos - is absolute position of the node
// you should return absolute position too
return {
x: this.absolutePosition().x,
y: pos.y
};
});
The gist of this is that we are able to use the code in the dragBoundFunc function to decide if we like the position the shape is being dragged to, or not. If not we can override that 'next' position with our own.
Ok - so that is how dragging is constrained via dragBoundFunc. You can also use the node.on('dragmove') to achieve the same effect - the code would be very similar.
Hit testing
To decide in the dragBoundFunc whether to accept the proposed position of the shape being dragged, we need to carry out 'hit testing'.
[Aside: An important consideration is that, to make a pleasing UI, we should be hit testing at the boundary of the shape that is being dragged - not where the mouse pointer or finger are positioned. Example - think of a circle being dragged with the mouse pointer at its center - we want to show the user the 'hit' UI when the perimeter of the circle goes 'out of bounds' from the perspective of the dragBoundFunc, not when the center hits that point. What this means in effect is that our logic should check the perimeter of the shape for collision with the boundary - that might be simple or more difficult, depending on the shape.]
So we know we want to hit test our dragging shape against an arbitrary, enclosing boundary (the country border).
Option #1: Konva built-in method.
[Update] On developing the demo for this option I discovered that its mainstay, getIntersection(pt), is deliberately disabled (will always return null) when used in a dragmove situation. This is by design and done for performance because the overhead for the process is so costly.
What getIntersection does is to look at a given pixel, from topmost shape down, of the shapes that might overlap the given x, y point. It stops at the first hit. To do this is draws in an off-screen canvas each shape, checks the pixel, and repeats until no shapes remain. As you can tell, quite a costly process to run in-between mousemove steps.
The proposal for this option was to check a bunch of static border points on the stage via getIntersection - if the dragging shape came up as the hit then we would know the border was being crossed.
What point do we give it to check ? So here's the rub - you would have to predefine points on your map that were on the borders. How many points? Enough so that your draggable shapes can't stray very far over the mesh of border points without the hit-test firing. Do it correctly and this would be a very efficient method of hit testing. And it's not as if the borders will be changing regularly.
I made a simple point creator here. This is the view after I created the points around Wombania.
** Option #2: The concept is to create an off-screen canvas the same size as the client rect of the shape being dragged and create a clone of the drag shape therein. Space around the shape would be transparent, shape itself would be colored. We now use a set of pre-defined points along the country boundary, example above. We filter the points inside the shape's clientRect - so we only have a handful to test. We then 'translate' those points to the appropriate location in the off-screen hit canvas and check the color of the pixels at those points - any color whatsoever indicates the dragging shape is 'over' the point we are testing. Any hit means we can break out of the loop and report a boundary collision.
This is optimised in the following ways:
1 - we only make the offscreen canvas once at the dragstart.
2 - we only test the minimum number of boundary points - only those falling in the bounding box of the dragging shape.
Demo here at codepen. Snippet below - best consumed full screen.
const scale = 1,
stage = new Konva.Stage({
container: "container",
width: 500,
height: 400,
draggable: false
}),
layer = new Konva.Layer({
draggable: false
}),
imageShape = new Konva.Image({
x: 0,
y: 0,
draggable: false
}),
// Rect drawn to show client rect of dragging shape
theShapeRect = new Konva.Rect({
stroke: "silver",
strokeWidth: 1,
listening: false
}),
// small dots to show check points
pointCircle = new Konva.Circle({
radius: 30,
fill: "silver",
draggable: false
}),
// the three draggable shape defs - select by button
dragShapes = {
circle: new Konva.Circle({
radius: 30,
fill: "lime",
draggable: true,
visible: false
}),
rectangle: new Konva.Rect({
width: 60,
height: 60,
fill: "lime",
draggable: true,
visible: false
}),
star: new Konva.Star({
numPoints: 6,
innerRadius: 40,
outerRadius: 70,
fill: "lime",
draggable: true,
visible: false
})
},
// data for the check points.
data = `{"pt0":{"x":85.5,"y":44.5},"pt1":{"x":76,"y":62},"pt2":{"x":60,"y":78},"pt3":{"x":47,"y":94},"pt4":{"x":33,"y":115},"pt5":{"x":26,"y":133},"pt6":{"x":17,"y":149},"pt7":{"x":27,"y":171},"pt8":{"x":45,"y":186},"pt9":{"x":69,"y":187},"pt10":{"x":87,"y":191},"pt11":{"x":104,"y":194},"pt12":{"x":123,"y":214},"pt13":{"x":124,"y":238},"pt14":{"x":120,"y":260},"pt15":{"x":94,"y":265},"pt16":{"x":92,"y":275},"pt17":{"x":113,"y":281},"pt18":{"x":130,"y":280},"pt19":{"x":148,"y":280},"pt20":{"x":156,"y":261},"pt21":{"x":169,"y":248},"pt22":{"x":188,"y":251},"pt23":{"x":201,"y":263},"pt24":{"x":207,"y":274},"pt25":{"x":195,"y":281},"pt26":{"x":181,"y":285},"pt27":{"x":183,"y":291},"pt28":{"x":194,"y":293},"pt29":{"x":222,"y":293},"pt30":{"x":242,"y":284},"pt31":{"x":245,"y":257},"pt32":{"x":247,"y":238},"pt33":{"x":263,"y":236},"pt34":{"x":278,"y":240},"pt35":{"x":293,"y":239},"pt36":{"x":305,"y":238},"pt37":{"x":315,"y":237},"pt38":{"x":333,"y":236},"pt39":{"x":337,"y":248},"pt40":{"x":324,"y":258},"pt41":{"x":303,"y":263},"pt42":{"x":314,"y":267},"pt43":{"x":326,"y":273},"pt44":{"x":347,"y":273},"pt45":{"x":364,"y":273},"pt46":{"x":378,"y":260},"pt47":{"x":401,"y":263},"pt48":{"x":422,"y":272},"pt49":{"x":429,"y":278},"pt50":{"x":414,"y":281},"pt51":{"x":400,"y":287},"pt52":{"x":411,"y":294},"pt53":{"x":434,"y":292},"pt54":{"x":462,"y":287},"pt55":{"x":478,"y":275},"pt56":{"x":474,"y":259},"pt57":{"x":466,"y":233},"pt58":{"x":470,"y":208},"pt59":{"x":483,"y":189},"pt60":{"x":484,"y":169},"pt61":{"x":494,"y":153},"pt62":{"x":496,"y":129},"pt63":{"x":489,"y":106},"pt64":{"x":472,"y":91},"pt65":{"x":458,"y":78},"pt66":{"x":443,"y":65},"pt67":{"x":428,"y":54},"pt68":{"x":412,"y":41},"pt69":{"x":394,"y":31},"pt70":{"x":369,"y":23},"pt71":{"x":346,"y":22},"pt72":{"x":323,"y":22},"pt73":{"x":300,"y":23},"pt74":{"x":278,"y":24},"pt75":{"x":265,"y":26},"pt76":{"x":251,"y":30},"pt77":{"x":235,"y":32},"pt78":{"x":220,"y":38},"pt79":{"x":203,"y":44},"pt80":{"x":189,"y":53},"pt81":{"x":174,"y":57},"pt82":{"x":163,"y":51},"pt83":{"x":148,"y":53},"pt84":{"x":128,"y":52},"pt85":{"x":100,"y":51}}`,
// load the data into an object.
pointsList = JSON.parse(data);
// shape is set when the shape-type button is clicked.
let theShape = undefined;
// Add shapes to the layer and layer to stage
layer.add(
imageShape,
dragShapes.circle, // not visible at this point
dragShapes.rectangle, // not visible at this point
dragShapes.star, // not visible at this point
theShapeRect
);
stage.add(layer);
// Make the hit stage where we will do color sampling
const hitStage = new Konva.Stage({
container: "container2",
width: 300,
height: 300,
draggable: true
}),
hitLayer = new Konva.Layer(),
ctx = hitLayer.getCanvas().getContext(); // Get the convas context for access to pixel data
hitStage.add(hitLayer);
// Make an HTML image variable to act as the image loader, load the image
const img = new Image();
img.crossOrigin = "Anonymous";
img.onload = function () {
imageShape.image(img); // when loaded give the image to the Konva image shape
};
img.src = "https://assets.codepen.io/255591/map_of_wombania2.svg"; // start image loading - fires onload above.
// draw a small grey dot centered on each test point
for (const [key, pt] of Object.entries(pointsList)) {
layer.add(
pointCircle.clone({
name: key + " point",
radius: 5,
x: pt.x,
y: pt.y
})
);
}
// Function to get the color data for given point on a given canvas context
function getRGBAInfo(ctx, point) {
// get the image data for one pixel at the computed point
const pixel = ctx.getImageData(point.x, point.y, 1, 1);
const data = pixel.data;
// for fun, we show the rgba value at the pixel
const rgba =
"pt " +
JSON.stringify(point) +
` rgba(${data[0]}, ${data[1]}, ${data[2]}, ${data[3] / 255})`;
// console.log(rgba);
return data;
}
// function to reset collided point colors
function clearPoints() {
// clear the collision point colors
const points = stage.find(".point");
for (const point of points) {
point.fill("silver");
}
}
// variable to track whether we collided or not.
let hit = false;
// user clicks a shape-select button
$(".shapeButton").on("click", function () {
setShape($(this).data("shape"));
});
// Set the active shape.
function setShape(shapeName) {
clearPoints();
if (theShape) {
theShape.visible(false);
}
theShape = dragShapes[shapeName];
// Somewhere in Wombania....
theShape.position({
x: 300,
y: 120
});
// finally we see the shape !
theShape.visible(true);
// and set the bounding rect visualising rect
theShapeRect.position(theShape.getClientRect());
theShapeRect.size(theShape.getClientRect());
// better clear any listeners on the shape just in case
theShape.off();
// fires once as the drag commences
theShape.on("dragstart", function (evt) {
// clear the hitLayer for color testing
hitLayer.destroyChildren();
// make a copy of the dragging shape, positioned at top-left of hit canvas
// Note I fill shape with solid color - if you drag a Konva.Group then make a filled rect
// the pos & size of the group.getClientRect and add that into the group after cloning.
const clone = evt.target.clone({ fill: "red", stroke: "red" });
clone.position({
x: clone.width() / 2,
y: clone.height() / 2
});
hitLayer.add(clone);
// cloning copies some events so better clear them as they are not needed on the clone.
clone.off();
// reset the boundary point color
clearPoints();
// position the client rect visulaiser
theShapeRect.position(theShape.getClientRect());
theShapeRect.size(theShape.getClientRect());
});
// Will run on each drag move event
theShape.on("dragmove", function (evt) {
// assume no collisions - we will know by the end of the event
hit = false;
// position the client rect visulaiser
theShapeRect.position(theShape.getClientRect());
// Get the translation vector from the drag shape in the main canvas to the location
// in the hit canvas. We use thit to translate the check points in the main canvas
// to their positions in the hit canvas
const translateDist = {
x: -this.position().x + this.width() / 2,
y: -this.position().y + this.width() / 2
};
// get a rect around the current pos of the draggging shape, use to check if points
// are within this rect. If YES then process them, otherwise ignore.
const checkRect = this.getClientRect();
// Walk the set of check points...
for (const [key, pt] of Object.entries(pointsList)) {
// Is this point in the client rect of the dragging shape ?...
if (
checkRect.x < pt.x &&
checkRect.y < pt.y &&
checkRect.x + checkRect.width > pt.x &&
checkRect.y + checkRect.height > pt.y
) {
//...yes - so we pocess it
// translate the point to its position in the hit canvas.
let pointTranslated = {
x: pt.x + translateDist.x,
y: pt.y + translateDist.y
};
// get the color info of the point
const colorInfo = getRGBAInfo(ctx, pointTranslated);
// Is there any color there, anything, at all, maybe ?
if (colorInfo[0] + colorInfo[1] + colorInfo[2] + colorInfo[3] > 0) {
// if we find color then we have a collision!
hit = true;
// set the color of the collided point to visualise it
stage.findOne("." + key).fill("black");
// !Important: In live code we could 'break' here because it is not
// important to know _all_ the hits. I will process them all for demo purposes.
// break;
}
}
}
// Phew - after all that point fettling, if we got a hit then say so !
if (hit) {
$("#alarm").html("Boundary collision");
evt.target.fill("red");
} else {
evt.target.fill("lime");
$("#alarm").html("Still good");
}
});
}
body {
margin: 10px;
background-color: #f0f0f0;
}
.container {
border: 1px solid black;
display: inline-block;
}
alarm {
color: red;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://unpkg.com/konva#8/konva.min.js"></script>
<p><span id='info'>Pick a shape, drag it around the country without hitting the edges!</span></p>
<p><span id='alarm'>.</span></p>
<p>
<button class="shapeButton" data-shape='circle'>Circle</button>
<button class="shapeButton" data-shape='rectangle'>Rectangle</button>
<button class="shapeButton" data-shape='star'>Star</button>
</span></p>
<div id="container" class='container'></div>
<div id="container2" class='container'></div>
Option #3: Alpha value checking.
The gist of this method is to have the color fill of each country have a specific alpha value in its RGBA setting. You can then check the colors at specific points on the perimeter of your dragging shape. Lets say we set the alpha for France to 250,
the Channel is 249, Spain 248, Italy 247, etc. If you are dragging your shape around 'inside' France, you expect an alpha value of 250. If you see anything else under any of those perimeter points then some part of your shape has crossed the border. [In practice, the HTML canvas will add some antialiasing along the border line so you will see some values outside those that you set but these have a low impact affect and can be ignored.]
One point is that you can't test the color on the main canvas if the shape being dragged is visible - because you will be getting the fill, stroke, or antialised pixel color of the shape!
To solve this you need a second stage - this can be memory only, so not visible on the page - where you load either a copy of the main stage with the dragging shape invisible, or you load the image of the map only. Let's call this the hit-stage. Assuming you keep the position of the hit-stage in line with the main-stage, then everything will work. Based on the location of the dragging shape and its perimeter points, you check the pixel colors on the hit-canvas. If the values match the country you are expecting then no hit, but if you see a different alpha value then you hit or passed the border. Actually you don't even need to know the color for the starting country - just note the color under the mouse point when the drag commences and look out for a different alpha value under the perimeter points.
There's a working demo of the 2-stage approach at codePen here. The demo just uses a country boundary and 'elsewhere' but you would use the same technique to construct an atlas of countries with different alpha values for your needs.
This is the JavaScript from the codepen demo. Best seen in full screen though when I checked it after copying from codepen some of the detections on right hand side did not fire, so maybe view the codepen if you can.
const countries = [
{ name: "wombania", alpha: 252 },
// add more countries as required
{ name: "Elsewhere", alpha: 0 }
],
scale = 1,
stage = new Konva.Stage({
container: "container",
width: 500,
height: 400,
draggable: false,
scale: {
x: scale,
y: scale
}
}),
layer = new Konva.Layer({
draggable: false
}),
imageShape = new Konva.Image({
x: 0,
y: 0,
draggable: false
}),
circle = new Konva.Circle({
radius: 30,
fill: "lime",
draggable: true,
x: 300,
y: 120,
scale: {
x: scale,
y: scale
}
});
let currentCountry = undefined;
const hitStage = new Konva.Stage({
container: "container2",
width: 500,
height: 400,
draggable: false
}),
hitLayer = new Konva.Layer(),
hitImage = new Konva.Image(),
ctx = hitLayer.getCanvas().getContext(); // Get the convas context for access to pixel data
layer.add(imageShape, circle);
stage.add(layer);
hitLayer.add(hitImage);
hitStage.add(hitLayer);
// Make an HTML image variable to act as the image loader, load the image
const img = new Image();
img.crossOrigin = "Anonymous";
img.onload = function () {
imageShape.image(img); // when loaded give the image to the Konva image shape
hitImage.image(img); // and to the hit canvas
const hitImageObj = new Image();
};
img.src = "https://assets.codepen.io/255591/map_of_wombania2.svg"; // start image loading - fires onload above.
// Will run on each drag move event
circle.on("dragmove", function () {
// get 20 points on the perimeter to check.
let hitCountry = currentCountry;
for (let angle = 0; angle < 360; angle = angle + 18) {
const angleRadians = (angle * Math.PI) / 180;
let point = {
x: parseInt(
circle.position().x + Math.cos(angleRadians) * circle.radius(),
10
),
y: parseInt(
circle.position().y + Math.sin(angleRadians) * circle.radius(),
10
)
};
// get the image data for one pixel at the computed point
const pixel = ctx.getImageData(point.x, point.y, 1, 1);
const data = pixel.data;
// for fun, we show the rgba value at the pixel
const rgba = `rgba(${data[0]}, ${data[1]}, ${data[2]}, ${data[3] / 255})`;
// console.log("color at (" + point.x + ", " + point.y + "):", rgba);
// Here comes the good part.
// We know the alpha value for the current country - any other value means
// we crossed the border!
let country = getCountryAtPoint(point);
if (country && country.name !== currentCountry.name) {
hitCountry = country;
break; // jump out of the loop now because we know we got a hit.
}
}
// After checking the points what did the hit indicator show ?
if (hitCountry.alpha !== currentCountry.alpha) {
circle.fill("magenta");
$("#alarm").html("You crossed the border into " + hitCountry.name);
} else {
circle.fill("lime");
$("#alarm").html("Still inside " + hitCountry.name);
}
});
function getRGBAInfo(ctx, point) {
// get the image data for one pixel at the computed point
const pixel = ctx.getImageData(point.x, point.y, 1, 1);
const data = pixel.data;
// for fun, we show the rgba value at the pixel
const rgba = `rgba(${data[0]}, ${data[1]}, ${data[2]}, ${data[3] / 255})`;
return data;
}
imageShape.on("mousemove", function () {
const point = stage.getPointerPosition();
getRGBAInfo(ctx, point);
});
function getCountryAtPoint(point) {
const colorInfo = getRGBAInfo(ctx, point);
for (const country of countries) {
if (country.alpha === colorInfo[3]) {
$("#info2").html("Selected: " + country.name);
return country;
}
}
}
imageShape.on("mousedown", function () {
currentCountry = getCountryAtPoint(stage.getPointerPosition());
});
circle.on("mousedown", function () {
currentCountry = getCountryAtPoint(stage.getPointerPosition());
});
body {
margin: 10px;
background-color: #f0f0f0;
}
.container {
border: 1px solid black;
display: inline-block;
}
alarm {
color: red;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://unpkg.com/konva#8/konva.min.js"></script>
<p><span id='info'>Drag the circle around the country without hitting the edges!</span></p>
<p><span id='info2'>Selected: none</span> <span id='alarm'></span></p>
<div id="container" class='container'></div>
<div id="container2" class='container'></div>
PS. As a bonus, knowing the alpha values of the countries gives you an instant way to know which country the user clicks on. See the mousedown event.
To use an image, you can use the drawImage method of the canvas context in the clipFunc
const image = new Image();
image.src = 'image.png';
const group = new Konva.Group({
clipFunc: function (ctx) {
ctx.drawImage(image, 0, 0);
},
});
To use an SVG path, you can use the clip method of the canvas context in the clipFunc
const group = new Konva.Group({
clipFunc: function (ctx) {
ctx.clip('M10,10 h80 v80 h-80 Z');
},
});
This is probably just maths.
I am using Konva to dynamically generate shapes, which I'm storing as a label. So there's a label which contains a textElement and a rectangle. I want to make sure text in that rectangle is always a) Centered horizontally and vertically and b) facing the right way up.
So a rectangle could have any rotation, but I always want the text centered and facing the right way up.
The code for creation; width, height, rotation, x and y all have values pulled from a database.
var table = new Konva.Label({
x: pos_x,
y: pos_y,
width: tableWidth,
height: tableHeight,
draggable:true
});
table.add(new Konva.Rect({
width: tableWidth,
height: tableHeight,
rotation: rotation,
fill: fillColor,
stroke: strokeColor,
strokeWidth: 4
}));
table.add(new Konva.Text({
width: tableWidth,
height: tableHeight,
x: pos_x, //Defaults to zero
y: pos_y, //Default to zero
text: tableNumber,
verticalAlign: 'middle',
align: 'center',
fontSize: 30,
fontFamily: 'Calibri',
fill: 'black'
}))
tableLayer.add(table);
The problem is, if rotation is in place, text is off center, as in this image:
I do manually correct in some circumstances - for example if rotation = 45 degrees:
pos_x = -tableWidth/2;
pos_y = tableHeight/5;
but that is not a permanent solution. I want the x and y co-ordinates of the text to be at the centerpoint of the shape itself.
I've tried a few approaches (such as applying rotation to the Label itself and then negative rotation value to the text)
This code snippet illustrates a solution. It is copied & modified from my other self-answer when I was looking for a robust approach to rotation around an arbitrary point - note that I consider this a slightly different question than my original so I have not suggested this is a dup. The difference is the need to work with a more complex grouped shape and to keep some element within that group unrotated.
Not in the OP's question, but I set a background rectangle into the text by making the text a group. The purpose of this was to show that the text rectangle will extend outside the label rectangle in some points of rotation. This is not a critical issue but it is useful to see it happen.
The fundamental challenge for the coder is to understand how the shapes move when rotated since we usually want to spin them around their centre but the fundamental 2D canvas pattern that Konva (and all HTML5 canvas wrappers) follow is to rotate from the top-left corner, al least for rectangles as per shapes in the question. It 'is' possible to move the rotation point (known as the offset) but again that is a conceptual challenge for the dev and a nice trap for anyone trying to support the code later.
There's a lot of code in this answer that is here to set up something dynamic that you can use to visualise what is going on. However, the crux is in this:
// This is the important call ! Cross is the rotation point as illustrated by crosshairs.
rotateAroundPoint(shape, rotateBy, {x: cross.x(), y: cross.y()});
// The label is a special case because we need to keep the text unrotated.
if (shape.name() === 'label'){
let text = shape.find('.text')[0];
rotateAroundPoint(text, -1 * rotateBy, {x: text.getClientRect().width/2, y: text.getClientRect().height/2});
}
The rotateAroundPoint() function takes as parameters the Konva shape to rotate, the clockwise rotation angle (not radians, good ole degrees), and the x & y position of the rotation point on the canvas / parent.
I constructed a group of shapes as my label, composing it from a rectangle and a text shape. I named this 'label'. Actually I switched the text shape to be another group of rect + text to that I could show the rectangle the text sits within. You could leave out the extra group. I named this 'text'.
The first call to rotateAroundPoint() rotates the group named 'label'. So the group rotates on the canvas. Since the 'text' is a child of the 'label' group, that would leave the 'text' rotated, so the next line checks if we are working with the 'label' group, and if so we need to get hold of the 'text' shape which is what this line does:
let text = shape.find('.text')[0];
In Konva the result of a find() is a list so we take the first in the list. Now all that remains for me to do is rotate the text on the 'label' group back again by applying the negative rotation degrees to its center point. The line below achieves this.
rotateAroundPoint(text, -1 * rotateBy, {x: text.getClientRect().width/2, y: text.getClientRect().height/2});
One note worthy of mention - I used a group for my 'text' shape. A Konva group does not naturally have a width or height - it is more of a means to collect shapes together but without a 'physical' container. So to get its width and height for the centre point calculations I use the group.getClientRect() method which gives the size of the minimum bounding box that would contain all shapes in the group, and yields an object formed as {width: , height: }.
Second note - the first use of rotateAroundPoint() affects the 'label' group which has as its parent the canvas. The second use of that function affects the 'text' group which has the 'label' group as its parent. Its subtle but worth knowing.
Here is the snippet. I urge you to run it fullscreen and spin a few shapes around a few different points.
// Code to illustrate rotation of a shape around any given point. The important functions here is rotateAroundPoint() which does the rotation and movement math !
let
angle = 0, // display value of angle
startPos = {x: 80, y: 45},
shapes = [], // array of shape ghosts / tails
rotateBy = 20, // per-step angle of rotation
shapeName = $('#shapeName').val(), // what shape are we drawing
shape = null,
ghostLimit = 10,
// Set up a stage
stage = new Konva.Stage({
container: 'container',
width: window.innerWidth,
height: window.innerHeight
}),
// add a layer to draw on
layer = new Konva.Layer(),
// create the rotation target point cross-hair marker
lineV = new Konva.Line({points: [0, -20, 0, 20], stroke: 'lime', strokeWidth: 1}),
lineH = new Konva.Line({points: [-20, 0, 20, 0], stroke: 'lime', strokeWidth: 1}),
circle = new Konva.Circle({x: 0, y: 0, radius: 10, fill: 'transparent', stroke: 'lime', strokeWidth: 1}),
cross = new Konva.Group({draggable: true, x: startPos.x, y: startPos.y}),
labelRect, labelText;
// Add the elements to the cross-hair group
cross.add(lineV, lineH, circle);
layer.add(cross);
// Add the layer to the stage
stage.add(layer);
$('#shapeName').on('change', function(){
shapeName = $('#shapeName').val();
shape.destroy();
shape = null;
reset();
})
// Draw whatever shape the user selected
function drawShape(){
// Add a shape to rotate
if (shape !== null){
shape.destroy();
}
switch (shapeName){
case "rectangle":
shape = new Konva.Rect({x: startPos.x, y: startPos.y, width: 120, height: 80, fill: 'magenta', stroke: 'black', strokeWidth: 4});
break;
case "hexagon":
shape = new Konva.RegularPolygon({x: startPos.x, y: startPos.y, sides: 6, radius: 40, fill: 'magenta', stroke: 'black', strokeWidth: 4});
break;
case "ellipse":
shape = new Konva.Ellipse({x: startPos.x, y: startPos.y, radiusX: 40, radiusY: 20, fill: 'magenta', stroke: 'black', strokeWidth: 4});
break;
case "circle":
shape = new Konva.Ellipse({x: startPos.x, y: startPos.y, radiusX: 40, radiusY: 40, fill: 'magenta', stroke: 'black', strokeWidth: 4});
break;
case "star":
shape = new Konva.Star({x: startPos.x, y: startPos.y, numPoints: 5, innerRadius: 20, outerRadius: 40, fill: 'magenta', stroke: 'black', strokeWidth: 4});
break;
case "label":
shape = new Konva.Group({name: 'label'});
labelRect = new Konva.Rect({x: 0, y: 0, width: 120, height: 80, fill: 'magenta', stroke: 'black', strokeWidth: 4, name: 'rect'})
shape.add(labelRect);
labelText = new Konva.Group({name: 'text'});
labelText.add(new Konva.Rect({x: 0, y: 0, width: 100, height: 40, fill: 'cyan', stroke: 'black', strokeWidth: 2}))
labelText.add(new Konva.Text({x: 0, y: 0, width: 100, height: 40, text: 'Wombat',fontSize: 20, fontFamily: 'Calibri', align: 'center', padding: 10}))
shape.add(labelText)
labelText.position({x: (labelRect.width() - labelText.getClientRect().width) /2, y: (labelRect.height() - labelText.getClientRect().height) /2})
break;
};
layer.add(shape);
cross.moveToTop();
}
// Reset the shape position etc.
function reset(){
drawShape(); // draw the current shape
// Set to starting position, etc.
shape.position(startPos)
cross.position(startPos);
angle = 0;
$('#angle').html(angle);
$('#position').html('(' + shape.x() + ', ' + shape.y() + ')');
clearTails(); // clear the tail shapes
stage.draw(); // refresh / draw the stage.
}
// Click the stage to move the rotation point
stage.on('click', function (e) {
cross.position(stage.getPointerPosition());
stage.draw();
});
// Rotate a shape around any point.
// shape is a Konva shape
// angleRadians is the angle to rotate by, in radians
// point is an object {x: posX, y: posY}
function rotateAroundPoint(shape, angleDegrees, point) {
let angleRadians = angleDegrees * Math.PI / 180; // sin + cos require radians
const x =
point.x +
(shape.x() - point.x) * Math.cos(angleRadians) -
(shape.y() - point.y) * Math.sin(angleRadians);
const y =
point.y +
(shape.x() - point.x) * Math.sin(angleRadians) +
(shape.y() - point.y) * Math.cos(angleRadians);
shape.rotation(shape.rotation() + angleDegrees); // rotate the shape in place
shape.x(x); // move the rotated shape in relation to the rotation point.
shape.y(y);
shape.moveToTop(); //
}
$('#rotate').on('click', function(){
let newShape = shape.clone();
shapes.push(newShape);
layer.add(newShape);
// This ghost / tails stuff is just for fun.
if (shapes.length >= ghostLimit){
shapes[0].destroy();
shapes = shapes.slice(1);
}
for (var i = shapes.length - 1; i >= 0; i--){
shapes[i].opacity((i + 1) * (1/(shapes.length + 2)))
};
// This is the important call ! Cross is the rotation point as illustrated by crosshairs.
rotateAroundPoint(shape, rotateBy, {x: cross.x(), y: cross.y()});
// The label is a special case because we need to keep the text unrotated.
if (shape.name() === 'label'){
let text = shape.find('.text')[0];
rotateAroundPoint(text, -1 * rotateBy, {x: text.getClientRect().width/2, y: text.getClientRect().height/2});
}
cross.moveToTop();
stage.draw();
angle = angle + 10;
$('#angle').html(angle);
$('#position').html('(' + Math.round(shape.x() * 10) / 10 + ', ' + Math.round(shape.y() * 10) / 10 + ')');
})
// Function to clear the ghost / tail shapes
function clearTails(){
for (var i = shapes.length - 1; i >= 0; i--){
shapes[i].destroy();
};
shapes = [];
}
// User cicks the reset button.
$('#reset').on('click', function(){
reset();
})
// Force first draw!
reset();
body {
margin: 10;
padding: 10;
overflow: hidden;
background-color: #f0f0f0;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://unpkg.com/konva#^3/konva.min.js"></script>
<p>1. Click the rotate button to see what happens when rotating around shape origin.</p>
<p>2. Reset then click stage to move rotation point and click rotate button again - rinse & repeat</p>
<p>
<button id = 'rotate'>Rotate</button>
<button id = 'reset'>Reset</button>
<select id='shapeName'>
<option value='label' selected='selected'>Label</option>
<option value='rectangle'>Rectangle</option>
<option value='hexagon'>Polygon</option>
<option value='ellipse' >Ellipse</option>
<option value='circle' >Circle</option>
<option value='star'>Star</option>
</select>
Angle : <span id='angle'>0</span>
Position : <span id='position'></span>
</p>
<div id="container"></div>
I have a line with one end at a fixed point that can be rotated or stretched by the user.
jsfiddle here
It seems that in fabricJS the only way to select a line/object for rotation is the little selection box in the middle. Also, a line is narrow so it is hard to select. Typically one must drag a rectangular selection box across the line to select it, then grab the unlabeled rotation box.
I want to simplify this to: click anywhere on the line and drag to rotate.
ideas?
thx.
code snippet:
var canvas = new fabric.Canvas("c", {stateful: true});
var line1 = new fabric.Line([ 100, 200, 330, 200 ], {
fill: 'red',
stroke: 'red',
strokeWidth: 3,
selectable: true,
evented: false,
minScaleLimit: 0.25,
lockRotation: false,
centeredRotation: false,
centeredScaling: false,
originX: "left", // origin of rotation/transformation.
originY: "bottom", // origin of rotation/transformation.
lockMovementX: true,
lockMovementY: true,
lockScalingFlip: true,
lockScalingX: false,
lockScalingY: false,
lockSkewingX: false,
lockSkewingY: false,
lockUniScaling: true
});
Here's one way to do what you need.
The idea is that on each scale event, we're going to rotate the line using fabric's internal fabric.canvas._rotateObject(), supplying it with current pointer's position. Then, immediately adjust the length of line to match the scale and reset the line's scale to 1.
This would be it, but while your example is relatively easy to do (the line is horizontal), it gets much trickier if you want to initialize a diagonal line. Imagine a line with [0, 0, 100, 100] as coordinates. This would render a rectangular 100x100 bounding box. You'd be able to rotate the line but the huge bounding box is obviously not something you want.
Because of that, we need to initialize the line as if it was rotated back to a horizontal position, then set an angle that it's supposed to have. To do that, we extend the built-in fabric.Line class and modify the constructor to make the calculations. And, since we already have new class, we're going to add the scale handler and default options into it as well. The constructor signature stays the same - new fabric.RotatingLine([x1, y1, x2, y2], options), where x1, y1 - fixed point, x2, y2 - draggable tip.
Lastly, we're changing some of the properties. E.g. evented: false was the reason you couldn't select the line on click.
Below is the snippet with more comments, just in case.
const canvas = new fabric.Canvas("c", {stateful: true})
fabric.RotatingLine = fabric.util.createClass(fabric.Line, {
minLength: 50, // we need to set this thing in px now
initialize: function (points, options) {
const a = new fabric.Point(points[0], points[1])
const b = new fabric.Point(points[2], points[3])
// find this line's vector
const vectorB = b.subtract(a)
// find angle between line's vector and x axis
let angleRad = Math.atan2(vectorB.y, vectorB.x)
if (angleRad < 0) {
angleRad = 2 * Math.PI + angleRad
}
const angleDeg = fabric.util.radiansToDegrees(angleRad)
// find initial horizontal position by rotating the tip back
const c = fabric.util.rotatePoint(b.clone(), a, -angleRad)
options = options || {}
// finally, initialize using transform points to make a horizontal line
this.callSuper('initialize', [a.x, a.y, c.x, c.y], {
noScaleCache: false, // false to force cache update while scaling (doesn't redraw parts of line otherwise)
selectable: true,
evented: true, // true because you want to select line on click
//minScaleLimit: 0.25, // has no effect now because we're resetting scale on each scale event
lockRotation: false,
hasRotatingPoint: false, // to disable rotation control
centeredRotation: false,
centeredScaling: false,
originX: "left", // origin of rotation/transformation.
originY: "bottom", // origin of rotation/transformation.
lockMovementX: true,
lockMovementY: true,
lockScalingFlip: true,
lockScalingX: false,
lockScalingY: false,
lockSkewingX: false,
lockSkewingY: false,
lockUniScaling: true,
...options,
angle: angleDeg // note that we use the calculated angle no matter what
})
this.setControlsVisibility({
tr: false,
tl: false,
bl: false,
mt: false, // middle top disable
mb: false, // midle bottom
ml: false, // middle left
mr: false, // I think you get it
})
this.on('scaling', function (e) {
// rotate to the pointer's x,y
this.canvas._rotateObject(e.pointer.x, e.pointer.y)
// while _rotateObject() tries to keep left/top at initial value,
// it sometimes fails because of rounding errors (?)
// so we need to do it manually again
this.set({left: this.x1, top: this.y1})
// calculate new length before resetting scale
const xOffset = (this.x2 - this.x1) * this.scaleX
const newLength = Math.max(this.minLength, xOffset)
// reset scaleX/scaleY and set new x coord for the tip point
this.set({
scaleX: 1,
scaleY: 1,
x2: this.x1 + newLength
})
})
}
})
const line1 = new fabric.RotatingLine([ 200, 200, 330, 200 ], {
fill: 'red',
stroke: 'red',
strokeWidth: 3,
});
const line2 = new fabric.RotatingLine([ 200, 200, 100, 100 ], {
fill: 'blue',
stroke: 'blue',
strokeWidth: 3,
});
canvas.add(line1, line2)
// Disables group selection.
canvas.on('selection:created', (e) => {
if(e.target.type === 'activeSelection') {
canvas.discardActiveObject();
} else {
//do nothing
}
})
// Keeps objects inside canvas. undos move/rotate/scale out of canvas.
canvas.on('object:modified', function (options) {
let obj = options.target;
let boundingRect = obj.getBoundingRect(true);
if (boundingRect.left < 0
|| boundingRect.top < 0
|| boundingRect.left + boundingRect.width > canvas.getWidth()
|| boundingRect.top + boundingRect.height > canvas.getHeight()) {
obj.top = obj._stateProperties.top;
obj.left = obj._stateProperties.left;
obj.angle = obj._stateProperties.angle;
obj.scaleX = obj._stateProperties.scaleX;
obj.scaleY = obj._stateProperties.scaleY;
obj.setCoords();
obj.saveState();
}
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/2.4.3/fabric.min.js"></script>
<canvas id='c' width="500" height="400"></canvas>
I am using the line graph in Chart.js v1 stable, and I want to dynamically change the point location along with the ling thats attached to it, so like if I slide the point up using javascript, then the point attached to it moves too. Does anyone know how to do it?
Thanks
$(document).ready(function() {
var data = {
labels: ["Dec", "Jan", "Feb", "March", "April", "May", "June"],
datasets: [
{
label: "My Second dataset",
fillColor: "rgba(151,187,205,0.2)",
strokeColor: "#ed1b2e",
pointColor: "red",
pointStrokeColor: "#fff",
pointHighlightFill: "#fff",
pointHighlightStroke: "rgba(151,187,205,1)",
data: [-28, -48, -40, -19, -86, -27, -90]
}
]
};
// Get the context of the canvas element we want to select
var ctx = document.getElementById("myChart").getContext("2d");
var myLineChart = new Chart(ctx).Line(data, {
bezierCurve: false
});
setTimeout(function() {
var len = myLineChart.datasets[0].points.length;
myLineChart.datasets[0].points[len-1].fillColor = "blue";
myLineChart.datasets[0].points[len-1].y = 250; // <---- doesn't work, it moves but then slides back to initial location
myLineChart.update();
}, 5000);
var g = myLineChart.datasets[0];
Chart.defaults.global = {
// Boolean - Whether to animate the chart
animation: true,
// Number - Number of animation steps
animationSteps: 60,
// String - Animation easing effect
// Possible effects are:
// [easeInOutQuart, linear, easeOutBounce, easeInBack, easeInOutQuad,
// easeOutQuart, easeOutQuad, easeInOutBounce, easeOutSine, easeInOutCubic,
// easeInExpo, easeInOutBack, easeInCirc, easeInOutElastic, easeOutBack,
// easeInQuad, easeInOutExpo, easeInQuart, easeOutQuint, easeInOutCirc,
// easeInSine, easeOutExpo, easeOutCirc, easeOutCubic, easeInQuint,
// easeInElastic, easeInOutSine, easeInOutQuint, easeInBounce,
// easeOutElastic, easeInCubic]
animationEasing: "easeOutQuart",
// Boolean - If we should show the scale at all
showScale: true,
// Boolean - If we want to override with a hard coded scale
scaleOverride: false,
// ** Required if scaleOverride is true **
// Number - The number of steps in a hard coded scale
scaleSteps: null,
// Number - The value jump in the hard coded scale
scaleStepWidth: null,
// Number - The scale starting value
scaleStartValue: null,
// String - Colour of the scale line
scaleLineColor: "rgba(0,0,0,.1)",
// Number - Pixel width of the scale line
scaleLineWidth: 1,
// Boolean - Whether to show labels on the scale
scaleShowLabels: true,
// Interpolated JS string - can access value
scaleLabel: "<%=value%>",
// Boolean - Whether the scale should stick to integers, not floats even if drawing space is there
scaleIntegersOnly: true,
// Boolean - Whether the scale should start at zero, or an order of magnitude down from the lowest value
scaleBeginAtZero: false,
// String - Scale label font declaration for the scale label
scaleFontFamily: "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
// Number - Scale label font size in pixels
scaleFontSize: 12,
// String - Scale label font weight style
scaleFontStyle: "normal",
// String - Scale label font colour
scaleFontColor: "#666",
// Boolean - whether or not the chart should be responsive and resize when the browser does.
responsive: true,
// Boolean - whether to maintain the starting aspect ratio or not when responsive, if set to false, will take up entire container
maintainAspectRatio: true,
// Boolean - Determines whether to draw tooltips on the canvas or not
showTooltips: true,
// Function - Determines whether to execute the customTooltips function instead of drawing the built in tooltips (See [Advanced - External Tooltips](#advanced-usage-custom-tooltips))
customTooltips: false,
// Array - Array of string names to attach tooltip events
tooltipEvents: ["mousemove", "touchstart", "touchmove"],
// String - Tooltip background colour
tooltipFillColor: "rgba(0,0,0,0.8)",
// String - Tooltip label font declaration for the scale label
tooltipFontFamily: "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
// Number - Tooltip label font size in pixels
tooltipFontSize: 14,
// String - Tooltip font weight style
tooltipFontStyle: "normal",
// String - Tooltip label font colour
tooltipFontColor: "#fff",
// String - Tooltip title font declaration for the scale label
tooltipTitleFontFamily: "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
// Number - Tooltip title font size in pixels
tooltipTitleFontSize: 14,
// String - Tooltip title font weight style
tooltipTitleFontStyle: "bold",
// String - Tooltip title font colour
tooltipTitleFontColor: "#fff",
// Number - pixel width of padding around tooltip text
tooltipYPadding: 6,
// Number - pixel width of padding around tooltip text
tooltipXPadding: 6,
// Number - Size of the caret on the tooltip
tooltipCaretSize: 8,
// Number - Pixel radius of the tooltip border
tooltipCornerRadius: 6,
// Number - Pixel offset from point x to tooltip edge
tooltipXOffset: 10,
// String - Template string for single tooltips
tooltipTemplate: "<%if (label){%><%=label%>: <%}%><%= value %>",
// String - Template string for multiple tooltips
multiTooltipTemplate: "<%= value %>",
// Function - Will fire on animation progression.
onAnimationProgress: function(){},
// Function - Will fire on animation completion.
onAnimationComplete: function(){}
}
});
Use .value instead of .y i.e.
myLineChart.datasets[0].points[len - 1].value = 250
I've been trying to put scrollbars into my KineticJS app following the tutorial Kinetic have on the API. I have the scrollbars themselves appearing as they should, but I'm not sure what to do with the event listeners to actually get the stage or each of the layers to move so that the scrollbars actually move the view along.
var hscrollArea = new Kinetic.Rect({
x: 10,
y: $(window).height() - 30 - 80, // 80 to account for the fixed footer
width: $(window).width() - 40,
height: 20,
fill: "gray",
opacity: 0.3
});
var hscroll = new Kinetic.Rect({
x: 10,
y: $(window).height() - 30 - 80,// 80 to account for the fixed footer
width: 130,
height: 20,
fill: "orange",
draggable: true,
dragBoundFunc: function(pos) {
// TODO: Move stage or layers at this point
console.log("dragBoundFunc: " + this);
return {
x: pos.x,
y: this.getAbsolutePostion().y
};
},
opacity: 0.9,
stroke: "black",
strokeWidth: 1
});
var vscrollArea = new Kinetic.Rect({
x: $(window).width() - 30,
y: 10,
width: 20,
height: $(window).height() - 40 - 80,
fill: "gray",
opacity: 0.3
});
var vscroll = new Kinetic.Rect({
x: $(window).width() - 30,
y: 10,
width: 20,
height: 70,
fill: "orange",
draggable: true,
dragBoundFunc: function(pos) {
// TODO: Move stage or layers at this point
console.log("dragBoundFunc: " + this);
return {
x: this.getAbsolutePosition().x,
y: pos.y
};
},
opacity: 0.9,
stroke: "black",
strokeWidth: 1
});
Any help would be greatly appreciated :)
Thanks,
Becky
You can move stage or layer when your scrollbar(rectangle) is dragged. i.e,
stage.move(50,50);
stage.draw();
stage.move(-50,-50);
stage.draw();
I would recommend placing the objects you wish to scroll into their own group and position the group accordingly, rather than trying to position layers or the stage. The size of the group would be the (axis-aligned) bounding box of all the objects within the group. You can use the size of the group and compare it against the size of the stage to get a ratio for width and height. These ratios would be used to help calculate sizes for horizontal and vertical scroll bars (the bars being what get dragged to create a scroll effect). The ratio would also be used to determine when to show and hide the scroll bar areas. The difference in sizes would help in determining how to position the group within the stage.