I want to give my users an easy way to visually trace a route on a map or a picture. The solution must let the users add control points that they can use to put bends into the route.
It should work with html5 canvas - I currently use the Konvajs library so a solution that uses this would be good.
In the interests of sharing & learning, if you can suggest solutions using other HTML5 canvas libraries that would be good to see too.
Note: This is not the original question posed. However it emerged over time that this was the actual requirement. The OP asked for means to find an arbitrary point part way along a line / curve in an HTML5 canvas so that a draggable control point could be added at that point to edit the line / curve. The accepted answer does not meet this need. However, an answer to this original question would involve serious collision-detection math and potentially use of bezier control points - in other words it would be a big ask whilst the accepted answer is a very approachable solution with consistent UX.
The original question can be seen in via the edit links below this question.
How about this idea. You click where you want the next point and the route line extends with new positioning handles along the line segments. If you need arrows you can extend the objects herein as you require. You can easily change colours, stroke width, circle opacity etc with attributes of the route class. The points are available in an array and in the standard Konva.js line points list. The JS is vanilla, no other libraries needed or used.
The Export button shows how to grab the (x,y) fixed point objects for export purposes.
Example video here, working code in below snippet.
// Set up the canvas / stage
var s1 = new Konva.Stage({container: 'container1', width: 600, height: 300});
// Add a layer for line
var lineLayer = new Konva.Layer({draggable: false});
s1.add(lineLayer);
// Add a layer for drag points
var pointLayer = new Konva.Layer({draggable: false});
s1.add(pointLayer);
// Add a rectangle to layer to catch events. Make it semi-transparent
var r = new Konva.Rect({x:0, y: 0, width: 600, height: 300, fill: 'black', opacity: 0.1})
pointLayer.add(r)
// Everything is ready so draw the canvas objects set up so far.
s1.draw()
// generic canvas end
// Class for the draggable point
// Params: route = the parent object, opts = position info, doPush = should we just make it or make it AND store it
var DragPoint = function(route, opts, doPush){
var route = route;
this.x = opts.x;
this.y = opts.y;
this.fixed = opts.fixed;
this.id = randId(); // random id.
if (doPush){ // in some cases we want to create the pt then insert it in the run of the array and not always at the end
route.pts.push(this);
}
// random id generator
function randId() {
return Math.random().toString(36).replace(/[^a-z]+/g, '').substr(2, 10);
}
// mark the pt as fixed - important state, shown by filled point
this.makeFixed = function(){
this.fixed = true;
s1.find('#' + this.id)
.fill(route.fillColor);
}
this.kill = function(){
s1.find('#' + this.id)
.remove();
}
this.draw = function(){
// Add point & pt
var circleId = this.id;
var pt = new Konva.Circle({
id: circleId,
x: this.x,
y: this.y,
radius: route.pointRadius,
opacity: route.pointOpacity,
strokeWidth: 2,
stroke: route.strokeColor,
fill: 'transparent',
draggable: 'true'
})
pt.on('dragstart', function(){
route.drawState = 'dragging';
})
pt.on('dragmove', function(){
var pos = this.getPosition();
route.updatePt(this.id(), pos)
route.calc(this.id());
route.draw();
})
pt.on('dragend', function(){
route.drawState = 'drawing';
var pos = this.getPosition();
route.updatePt(this.getId(), pos);
route.splitPts(this.getId());
route.draw();
})
if (this.fixed){
this.makeFixed();
}
route.ptLayer.add(pt);
route.draw();
}
}
var Route = function() {
this.lineLayer = null;
this.ptLayer = null;
this.drawState = '';
this.fillColor = 'Gold';
this.strokeColor = 'Gold';
this.pointOpacity = 0.5;
this.pointRadius = 10;
this.color = 'LimeGreen';
this.width = 5;
this.pts = []; // array of dragging points.
this.startPt = null;
this.endPt = null;
// reset the points
this.reset = function(){
for (var i = 0; i < this.pts.length; i = i + 1){
this.pts[i].kill();
}
this.pts.length = 0;
this.draw();
}
// Add a point to the route.
this.addPt = function(pos, isFixed){
if (this.drawState === 'dragging'){ // do not add a new point because we were just dragging another
return null;
}
this.startPt = this.startPt || pos;
this.endPt = pos;
// create this new pt
var pt = new DragPoint(this, {x: this.endPt.x, y: this.endPt.y, fixed: isFixed}, true, "A");
pt.draw();
pt.makeFixed(); // always fixed for manual points
// if first point ignore the splitter process
if (this.pts.length > 0){
this.splitPts(pt.id, true);
}
this.startPt = this.endPt; // remember the last point
this.calc(); // calculate the line points from the array
this.draw(); // draw the line
}
// Position the points.
this.calc = function (draggingId){
draggingId = (typeof draggingId === 'undefined' ? '---' : draggingId); // when dragging an unfilled point we have to override its automatic positioning.
for (var i = 1; i < this.pts.length - 1; i = i + 1){
var d2 = this.pts[i];
if (!d2.fixed && d2.id !== draggingId){ // points that have been split are fixed, points that have not been split are repositioned mid way along their line segment.
var d1 = this.pts[i - 1];
var d3 = this.pts[i + 1];
var pos = this.getHalfwayPt(d1, d3);
d2.x = pos.x;
d2.y = pos.y;
}
s1.find('#' + d2.id).position({x: d2.x, y: d2.y}); // tell the shape where to go
}
}
// draw the line
this.draw = function (){
if (this.drawingLine){
this.drawingLine.remove();
}
this.drawingLine = this.newLine(); // initial line point
for (var i = 0; i < this.pts.length; i = i + 1){
this.drawingLine.points(this.drawingLine.points().concat([this.pts[i].x, this.pts[i].y]))
}
this.ptLayer.draw();
this.lineLayer.draw();
}
// When dragging we need to update the position of the point
this.updatePt = function(id, pos){
for (var i = 0; i < this.pts.length; i = i + 1){
if (this.pts[i].id === id){
this.pts[i].x = pos.x;
this.pts[i].y = pos.y;
break;
}
}
}
// Function to add and return a line object. We will extend this line to give the appearance of drawing.
this.newLine = function(){
var line = new Konva.Line({
stroke: this.color,
strokeWidth: this.width,
lineCap: 'round',
lineJoin: 'round',
tension : .1
});
this.lineLayer.add(line)
return line;
}
// make pts either side of the split
this.splitPts = function(id, force){
var idx = -1;
// find the pt in the array
for (var i = 0; i < this.pts.length; i = i + 1){
if (this.pts[i].id === id){
idx = i;
if (this.pts[i].fixed && !force){
return null; // we only split once.
}
//break;
}
}
// If idx is -1 we did not find the pt id !
if ( idx === -1){
return null
}
else if (idx === 0 ) {
return null
}
else { // pt not = 0 or max
// We are now going to insert a new pt either side of the one we just dragged
var d1 = this.pts[idx - 1]; // previous pt to the dragged pt
var d2 = this.pts[idx ]; // the pt pt
var d3 = this.pts[idx + 1]; // the next pt after the dragged pt
d2.makeFixed()// flag this pt as no longer splittable
// get point midway from prev pt and dragged pt
var pos = this.getHalfwayPt(d1, d2);
var pt = new DragPoint(this, {x: pos.x, y: pos.y, foxed: false}, false, "C");
pt.draw();
this.pts.splice(idx, 0, pt);
if (d3){
// get point midway from dragged pt to next
pos = this.getHalfwayPt(d2, d3);
var pt = new DragPoint(this, {x: pos.x, y: pos.y, foxed: false}, false, "D");
pt.draw();
this.pts.splice(idx + 2, 0, pt); // note idx + 2 !
}
}
}
// convert last point array entry to handy x,y object.
this.getPoint = function(pts){
return {x: pts[pts.length - 2], y: pts[pts.length - 1]};
}
this.getHalfwayPt = function(d1, d2){
var pos = {
x: d1.x + (d2.x - d1.x)/2,
y: d1.y + (d2.y - d1.y)/2
}
return pos;
}
this.exportPoints = function(){
var list = [], pt;
console.log('pts=' + this.pts.length)
for (var i = 0; i < this.pts.length; i = i + 1){
pt = this.pts[i]
if (pt.fixed){
console.log('push ' + i)
list.push({x: pt.x, y: pt.y});
}
}
return list;
}
}
var route = new Route();
route.lineLayer = lineLayer;
route.ptLayer = pointLayer;
route.fillColor = 'AliceBlue';
route.strokeColor = 'Red';
route.pointOpacity = 0.5;
route.pointRadius = 7;
route.color = '#2982E8'
// Listen for mouse up on the stage to know when to draw points
s1.on('mouseup touchend', function () {
route.addPt(s1.getPointerPosition(), true);
});
// jquery is used here simply as a quick means to make the buttons work.
// Controls for points export
$('#export').on('click', function(){
if ($(this).html() === "Hide"){
$(this).html('Export');
$('#points').hide();
}
else {
$(this).html('Hide');
$('#points')
.css('display', 'block')
.val(JSON.stringify(route.exportPoints()));
}
})
// reset button
$('#reset').on('click', function(){
route.reset();
})
p
{
padding: 4px;
}
#container1
{
background-image: url('https://i.stack.imgur.com/gADDJ.png');
}
#ctrl
{
position: absolute;
z-index: 10;
margin: 0px;
border: 1px solid red;
}
#points
{
width: 500px;
height: 100px;
display: none;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="https://cdn.rawgit.com/konvajs/konva/1.6.5/konva.min.js"></script>
<p>Click to add a point, click to add another, drag a point to make a bend, etc.
</p>
<div id='ctrl'>
<button id='reset'>Reset</button>
<button id='export'>Export</button>
<textarea id='points'></textarea>
</div>
<div id='container1' style="display: inline-block; width: 300px, height: 200px; background-color: silver; overflow: hidden; position: relative;"></div>
<div id='img'></div>
Related
Our project is an online DIY album. Users fill in photos in the frame. Konva is required to achieve a mask function similar to Photoshop, similar to the effect of photos pressed under the frame. There may be many picture frames in my canvas, and only one canvas can not achieve this function. Therefore, I need to use a new canvas to draw this, and then draw the contents of this mask canvas onto Konva's canvas.
Based on this idea, I realized the basic needs. Our requirement is that users can drag, rotate or scale photos. Therefore, I drew a transparent element on Konva's main canvas (Canvas 1) with the same size and position as the photo in the mask canvas (Canvas 2), and then bound it with transformer.With this transformer, I can change the position, size and rotation angle of the transparent elements, and then transfer the matrix of the transparent elements to the photos in the mask canvas through getTransform and setTransform, so that the photos in the mask can rotate, scale or translate with the transparent elements.
When the width and height of the mask canvas (Canvas 2) are the same as those of the elements finally drawn on the main canvas (Canvas 1), everything is OK. However, in order to ensure a clearer final effect, we set the width and height of the mask canvas (Canvas 2) to a higher value. Then there is a problem. When I drag or rotate the transparent elements, the position of the photos in the mask does not completely follow the transparent elements, but there is a deviation.
I think this is because the size of the transparent element is different from the size of the photo in the mask canvas (Canvas 2), so it is not possible to directly set the matrix obtained by getTransform to the photo. In this process, a transformation is required. It should be OK to set the transformed matrix to the photo in the mask. The problem is that I don't know how to carry out such a transformation.
Here's the code:
//canvas 1
let container = document.getElementById("container");
let stage = new Konva.Stage({
container: container,
width: 800,
height: 800
});
layer = new Konva.Layer();
stage.add(layer);
let group = new Konva.Group({
x: 100,
y: 100,
width: 200,
height: 200,
clipX: 0,
clipY: 0,
clipWidth: 200,
clipHeight: 200
});
let shape = await drawMaskPhoto();
group.add(shape);
let transparentRect = new Konva.Rect({
width: 200,
height: 200,
x:0,
y:0,
type:"transparentRect"
});
group.add(transparentRect);
transparentRect.on('transform',async function (e) {
let transform = e.target.getTransform().m;
shape = await drawMaskPhoto(transform);
});
function newImg(src) {
return new Promise((resolve, reject) => {
let img = new Image();
img.src = src;
img.onload = () => {
resolve(img)
}
})
}
async function drawMaskPhoto(transform){
//canvas 2
let maskCanvas = document.createElement("canvas");
let mask = await newImg(mask.imgPath);
let photo = await newImg(photo.imgPath);
//To ensure clarity, I set the new canvas size to be 5 times the size of the elements finally drawn on the main canvas (1000*1000)
maskCanvas.width = 1000;
maskCanvas.height = 1000;
let ctx = maskCanvas.getContext("2d");
ctx.drawImage(mask, 0, 0, 1000, 1000);
ctx.globalCompositeOperation = 'source-atop';
if(transform){
ctx.setTransform(...transform);
}
ctx.drawImage(photo, 0, 0, 1000, 1000);
let shape = new Konva.Image({
image:maskCanvas,
width:200,
height:200
})
return shape
}
The effect picture is like this
The effect picture drawn
Effect picture after rotation
let traceEle = $('#trace');
/*
* SyncHandler is a DIY object for synchronising canvases. In this case we have one leader and one follower.
* An object is used to scope the code. Create new instaces via
* let myStage = new Konva.Stage(..all usual Konva stage stuff...)
* let myHandler = new syncHandler(myStage, 'h4'); // where h4 = a useful trace prefix so we can see which handler is fired.
*/
function syncHandler(stage, handlerName){
let followers = [], // In this demo we have only one listening canvas but we will assume there could be many.
shapes = {}; // Each shape created is placed in this object thus forming a quick lookup list based on id.
// We need all shapes to have a unique ID so we use this func to make them.
// These are not entirely compliant GUID's but meet our needs.
function getGuid(){
let fC=function () {
return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1).toUpperCase();
}
return (fC() + fC() + "-" + fC() + "-" + fC() + "-" + fC() + "-" + fC() + fC() + fC());
}
// Fires during init stage - set the stage id if not set already.
stage.id(getGuid());
// Add a follower to the list of followers.
this.addFollower = function(newFollower){
followers.push(newFollower);
}
// Func to make a proxy for the shape's attrs arry.
function makeProxy(target){
let prxy = new Proxy(target, {
// this code fires when the shape has a change to its attrs array. It allows us
// to intervene with the prop change, send the change to the listerners
// target is the shape's attrs array which will shortly receive the new value.
set(array, prop, value, target) {
changeAttr(prop, value, target); // Invoke the function to communicate the change to any follower[]
// finally let the target - the shape's attr array get the change.
return Reflect.set(array, prop, value, target);
},
deleteProperty(array, prop, target) {
// Included in case needed in future.
let msg = {type: 'attrdel', id: target["id"], name: prop};
sendMessage(msg);
// finally let the target - the shape's attr array get the change.
return Reflect.deleteProperty(array, prop);
}
})
return prxy;
}
// This func is a wrapper for the 'new Konver.<ShapeName>()' process. It is required to:
// - ensure that the new shape will have an id;
// - hide the wiring up of the shape.attrs prox;
// - add the shape into our shapes list, keyed on the assigned id;
// - send the message about the new object to the follower[] canvases.
this.makeShape = function(typeName, attrs){
let shape = null;
attrs = (typeof attrs == 'undefined' ? {} : attrs); // if attrs is not supplied then make an empty array
attrs.id = (typeof attrs.id === 'undefined' ? getGuid() : attrs.id); // ensure there is an ID.
shape = new Konva[typeName](); // Make the actual Konva shape in this canvas.
shape.setAttrs(attrs); // Set the attrs for the new shape.
shape.attrs = makeProxy(shape.attrs);
if (typeof shapes[attrs.id] === 'undefined'){
shapes[attrs.id] = shape;
}
// Send the message about the new shape to any follower[]
let msg = {type: 'makeShape', name: typeName, attrs: attrs };
sendMessage(msg);
return shape; // Hand back the shape to the code that created it.
}
// This func is a wrapper for Konva.container.add() or move(). It takes
// as args the shape being moved and the container that is to be the new parent.
this.changeParent = function(shapeObj, newParentObj){
newParentObj.add(shapeObj); // add shape to new parent.
// Send the message about the new shape to any follower[]
let msg = {type: 'changeParent', id: shapeObj.id(), parentId: newParentObj.id(), parentType: newParentObj.getType()};
sendMessage(msg);
}
/* this func is a wrapper for the Konva.shape.setAttr() method.
* Network comms are costly - we do not want to send messages about propos that either have not changed or
* where the change to a numeric property is insignificant.
*/
function changeAttr(prop, value, target){
let currentVal = target[prop],
sendIt = true; // flag to indicate change is to be sent - overridden below for numeric types if needed.
if (currentVal !== value){
if ( typeof(value) === "number" ){
if (Math.abs(currentVal - value) < 0.01){ // adjust or remove this tollerence as needed.
sendIt = false;
}
}
if (sendIt){
// make the message
let msg = {type: 'changeAttr', id: target["id"], name: prop, value: value };
sendMessage(msg);
}
}
return true;
}
// Func to convert the given message to a JSON string and send it to any followers.
function sendMessage(msg){
if (followers.length === 0){ // no send if no listening followers.
return;
}
let jsonMsg = JSON.stringify(msg);
for (let i = 0; i < followers.length; i++){
followers[i].processMessage(jsonMsg);
}
}
/* In this func we process a received change message. In this demo this is simply one object calling a func in another
* but in the final version this will be talking via peer-to-peer between browsers. We receive a message in JSON format
* containing the change information. The 'type' value gives either 'makeShape', 'changeParent', or 'changeAttr'.
*
* Note that when this runs it is within the context of a 'following' syncHandler instance, not the sending instance!
*
*/
this.processMessage = function(changeInfo){
let change = JSON.parse(changeInfo), // parse the JSON into a JS object. Note you will want a try-catch here !
shape = null;
switch (change.type){
case "makeShape": // a new shape message.
shape = this.makeShape(change.name, change.attrs); // make the shape in the follower syncHandler - this works
// in this demo because the follwower has no followers of its own.
// If it _did_ have followers a deadlock would occur !
trace(handlerName + ".makeShape: " + change.name + ' ' + shape.id()); // record a trace of what is going on
shapes[shape.id()] = shape; // note the shape in our shape list.
break;
case "changeParent": // an existing shape is changing parent container - like from layer A to layer B.
trace(handlerName + '.changeParent: id=' + change["id"])
shape = shapes[change.id]; // get the Konva shape instance that is moving parent
// Special case for adding to stage
if (change.parentType === "Stage"){
stage.add(shape)
}
else {
let parentContainer = shapes[change.parentId]; // get the Kona shape that is to be the new container.
parentContainer.add(shape); // execute the Konva command to switch parents.
}
break;
case "changeAttr": // an attribute of a shape has changed - mirror the change in this follower.
trace(handlerName + '.changeAttr: id=' + change["id"] + ' - ' + change.name + ' = ' + change.value);
shape = shapes[change.id];
shape.setAttr(change.name, change.value);
break;
}
}
} // end of the syncHandlerobject declaration.
// a simple trace output function so we can see some of what is happening - better than console.log!
function trace(msg){
traceEle.prepend('<pre>' + msg + ' </pre>');
}
/* from here onwards is Konva canvas admin */
// Making the stage is standard Konva API code.
let stage1 = new Konva.Stage({container: "container1", width: $('#container1').width(), height: $('#container1').height()});
// And now we create the handler object.
let handler1 = new syncHandler(stage1, 'sh1');
// Making the stage is standard Konva API code.
let stage2 = new Konva.Stage({container: "container2", width: $('#container2').width(), height: $('#container2').height()});
let handler2 = new syncHandler(stage2, 'sh2');
// Very importantly - we inform handler1 than handler2 is listening and wants to know about changes.
handler1.addFollower(handler2);
// The stage object was made via standard Konva API but for all other containers and shapes we use the handler
// function which adds id and wires up listener on attrs list.
let layer1 = handler1.makeShape("Layer");
// Add the layer to the stage.
// Adding a shape is done via syncHandler so that we can capture and broadcast the change
handler1.changeParent(layer1, stage1);
// Make a rect.
let rect1 = handler1.makeShape("Rect", {x: 20, y: 10, width: 100, height: 80, fill: 'cyan', draggable: true});
// Add rect1 to layer1 via syncHandler
handler1.changeParent(rect1, layer1);
// Make a circle
let circle1 = handler1.makeShape("Circle", {x: 140, y: 100, radius: 40, fill: 'magenta'});
// Add circle1 to layer1 via syncHandler
handler1.changeParent(circle1, layer1);
// Make a pentagon
let radialGradPentagon1 = handler1.makeShape("RegularPolygon", {
x: 500,
y: stage1.height() / 2,
sides: 5,
radius: 70,
fillRadialGradientStartPoint: { x: 0, y: 0 },
fillRadialGradientStartRadius: 0,
fillRadialGradientEndPoint: { x: 0, y: 0 },
fillRadialGradientEndRadius: 70,
fillRadialGradientColorStops: [0, 'red', 0.5, 'yellow', 1, 'blue'],
stroke: 'black',
strokeWidth: 4,
draggable: true,
});
// Add radialGradPentagon1 to layer1 via syncHandler
handler1.changeParent(radialGradPentagon1, layer1);
//
// Now we carry out a handful of attribute changes on the shapes to confirm it works !
//
// Move the rect to x = 101
rect1.x(101)
// Fill rect with red and rotate 45 degrees.
rect1
.fill('red')
.rotation(45);
rect1.on('mousedown', function(e){
e.cancelBubble = true;
})
// make circle draggable
circle1.draggable(true);
// Change the pentagon gradient
radialGradPentagon1
.fillRadialGradientEndRadius(60)
.fillRadialGradientColorStops([0, 'red', 0.5, 'yellow', 1, 'blue']);
// We will also now make a transformer on Stage 1 to experiment with dynamic attr changes.
var tr = handler1.makeShape("Transformer", {
anchorStroke: 'red',
anchorFill: 'yellow',
anchorSize: 20,
borderStroke: 'green',
borderDash: [3, 3],
nodes: [],
});
handler1.changeParent(tr, layer1);
// attach the transformer to the rect
tr.nodes([rect1])
layer1.on('mousedown', function(){
traceEle.html('');
})
// add a new rect to be used as a mouse-selection rectangle via click & drag on stage1.
var selectionRectangle = handler1.makeShape("Rect", {
name: 'selectionRect',
fill: 'rgba(0,0,255,0.5)',
visible: false,
});
// Following copied from https://konvajs.org/docs/select_and_transform/Basic_demo.html
// Add selectionRectangle to layer1 via syncHandler
handler1.changeParent(selectionRectangle, layer1);
let x1, y1, x2, y2;
stage1.on('mousedown touchstart', (e) => {
// do nothing if we mousedown on any shape
if (e.target !== stage1) {
return;
}
x1 = stage1.getPointerPosition().x;
y1 = stage1.getPointerPosition().y;
x2 = stage1.getPointerPosition().x;
y2 = stage1.getPointerPosition().y;
selectionRectangle.visible(true);
selectionRectangle.width(0);
selectionRectangle.height(0);
});
stage1.on('mousemove touchmove', () => {
// do nothing if we didn't start selection
if (!selectionRectangle.visible() ) {
return;
}
x2 = stage1.getPointerPosition().x;
y2 = stage1.getPointerPosition().y;
selectionRectangle.setAttrs({
x: Math.min(x1, x2),
y: Math.min(y1, y2),
width: Math.abs(x2 - x1),
height: Math.abs(y2 - y1),
});
});
stage1.on('mouseup touchend', () => {
// no nothing if we didn't start selection
if (!selectionRectangle.visible()) {
return;
}
// update visibility in timeout, so we can check it in click event
setTimeout(() => {
selectionRectangle.visible(false);
});
var shapes = stage1.find();
var shapes = layer1.getChildren(function(node){
return node.name() !== 'selectionRect' && node.getClassName() != "Transformer";
});
var box = selectionRectangle.getClientRect();
var selected = shapes.filter((shape) =>
Konva.Util.haveIntersection(box, shape.getClientRect())
);
tr.nodes(selected);
});
.container {
width: 800px;
height: 300px;
background-color: silver;
margin: 5px;
}
#trace {
max-height: 200px;
overflow-y: scroll;
font-family: 'Courier'
}
pre {
margin: 0;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.0.0/jquery.min.js"></script>
<script src="https://unpkg.com/konva#8/konva.min.js"></script>
<div id='container1' class='container'></div>
<div id='container2' class='container'></div>
<p id='trace'></p>
I have been asked, using Konvajs, to work out an animation that will rotate a circle as if spinning on its central x-axis. So imagine a coin spinning on a table. The intention is to reveal some text on the circle. At the start the circle is fully visible as if from behind so no text visible, then it flips to reveal the text.
I have this code that does a rotation like a spinning wheel.
Can anyone give me a tween / animation approach that would achieve the spinning coin effect?
// the tween has to be created after the node has been added to the layer
var tween = new Konva.Tween({
node: group,
duration: 4,
rotation: 360,
easing: Konva.Easings.BackEaseOut
}
});
tween.play();
After some research it looks like a 3D spin requires heavier lifting which may not be available or work well on mobile.
A good second-best appears to be using scaleX and animating from 0 > 1.
group.scaleX(0);
var tween = new Konva.Tween({
node: group,
duration: .25,
scaleX: 1,
easing: Konva.Easings.EaseOut
});
Here is an example of the second-best version using scaleX() effect. Because of the need to calculate scaleX() and control visibility of the text so as to make it appear that the disc is solid, I moved away from a tween and over to an animation().
// Set up the canvas / stage
var s1 = new Konva.Stage({container: 'container1', width: 300, height: 200});
// Add a layer for line
var layer = new Konva.Layer({draggable: false});
s1.add(layer);
// just a plain JS object to keep common variables in hand.
var cfg = { w: 300, h: 200, r: 80, txtSize: 520};
var group = new Konva.Group();
var circle = new Konva.Circle({x: cfg.w/2, y: cfg.h/2, radius: cfg.r, fill: 'DodgerBlue', stroke: 'DeepPink', strokeWidth: 5})
group.add(circle)
var textValue = new Konva.Text({
id: "t1",
x: cfg.w/2,
y: cfg.h/2,
text: '',
fill: 'DeepPink ',
fontSize: cfg.txtSize
});
group.add(textValue);
textValue.offset({x: textValue.getWidth()/2, y: textValue.getHeight()/2});
layer.add(group)
// to spin a group about a point, set the offset to that point, then set the x & y to that point to !
var pos = group.getClientRect();
RotatePoint(group, {x: pos.x + pos.width/2, y: pos.y + pos.height/2});
// Everything is ready so draw the canvas objects set up so far.
s1.draw()
$('#st').on('click', function(){
group.scaleX(1);
var txt = $('#theText').val();
setValue(txt);
})
// set the offset for rotation to the given location and re-position the shape
function RotatePoint(shape, pos){ // where pos = {x: xpos, y: yPos}
var initialPos = shape.getAbsolutePosition();
var moveBy = {x: pos.x - initialPos.x, y: pos.y - initialPos.y};
// offset is relative to initial x,y of shape, so deduct x,y.
shape.offsetX(moveBy.x);
shape.offsetY(moveBy.y);
shape.x(initialPos.x + moveBy.x);
shape.y(initialPos.y + moveBy.y);
}
var setValue = function(newText){
// work out scaling to make text fit into the circle
var txt = this.layer.find('#t1')[0];
txt.text(newText).scale({x:1, y: 1})
var txtSize = txt.getClientRect();
var maxW = (cfg.r); // max allowed width of text
var txtScaleW = (txtSize.width > maxW ? ( maxW / txtSize.width) : 1);
var maxH = cfg.r; // max allowed height of text
var txtScaleH = (txtSize.height > maxH ? ( maxH / txtSize.height) : 1);
// finally decide which is the worst case and use that scaling
var txtScale = ( txtScaleW > txtScaleH ? txtScaleH : txtScaleW);
txt.scale({x: txtScale, y: txtScale});
txt.offset({x: txt.getWidth()/2, y: txt.getHeight()/2});
layer.draw()
}
// set initial text & spin !
setValue('BBB');
var anim, pos = 0, frameCnt = 0
if (anim) {anim.stop(); }
anim = new Konva.Animation(function(frame) {
frameCnt = frameCnt + 1;
if (frameCnt % 2 === 0){
pos = pos + .2
var scaleX = Math.sin(pos)
textValue.visible(scaleX < 0 ? false : true);
group.scaleX(scaleX);
if (pos % 360 === 0){ console.log('spin') }
}
}, layer);
anim.start();
div
{
float: left;
margin: 0 5px;
}
p
{
margin: 0 5px 5px 0;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/konva/2.5.1/konva.min.js"></script>
<div id='container1' style="width: 300px, height: 200px;"></div>
<div>
<p> <input type='text' id='theText' value='BBB' /> <button id='st'>Change text</button> </p>
</div>
Is it possible in Konva to animate a shape (marker, circle) along a lline/path. I tried to manually calculate positions over time but this is only feasible if the line is straight from A to B, but I'm interested in a bezier curve and multiple path points.
So I wonder if Konva supports this kind of thing or someone could give a direction how to approach this.
As you have identified, the Path object has some handy methods in getLength() to find the overall path length and getPointAtLength() which can then be used to find the (x,y) at any given point along the length.
In case it helps anyone, I built the path data from the output of another snippet from this other question.
var data = [{"x":34,"y":34},{"x":84,"y":64},{"x":141,"y":79},{"x":181.5,"y":78.5},{"x":218,"y":62},{"x":223,"y":40},{"x":240,"y":26},{"x":259.5,"y":25},{"x":271,"y":40},{"x":292.5,"y":53},{"x":311.25,"y":55.5},{"x":330.625,"y":46.75},{"x":332.3125,"y":30.375},{"x":349.15625,"y":10.1875},{"x":374.578125,"y":10.09375},{"x":392,"y":26},{"x":411,"y":36},{"x":444.5,"y":37},{"x":453.875,"y":27.25},{"x":463.25,"y":17.5},{"x":472.9375,"y":10.625},{"x":494.625,"y":15.75},{"x":530,"y":48},{"x":534,"y":88},{"x":540,"y":150},{"x":552,"y":198},{"x":544,"y":227},{"x":522,"y":256},{"x":504.5,"y":263},{"x":471,"y":262},{"x":448,"y":252},{"x":372,"y":214},{"x":290,"y":146},{"x":256,"y":100},{"x":198,"y":104},{"x":182,"y":140},{"x":204,"y":185},{"x":203,"y":201.5},{"x":190,"y":214},{"x":174.5,"y":218},{"x":155,"y":214},{"x":124,"y":222},{"x":113.5,"y":232.5},{"x":95,"y":227},{"x":75.5,"y":211.5},{"x":72,"y":188},{"x":58,"y":136}]
// Set up the canvas / stage
var stage = new Konva.Stage({container: 'container1', width: 600, height: 300});
// Add a layer for line
var layer = new Konva.Layer({draggable: false});
stage.add(layer);
// draw a path.
var path = new Konva.Path({
x: 0,
y: 0,
stroke: 'cyan'
});
layer.add(path)
// Load the path points up using M = moveto, L = lineto.
var p = "M" + data[0].x + " " + data[0].y;
for (var i = 1; i < data.length; i = i + 1){
p = p + " L" + data[i].x + " " + data[i].y;
}
path.setData(p);
// add a circle to be animated along the path
var circle = new Konva.Circle({ x: data[0].x, y: data[0].y, radius: 10, fill: 'Magenta'});
layer.add(circle);
stage.draw();
$('#reset').on('click', function(){
// Now animate a circle along the path
var steps = 50; // number of steps in animation
var pathLen = path.getLength();
var step = pathLen / steps;
var frameCnt = 0, pos =0, pt;
anim = new Konva.Animation(function(frame) {
pos = pos + 1;
pt = path.getPointAtLength(pos * step);
circle.position({x: pt.x, y: pt.y});
}, layer);
anim.start();
})
$('#reset').trigger('click');
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/konva/2.5.1/konva.min.js"></script>
<button style='position: absolute; z-index: 10;' id='reset'>Go</button>
<div id='container1' style="width: 300px, height: 200px; background-color: silver;"></div>
<div id='img'></div>
I have n+1 hexshapes in a honeycomb grid. The objects are stacked close together. With this code:
// Get intersected objects, a.k.a objects "hit" by mouse, a.k.a objects that are mouse-overed
const intersects = raycaster.intersectObjects(hexObjects);
// If there is one (or more) intersections
let scaleTween = null;
if (intersects.length > 0) {
// If mouse is not currently over an object
// Set cursor to pointer so that the user can see that the object is clickable
document.body.style.cursor = 'pointer';
// Get the last intersected object, it's most likely that object we are currently hovering
const is = intersects.length > 0 ? intersects.length - 1 : 0;
// Is the object hovered over for the first time?
if (INTERSECTED === null) {
// Save current hovered object
INTERSECTED = intersects[is].object;
// HIGHLIGHT
// Save current color
INTERSECTED.currentHex = INTERSECTED.material.color.getHex();
// Set highlight color
INTERSECTED.material.color.setHex(COLOR_HIGHLIGHT);
// SCALE UP
// Try to stop the current tween, if any, in progress, so we can proceed with the next, if any, tween
try {
scaleTween.stop();
} catch (e) {}
// Create tween, save it so we can try to stop it, if needed
scaleTween = scale_tween(
INTERSECTED,
INTERSECTED.scale.clone(),
{
x: 1.5,
y: 1.5
},
100
);
scaleTween.start();
// SET Z-INDEX
INTERSECTED.position.z = 10;
} else {
// If the mouse is over an object
// Do we have a previous hovered item?
if (INTERSECTED !== null) {
// Revert color
INTERSECTED.material.color.setHex(INTERSECTED.currentHex);
// SCALE DOWN
// Try to stop the current tween, if any, in progress, so we can proceed with the next, if any, tween
try {
scaleTween.stop();
} catch (e) {}
// Create tween, save it so we can try to stop it, if needed
scaleTween = scale_tween(
INTERSECTED,
INTERSECTED.scale.clone(),
{
x: 1,
y: 1
},
100
);
scaleTween.start();
// REVERT Z-INDEX
INTERSECTED.position.z = 1;
}
// Save current intersected object
INTERSECTED = intersects[is].object;
// HIGHLIGHT
// Save current color
INTERSECTED.currentHex = INTERSECTED.material.color.getHex();
// Set highlight color
INTERSECTED.material.color.setHex(COLOR_HIGHLIGHT);
// SCALE UP
// Try to stop the current tween, if any, in progress, so we can proceed with the next, if any, tween
try {
scaleTween.stop();
} catch (e) {}
// Create tween, save it so we can try to stop it, if needed
scaleTween = scale_tween(
INTERSECTED,
INTERSECTED.scale.clone(),
{
x: 1.5,
y: 1.5
},
100
);
scaleTween.start();
// SET Z-INDEX
INTERSECTED.position.z = 10;
}
} else {
// If there are no intersections
// Reset cursor
document.body.style.cursor = 'default';
// Restore previous intersection object (if it exists) to its original color
if (INTERSECTED !== null) {
// REVERT COLOR
INTERSECTED.material.color.setHex(INTERSECTED.currentHex);
// SCALE DOWN
// Try to stop the current tween, if any, in progress, so we can proceed with the next, if any, tween
try {
scaleTween.stop();
} catch (e) {}
// Create tween, save it so we can try to stop it, if needed
scaleTween = scale_tween(
INTERSECTED,
INTERSECTED.scale.clone(),
{
x: 1,
y: 1
},
100
);
scaleTween.start();
// REVERT "Z-INDEX"
INTERSECTED.position.z = 1;
}
// Remove previous intersection object reference by setting current intersection object to "nothing"
INTERSECTED = null;
}
I've managed to highlight the object and scale it up with a tween quite nicely, but when I move the mouse out of the object onto the next object (the scaled object is scaled over the next object a bit), the highlight is gone, but the scale persists. How do I manage to scale the object down? And preferably with a tween?
A pen for this code can be found here: https://codepen.io/phun-ky/pen/erBZZy, the relevant part is at about line 1284 or search for INTERSECTED.
I wrote my own one. It's hell imperfect, but, at least, it scales up and down the hexagons:
var scene = new THREE.Scene();
var camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 1, 1000);
camera.position.set(0, 0, 10);
var renderer = new THREE.WebGLRenderer({
antialias: true
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0x101010);
document.body.appendChild(renderer.domElement);
var hexes = [];
var colCount = 5;
var rowCount = 4;
var hexDiameter = 3;
var xStart = -(colCount) * hexDiameter * 0.5;
var rowSpace = Math.sqrt(3) * hexDiameter * 0.5;
var yStart = (rowCount - 1) * rowSpace * 0.5;
var hexGeom = new THREE.CylinderGeometry(hexDiameter * 0.5, hexDiameter * 0.5, 0.0625, 6, 1);
hexGeom.rotateX(Math.PI * 0.5);
for (let j = 0; j < rowCount; j++) {
for (let i = 0; i < colCount + (j % 2 === 0 ? 0 : 1); i++) {
let hex = new THREE.Mesh(hexGeom, new THREE.MeshBasicMaterial({
color: Math.random() * 0x7e7e7e + 0x7e7e7e,
wireframe: false
}));
hex.position.set(xStart + i * hexDiameter + (j % 2 === 0 ? 0.5 * hexDiameter : 0), yStart - j * rowSpace, 0);
hex.userData.scaleUp = function(h) {
if (h.userData.scaleDownTween) h.userData.scaleDownTween.stop();
let initScale = h.scale.clone();
let finalScale = new THREE.Vector3().setScalar(2);
h.userData.scaleUpTween = new TWEEN.Tween(initScale).to(finalScale, 500).onUpdate(function(obj) {
h.scale.copy(obj)
}).start();
}
hex.userData.scaleDown = function(h) {
if (h.userData.scaleUpTween) h.userData.scaleUpTween.stop();
let initScale = h.scale.clone();
let finalScale = new THREE.Vector3().setScalar(1);
h.userData.scaleUpTween = new TWEEN.Tween(initScale).to(finalScale, 500).onUpdate(function(obj) {
h.scale.copy(obj)
}).start();
}
scene.add(hex);
hexes.push(hex);
}
}
window.addEventListener("mousemove", onMouseMove, false);
var raycaster = new THREE.Raycaster();
var mouse = new THREE.Vector2();
var intersects = [];
var intersected;
function onMouseMove(event) {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
intersects = raycaster.intersectObjects(hexes);
if (intersects.length > 0) {
if (intersected != intersects[0].object) {
if (intersected) intersected.userData.scaleDown(intersected);
intersected = intersects[0].object;
intersected.userData.scaleUp(intersected);
}
} else {
if (intersected) intersected.userData.scaleDown(intersected);
intersected = null;
}
}
render();
function render() {
requestAnimationFrame(render);
TWEEN.update();
renderer.render(scene, camera);
}
body {
overflow: hidden;
margin: 0;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/92/three.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tween.js/17.2.0/Tween.min.js"></script>
I have a KineticJS shape that draws a bezier curve that is wider on one end. It draws correctly, but I can't yet detect a 'mouseover' event on it. I have created a small JSFiddle demo of the anomaly, at:
http://jsfiddle.net/VikR0001/nZYxL/6/
How can I detect 'mouseover' events on this shape?
Thanks very much in advance to all for any info!
var mainLayer;
//bezier curve code:
//http://stackoverflow.com/questions/8325680/how-to-draw-a-bezier-curve-with-variable-thickness-on-an-html-canvas
//draw a bezier curve that gets larger as it flows
//adapted for use with KineticJS
function drawBezierCurve() {
var centerLeft = new Object();
centerLeft.x = 100;
centerLeft.y = 400;
var centerRight = new Object();
centerRight.x = 400;
centerRight.y = 100;
var thicknessLeft = 1;
var thicknessRight = 50;
var color = "#000";
var context = mainLayer.getContext();
var leftUpper = {
x: centerLeft.x,
y: centerLeft.y - thicknessLeft / 2
};
var leftLower = {
x: centerLeft.x,
y: leftUpper.y + thicknessLeft
};
var rightUpper = {
x: centerRight.x,
y: centerRight.y - thicknessRight / 2
};
var rightLower = {
x: centerRight.x,
y: rightUpper.y + thicknessRight
};
var center = (centerRight.x + centerLeft.x) / 2;
var cp1Upper = {
x: center,
y: leftUpper.y
};
var cp2Upper = {
x: center,
y: rightUpper.y
};
var cp1Lower = {
x: center,
y: rightLower.y
};
var cp2Lower = {
x: center,
y: leftLower.y
};
var bezierCurve = new Kinetic.Shape({
drawFunc: function (canvas) {
var context = mainLayer.getContext();
context.fillStyle = color;
context.beginPath();
context.moveTo(leftUpper.x, leftUpper.y);
context.bezierCurveTo(cp1Upper.x, cp1Upper.y, cp2Upper.x, cp2Upper.y, rightUpper.x, rightUpper.y);
context.lineTo(rightLower.x, rightLower.y);
context.bezierCurveTo(cp1Lower.x, cp1Lower.y, cp2Lower.x, cp2Lower.y, leftLower.x, leftLower.y);
context.lineTo(leftUpper.x, leftUpper.y);
context.fill();
canvas.stroke(this);
},
fill: color,
stroke: color,
strokeWidth: 1
});
bezierCurve.on('mouseover', function (evt) {
document.body.style.cursor = "pointer";
$("#debug").html("MOUSEOVER DETECTED."); //<==NEVER CALLED
});
bezierCurve.on('mouseout', function (evt) {
document.body.style.cursor = "default";
$("#debug").html("MOUSEOUT DETECTED."); //NEVER CALLED
});
bezierCurve.setAttrs({
'leftUpper': leftUpper,
'leftLower': leftLower,
'rightUpper': rightUpper,
'rightLower': rightLower,
'cp1Upper': cp1Upper,
'cp2Upper': cp2Upper,
'cp1Lower': cp1Lower,
'cp2Lower': cp2Lower
});
mainLayer.add(bezierCurve);
mainLayer.draw();
$("#debug").html("bezier curve has been drawn onscreen.");
}
$(document).ready(function () {
var stage = new Kinetic.Stage({
container: 'canvasContainer',
width: 500,
height: 500
});
mainLayer = new Kinetic.Layer('main');
stage.add(mainLayer);
mainLayer.draw();
drawBezierCurve();
});
Can you define it as an SVG element, and just give that an onmouseover?
Fixed it! Changes are shown at the jsFiddle link in the original post.
//FIXED!
//OLD VERSION: DOES NOT WORK
// var bezierCurve = new Kinetic.Shape({
// drawFunc: function (canvas) {
// var context = mainLayer.getContext();
// context.fillStyle = color;
// context.beginPath();
// context.moveTo(leftUpper.x, leftUpper.y);
// context.bezierCurveTo(cp1Upper.x, cp1Upper.y, cp2Upper.x, cp2Upper.y, rightUpper.x, rightUpper.y);
// context.lineTo(rightLower.x, rightLower.y);
// context.bezierCurveTo(cp1Lower.x, cp1Lower.y, cp2Lower.x, cp2Lower.y, leftLower.x, leftLower.y);
// context.lineTo(leftUpper.x, leftUpper.y);
// context.closePath();
// context.fill();
// canvas.stroke(this);
// },
// fill: color,
// stroke: color,
// strokeWidth: 1
// });
//NEW VERSION: WORKS!
var bezierCurve = new Kinetic.Shape({
drawFunc: function (canvas) {
var context = canvas.getContext('2d');
context.beginPath();
context.moveTo(leftUpper.x, leftUpper.y);
context.bezierCurveTo(cp1Upper.x,cp1Upper.y, cp2Upper.x,cp2Upper.y, rightUpper.x,rightUpper.y);
context.lineTo(rightLower.x, rightLower.y);
context.bezierCurveTo(cp1Lower.x,cp1Lower.y, cp2Lower.x,cp2Lower.y, leftLower.x,leftLower.y);
context.lineTo(leftUpper.x, leftUpper.y);
context.fill();
canvas.stroke(this);
},
fill: color,
stroke: color,
strokeWidth: 3
});