SVG smooth freehand drawing - javascript

I implemented a freehand drawing of a path using native JS. But as expected path edges are little aggressive and not smooth. So I have an option of using simplifyJS to simplify points and then redraw path. But like here, instead of smoothening after drawing, I am trying to find simplified edges while drawing
Here is my code:
var x0, y0;
var dragstart = function(event) {
var that = this;
var pos = coordinates(event);
x0 = pos.x;
y0 = pos.y;
that.points = [];
};
var dragging = function(event) {
var that = this;
var xy = coordinates(event);
var points = that.points;
var x1 = xy.x, y1 = xy.y, dx = x1 - x0, dy = y1 - y0;
if (dx * dx + dy * dy > 100) {
xy = {
x: x0 = x1,
y: y0 = y1
};
} else {
xy = {
x: x1,
y: y1
};
}
points.push(xy);
};
But it is not working as in the link added above. Still edges are not good. Please help.

The following code snippet makes the curve smoother by calculating the average of the last mouse positions. The level of smoothing depends on the size of the buffer in which these values are kept. You can experiment with the different buffer sizes offered in the dropdown list. The behavior with a 12 point buffer is somewhat similar to the Mike Bostock's code snippet that you refer to in the question.
More sophisticated techniques could be implemented to get the smoothed point from the positions stored in the buffer (weighted average, linear regression, cubic spline smoothing, etc.) but this simple average method may be sufficiently accurate for your needs.
var strokeWidth = 2;
var bufferSize;
var svgElement = document.getElementById("svgElement");
var rect = svgElement.getBoundingClientRect();
var path = null;
var strPath;
var buffer = []; // Contains the last positions of the mouse cursor
svgElement.addEventListener("mousedown", function (e) {
bufferSize = document.getElementById("cmbBufferSize").value;
path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute("fill", "none");
path.setAttribute("stroke", "#000");
path.setAttribute("stroke-width", strokeWidth);
buffer = [];
var pt = getMousePosition(e);
appendToBuffer(pt);
strPath = "M" + pt.x + " " + pt.y;
path.setAttribute("d", strPath);
svgElement.appendChild(path);
});
svgElement.addEventListener("mousemove", function (e) {
if (path) {
appendToBuffer(getMousePosition(e));
updateSvgPath();
}
});
svgElement.addEventListener("mouseup", function () {
if (path) {
path = null;
}
});
var getMousePosition = function (e) {
return {
x: e.pageX - rect.left,
y: e.pageY - rect.top
}
};
var appendToBuffer = function (pt) {
buffer.push(pt);
while (buffer.length > bufferSize) {
buffer.shift();
}
};
// Calculate the average point, starting at offset in the buffer
var getAveragePoint = function (offset) {
var len = buffer.length;
if (len % 2 === 1 || len >= bufferSize) {
var totalX = 0;
var totalY = 0;
var pt, i;
var count = 0;
for (i = offset; i < len; i++) {
count++;
pt = buffer[i];
totalX += pt.x;
totalY += pt.y;
}
return {
x: totalX / count,
y: totalY / count
}
}
return null;
};
var updateSvgPath = function () {
var pt = getAveragePoint(0);
if (pt) {
// Get the smoothed part of the path that will not change
strPath += " L" + pt.x + " " + pt.y;
// Get the last part of the path (close to the current mouse position)
// This part will change if the mouse moves again
var tmpPath = "";
for (var offset = 2; offset < buffer.length; offset += 2) {
pt = getAveragePoint(offset);
tmpPath += " L" + pt.x + " " + pt.y;
}
// Set the complete current path coordinates
path.setAttribute("d", strPath + tmpPath);
}
};
html, body
{
padding: 0px;
margin: 0px;
}
#svgElement
{
border: 1px solid;
margin-top: 4px;
margin-left: 4px;
cursor: default;
}
#divSmoothingFactor
{
position: absolute;
left: 14px;
top: 12px;
}
<div id="divSmoothingFactor">
<label for="cmbBufferSize">Buffer size:</label>
<select id="cmbBufferSize">
<option value="1">1 - No smoothing</option>
<option value="4">4 - Sharp curves</option>
<option value="8" selected="selected">8 - Smooth curves</option>
<option value="12">12 - Very smooth curves</option>
<option value="16">16 - Super smooth curves</option>
<option value="20">20 - Hyper smooth curves</option>
</select>
</div>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="svgElement" x="0px" y="0px" width="600px" height="400px" viewBox="0 0 600 400" enable-background="new 0 0 600 400" xml:space="preserve">

Quadtratic Bézier polyline smoothing
#ConnorsFan solution works great and is probably providing a better rendering performance and more responsive drawing experience.
In case you need a more compact svg output (in terms of markup size) quadratic smoothing might be interesting.
E.g. if you need to export the drawings in an efficient way.
Simplified example: polyline smoothing
Green dots show the original polyline coordinates (in x/y pairs).
Purple points represent interpolated middle coordinates – simply calculated like so:
[(x1+x2)/2, (y1+y2)/2].
The original coordinates (highlighted green) become quadratic bézier control points
whereas the interpolated middle points will be the end points.
let points = [{
x: 0,
y: 10
},
{
x: 10,
y: 20
},
{
x: 20,
y: 10
},
{
x: 30,
y: 20
},
{
x: 40,
y: 10
}
];
path.setAttribute("d", smoothQuadratic(points));
function smoothQuadratic(points) {
// set M/starting point
let [Mx, My] = [points[0].x, points[0].y];
let d = `M ${Mx} ${My}`;
renderPoint(svg, [Mx, My], "green", "1");
// split 1st line segment
let [x1, y1] = [points[1].x, points[1].y];
let [xM, yM] = [(Mx + x1) / 2, (My + y1) / 2];
d += `L ${xM} ${yM}`;
renderPoint(svg, [xM, yM], "purple", "1");
for (let i = 1; i < points.length; i += 1) {
let [x, y] = [points[i].x, points[i].y];
// calculate mid point between current and next coordinate
let [xN, yN] = points[i + 1] ? [points[i + 1].x, points[i + 1].y] : [x, y];
let [xM, yM] = [(x + xN) / 2, (y + yN) / 2];
// add quadratic curve:
d += `Q${x} ${y} ${xM} ${yM}`;
renderPoint(svg, [xM, yM], "purple", "1");
renderPoint(svg, [x, y], "green", "1");
}
return d;
}
pathRel.setAttribute("d", smoothQuadraticRelative(points));
function smoothQuadraticRelative(points, skip = 0, decimals = 3) {
let pointsL = points.length;
let even = pointsL - skip - (1 % 2) === 0;
// set M/starting point
let type = "M";
let values = [points[0].x, points[0].y];
let [Mx, My] = values.map((val) => {
return +val.toFixed(decimals);
});
let dRel = `${type}${Mx} ${My}`;
// offsets for relative commands
let xO = Mx;
let yO = My;
// split 1st line segment
let [x1, y1] = [points[1].x, points[1].y];
let [xM, yM] = [(Mx + x1) / 2, (My + y1) / 2];
let [xMR, yMR] = [xM - xO, yM - yO].map((val) => {
return +val.toFixed(decimals);
});
dRel += `l${xMR} ${yMR}`;
xO += xMR;
yO += yMR;
for (let i = 1; i < points.length; i += 1 + skip) {
// control point
let [x, y] = [points[i].x, points[i].y];
let [xR, yR] = [x - xO, y - yO];
// next point
let [xN, yN] = points[i + 1 + skip] ?
[points[i + 1 + skip].x, points[i + 1 + skip].y] :
[points[pointsL - 1].x, points[pointsL - 1].y];
let [xNR, yNR] = [xN - xO, yN - yO];
// mid point
let [xM, yM] = [(x + xN) / 2, (y + yN) / 2];
let [xMR, yMR] = [(xR + xNR) / 2, (yR + yNR) / 2];
type = "q";
values = [xR, yR, xMR, yMR];
// switch to t command
if (i > 1) {
type = "t";
values = [xMR, yMR];
}
dRel += `${type}${values
.map((val) => {
return +val.toFixed(decimals);
})
.join(" ")} `;
xO += xMR;
yO += yMR;
}
// add last line if odd number of segments
if (!even) {
values = [points[pointsL - 1].x - xO, points[pointsL - 1].y - yO];
dRel += `l${values
.map((val) => {
return +val.toFixed(decimals);
})
.join(" ")}`;
}
return dRel;
}
function renderPoint(svg, coords, fill = "red", r = "2") {
let marker =
'<circle cx="' +
coords[0] +
'" cy="' +
coords[1] +
'" r="' +
r +
'" fill="' +
fill +
'" ><title>' +
coords.join(", ") +
"</title></circle>";
svg.insertAdjacentHTML("beforeend", marker);
}
svg {
border: 1px solid #ccc;
width: 45vw;
overflow: visible;
margin-right: 1vw;
}
path {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
stroke-opacity: 0.5;
}
<svg id="svg" viewBox="0 0 40 30">
<path d="M 0 10 L 10 20 20 10 L 30 20 40 10" fill="none" stroke="#999" stroke-width="1"></path>
<path id="path" d="" fill="none" stroke="red" stroke-width="1" />
</svg>
<svg id="svg2" viewBox="0 0 40 30">
<path d="M 0 10 L 10 20 20 10 L 30 20 40 10" fill="none" stroke="#999" stroke-width="1"></path>
<path id="pathRel" d="" fill="none" stroke="red" stroke-width="1" />
</svg>
Example: Svg draw Pad
const svg = document.getElementById("svg");
const svgns = "http://www.w3.org/2000/svg";
let strokeWidth = 0.25;
// rounding and smoothing
let decimals = 2;
let getNthMouseCoord = 1;
let smooth = 2;
// init
let isDrawing = false;
var points = [];
let path = "";
let pointCount = 0;
const drawStart = (e) => {
pointCount = 0;
isDrawing = true;
// create new path
path = document.createElementNS(svgns, "path");
svg.appendChild(path);
};
const draw = (e) => {
if (isDrawing) {
pointCount++;
if (getNthMouseCoord && pointCount % getNthMouseCoord === 0) {
let point = getMouseOrTouchPos(e);
// save to point array
points.push(point);
}
if (points.length > 1) {
let d = smoothQuadratic(points, smooth, decimals);
path.setAttribute("d", d);
}
}
};
const drawEnd = (e) => {
isDrawing = false;
points = [];
// just illustrating the ouput
svgMarkup.value = svg.outerHTML;
};
// start drawing: create new path;
svg.addEventListener("mousedown", drawStart);
svg.addEventListener("touchstart", drawStart);
svg.addEventListener("mousemove", draw);
svg.addEventListener("touchmove", draw);
// stop drawing, reset point array for next line
svg.addEventListener("mouseup", drawEnd);
svg.addEventListener("touchend", drawEnd);
svg.addEventListener("touchcancel", drawEnd);
function smoothQuadratic(points, skip = 0, decimals = 3) {
let pointsL = points.length;
let even = pointsL - skip - (1 % 2) === 0;
// set M/starting point
let type = "M";
let values = [points[0].x, points[0].y];
let [Mx, My] = values.map((val) => {
return +val.toFixed(decimals);
});
let dRel = `${type}${Mx} ${My}`;
// offsets for relative commands
let xO = Mx;
let yO = My;
// split 1st line segment
let [x1, y1] = [points[1].x, points[1].y];
let [xM, yM] = [(Mx + x1) / 2, (My + y1) / 2];
let [xMR, yMR] = [xM - xO, yM - yO].map((val) => {
return +val.toFixed(decimals);
});
dRel += `l${xMR} ${yMR}`;
xO += xMR;
yO += yMR;
for (let i = 1; i < points.length; i += 1 + skip) {
// control point
let [x, y] = [points[i].x, points[i].y];
let [xR, yR] = [x - xO, y - yO];
// next point
let [xN, yN] = points[i + 1 + skip] ?
[points[i + 1 + skip].x, points[i + 1 + skip].y] :
[points[pointsL - 1].x, points[pointsL - 1].y];
let [xNR, yNR] = [xN - xO, yN - yO];
// mid point
let [xM, yM] = [(x + xN) / 2, (y + yN) / 2];
let [xMR, yMR] = [(xR + xNR) / 2, (yR + yNR) / 2];
type = "q";
values = [xR, yR, xMR, yMR];
// switch to t command
if (i > 1) {
type = "t";
values = [xMR, yMR];
}
dRel += `${type}${values
.map((val) => {
return +val.toFixed(decimals);
})
.join(" ")} `;
xO += xMR;
yO += yMR;
}
// add last line if odd number of segments
if (!even) {
values = [points[pointsL - 1].x - xO, points[pointsL - 1].y - yO];
dRel += `l${values
.map((val) => {
return +val.toFixed(decimals);
})
.join(" ")}`;
}
return dRel;
}
/**
* based on:
* #Daniel Lavedonio de Lima
* https://stackoverflow.com/a/61732450/3355076
*/
function getMouseOrTouchPos(e) {
let x, y;
// touch cooordinates
if (
e.type == "touchstart" ||
e.type == "touchmove" ||
e.type == "touchend" ||
e.type == "touchcancel"
) {
let evt = typeof e.originalEvent === "undefined" ? e : e.originalEvent;
let touch = evt.touches[0] || evt.changedTouches[0];
x = touch.pageX;
y = touch.pageY;
} else if (
e.type == "mousedown" ||
e.type == "mouseup" ||
e.type == "mousemove" ||
e.type == "mouseover" ||
e.type == "mouseout" ||
e.type == "mouseenter" ||
e.type == "mouseleave"
) {
x = e.clientX;
y = e.clientY;
}
// get svg user space coordinates
let point = svg.createSVGPoint();
point.x = x;
point.y = y;
let ctm = svg.getScreenCTM().inverse();
point = point.matrixTransform(ctm);
return point;
}
body {
margin: 0;
font-family: sans-serif;
padding: 1em;
}
* {
box-sizing: border-box;
}
svg {
width: 100%;
max-height: 75vh;
overflow: visible;
}
textarea {
width: 100%;
min-height: 50vh;
resize: none;
}
.border {
border: 1px solid #ccc;
}
path {
fill: none;
stroke: #000;
stroke-linecap: round;
stroke-linejoin: round;
}
input[type="number"] {
width: 3em;
}
input[type="number"]::-webkit-inner-spin-button {
opacity: 1;
}
#media (min-width: 720px) {
svg {
width: 75%;
}
textarea {
width: 25%;
}
.flex {
display: flex;
gap: 1em;
}
.flex * {
flex: 1 0 auto;
}
}
<h2>Draw quadratic bezier (relative commands)</h2>
<p><button type="button" id="clear" onclick="clearDrawing()">Clear</button>
<label>Get nth Mouse position</label><input type="number" id="nthMouseCoord" value="1" min="0" oninput="changeVal()">
<label>Smooth</label><input type="number" id="simplifyDrawing" min="0" value="2" oninput="changeVal()">
</p>
<div class="flex">
<svg class="border" id="svg" viewBox="0 0 200 100">
</svg>
<textarea class="border" id="svgMarkup"></textarea>
</div>
<script>
function changeVal() {
getNthMouseCoord = +nthMouseCoord.value + 1;
simplify = +simplifyDrawing.value;;
}
function clearDrawing() {
let paths = svg.querySelectorAll('path');
paths.forEach(path => {
path.remove();
})
}
</script>
How it works
save mouse/cursor positions in a point array via event listeners
Event Listeners (including touch events):
function getMouseOrTouchPos(e) {
let x, y;
// touch cooordinates
if (e.type == "touchstart" || e.type == "touchmove" || e.type == "touchend" || e.type == "touchcancel"
) {
let evt = typeof e.originalEvent === "undefined" ? e : e.originalEvent;
let touch = evt.touches[0] || evt.changedTouches[0];
x = touch.pageX;
y = touch.pageY;
} else if ( e.type == "mousedown" || e.type == "mouseup" || e.type == "mousemove" || e.type == "mouseover" || e.type == "mouseout" || e.type == "mouseenter" || e.type == "mouseleave") {
x = e.clientX;
y = e.clientY;
}
// get svg user space coordinates
let point = svg.createSVGPoint();
point.x = x;
point.y = y;
let ctm = svg.getScreenCTM().inverse();
point = point.matrixTransform(ctm);
return point;
}
It's crucial to translate HTML DOM cursor coordinates to SVG DOM user units unless your svg viewport corresponds to the HTML placement 1:1.
let ctm = svg.getScreenCTM().inverse();
point = point.matrixTransform(ctm);
optional: skip cursor points and use every nth point respectively (pre processing – aimed at reducing the total amount of cursor coordinates)
optional: similar to the previous measure: smooth by skipping polyine segments – the curve control point calculation will skip succeeding mid and control points (post processing – calculate curves based on retrieved point array but skip points).
Q to T simplification: Since we are splitting the polyline coordinates evenly we can simplify the path d output by using the quadratic shorthand command T repeating the previous tangents.
Converting to relative commands and rounding
Based on x/y offsets globally incremented by the previous command's end point.
Depending on your layout sizes you need to tweak smoothing values.
For a "micro smoothing" you should also include these css properties:
path {
fill: none;
stroke: #000;
stroke-linecap: round;
stroke-linejoin: round;
}
Further reading
Change T command to Q command in SVG

There are already some implementations for this on github e.g. https://github.com/epistemex/cardinal-spline-js
You dont have to change anything on your input for that and can only change the draw function, that the line between the points is smooth. With that the points dont slip a bit during the simplification.

Related

Canvas drawing is very slow

I want to display scale with markings which is working fine. On top of that I also want to display mouse location in the scale with red indicator.
So, I draw canvas when I run the app and then I'm redrawing entire canvas when mouse location is changed.
I'm new to canvas and don't understand whats wrong in my code. I have been trying to resolve it but no luck.
Problem might be in this function,
function drawBlackMarkers(y, coordinateMeasurment){
const markHightY = scaleTextPadding.initial;
ctxLeft.moveTo(coordinateMeasurment, y + markHightY);
ctxLeft.lineTo(completeMarkHight, y + markHightY);
}
I'm having a big for loop means so many iterations to go through and in that loop I call drawBlackMarkers function that many times as shown below.
function setMarkers(initialValY, rangeValY, coordinateMeasurmentr, divisableVal,
scaleCountStartValueOfY, scaleCountRangeValueOfY) {
let count = 0;
// re-modifying scale staring and ending values based on zoom factor
const scaleInceremnt = scaleIncementValue;
for (let y = (initialValY), scaleCountY = scaleCountStartValueOfY;
y <= (rangeValY) && scaleCountY <= scaleCountRangeValueOfY;
y += scaleInceremnt, scaleCountY += incrementFactor) {
switch (count) {
case displayScale.starting:
coordinateMeasurment = marktype.bigMark; count++;
const scaleValY = scaleCountY - divisableVal;
ctxLeft.strokeStyle = colors.black;
ctxLeft.font = scaleNumberFont;
const size = ctxLeft.measureText(scaleValY.toString());
ctxLeft.save();
const textX = coordinateMeasurment + ((size.width) / 2);
const textY = y - scaleTextPadding.alignment;
ctxLeft.translate(textX, textY);
ctxLeft.rotate(-Math.PI / 2);
ctxLeft.translate(-textX, -textY);
ctxLeft.fillText(scaleValY.toString(), coordinateMeasurment, y - scaleTextPadding.complete);
ctxLeft.restore();
break;
case displayScale.middle:
coordinateMeasurment = marktype.middleMark; count++;
break;
case displayScale.end:
coordinateMeasurment = marktype.smallMark; count = 0;
break;
default:
coordinateMeasurment = marktype.smallMark; count++;
break;
}
// to draw scale lines on canvas
// drawBlackMarkers(y, coordinateMeasurment);
}
}
Please check this : http://jsfiddle.net/3v5nt7fe/1/
The problem is if I comment drawBlackMarkers function call, mouse co-ordinate updation is very fast but if I uncomment, it takes so long to update the location.
I really need help to resolve this issue.
It's not the drawBlackMarkers itself, it's this:
for (let y = (initialValY), scaleCountY = scaleCountStartValueOfY;
y <= (rangeValY) && scaleCountY <= scaleCountRangeValueOfY;
y += scaleInceremnt, scaleCountY += incrementFactor) {
This is constantly increasing and happening 640,000 times. You can tell that's the case by writing:
// to draw scale lines on canvas
// drawBlackMarkers(y, coordinateMeasurment);
console.log(y);
and seeing the console result.
So that for loop does very little, because most of it is behind a switch statement, and when it does even this simple drawBlackMarkers outside its showing the true cost of that loop. rangeValY is 640,000, which means the path the canvas context must construct is enormous.
So to fix this you must find a way to ameliorate that problem.
This is doing a lot of unnecessary work
The screen is not 64000 pixels in height. You want to calculate the viewport, and only draw what is in the viewport.
Your function drawBlackMarkers is not the culprit. The system is very slow before that, its simply adding one more thing to be drawn. It was the straw that broke the camel's back.
By reducing the length of what you are drawing, you can very easily avoid the wasted CPU cycles.
In this version, all I have done is re-enable drawBlackMarkers, and shrink the canvas.
const CANVAS_WIDTH = 2000;
const CANVAS_HEIGHT = 50;
const completeMarkHight = 15;
const divisibleValue = 0;
const scaleIncementValue = 10;
const scaleTextPadding = { initial: 0, middle: 5, end: 10, complete: 15, alignment: 18 };
const displayScale = { starting: 0, middle: 5, end: 9 };
const colors = { red: '#FF0000', white: '#D5D6D7', black: '#181c21' };
const marktype = { bigMark: 0, middleMark: 5, smallMark: 10 };
const startingInitialOrigin = { x: 0, y: 0 };
const scaleNumberFont = '10px Titillium Web Regular';
const defaultZoomLevel = 100;
const markingGap = {level1: 400, level2: 200, level3: 100, level4: 50, level5: 20, level6: 10 };
const zoomScaleLevel = {level0: 0, level1: 25, level2: 50, level3: 100, level4: 200, level5: 500, level6: 1000};
var $canvas = $('#canvas');
var ctxLeft = $canvas[0].getContext('2d');
var mousePositionCoordinates;
var pagePositions = { x: 100, y:0 };
var remainderX;
var remainderY;
var scaleCountRemainderX;
var scaleCountRemainderY;
var zoomFactor;
var zoomScale;
var zoomLevel;
var multiplyFactor;
var incrementFactor;
var markingDistance;
var timetaken=0;
ctxLeft.fillStyle = colors.white;
function render() {
clear();
ctxLeft.beginPath();
zoomScale = 1000;
zoomLevel = 1000;
zoomFactor = zoomLevel / defaultZoomLevel;
markingDistance = markingGap.level6;
multiplyFactor = markingDistance / defaultZoomLevel;
incrementFactor = markingDistance / scaleIncementValue;
renderVerticalRuler(startingInitialOrigin.y);
}
function renderVerticalRuler(posY) {
const initialValY = - posY / multiplyFactor;
const rangeValY = (CANVAS_WIDTH - posY) / multiplyFactor;
const initialValOfYwithMultiplyFactor = -posY;
const rangeValOfYwithMultiplyFactor = (CANVAS_WIDTH - posY);
// to adjust scale count get remainder value based on marking gap
scaleCountRemainderY = initialValOfYwithMultiplyFactor % markingDistance;
const scaleCountStartValueOfY = initialValOfYwithMultiplyFactor - scaleCountRemainderY;
const scaleCountRangeValueOfY = rangeValOfYwithMultiplyFactor - scaleCountRemainderY;
// to get orgin(0,0) values
remainderY = initialValY % 100;
const translateY = (posY / multiplyFactor) - remainderY;
ctxLeft.translate(origin.x, translateY); // x,y
const coordinateMeasurment = 0;
const t0 = performance.now();
setMarkers(initialValY, rangeValY, coordinateMeasurment, divisibleValue, scaleCountStartValueOfY, scaleCountRangeValueOfY);
const t1 = performance.now()
console.log("it took " + (t1 - t0) + " milliseconds.");
ctxLeft.stroke();
ctxLeft.closePath();
}
function setMarkers(initialValY, rangeValY, coordinateMeasurmentr, divisableVal,
scaleCountStartValueOfY, scaleCountRangeValueOfY) {
let count = 0;
// re-modifying scale staring and ending values based on zoom factor
const scaleInceremnt = scaleIncementValue;
for (let y = (initialValY), scaleCountY = scaleCountStartValueOfY;
y <= (rangeValY) && scaleCountY <= scaleCountRangeValueOfY;
y += scaleInceremnt, scaleCountY += incrementFactor) {
switch (count) {
case displayScale.starting:
coordinateMeasurment = marktype.bigMark; count++;
const scaleValY = scaleCountY - divisableVal;
ctxLeft.strokeStyle = colors.black;
ctxLeft.font = scaleNumberFont;
const size = ctxLeft.measureText(scaleValY.toString());
ctxLeft.save();
const textX = coordinateMeasurment + ((size.width) / 2);
const textY = y - scaleTextPadding.alignment;
ctxLeft.translate(textX, textY);
ctxLeft.rotate(-Math.PI / 2);
ctxLeft.translate(-textX, -textY);
ctxLeft.fillText(scaleValY.toString(), coordinateMeasurment, y - scaleTextPadding.complete);
ctxLeft.restore();
break;
case displayScale.middle:
coordinateMeasurment = marktype.middleMark; count++;
break;
case displayScale.end:
coordinateMeasurment = marktype.smallMark; count = 0;
break;
default:
coordinateMeasurment = marktype.smallMark; count++;
break;
}
// to draw scale lines on canvas
drawBlackMarkers(y, coordinateMeasurment);
}
}
function drawBlackMarkers(y, coordinateMeasurment){
const markHightY = scaleTextPadding.initial;
ctxLeft.moveTo(coordinateMeasurment, y + markHightY);
ctxLeft.lineTo(completeMarkHight, y + markHightY);
}
function clear() {
ctxLeft.resetTransform();
ctxLeft.clearRect(origin.x, origin.y, CANVAS_HEIGHT, CANVAS_WIDTH);
}
render();
$('.canvas-container').mousemove(function(e) {
mousePositionCoordinates = {x:e.clientX, y:e.clientY};
render();
// SHOW RED INDICATOR
ctxLeft.beginPath();
ctxLeft.strokeStyle = colors.red; // show mouse indicator
ctxLeft.lineWidth = 2;
// to display purple indicator based on zoom level
const mouseX = mousePositionCoordinates.x * zoomFactor;
const mouseY = mousePositionCoordinates.y * zoomFactor;
const markHightY =scaleTextPadding.initial + this.remainderY;
ctxLeft.moveTo(marktype.bigMark, e.clientY );
ctxLeft.lineTo(completeMarkHight, e.clientY);
ctxLeft.stroke();
$('.mouselocation').text(`${mousePositionCoordinates.x},${mousePositionCoordinates.y}`);
});
body, html{
width: 100000px;
height:100000px;
}
.canvas-container{
width:100%;
height:100%;
}
.canvasLeft {
position: absolute;
border:1px solid black;
background: grey;
border-top: none;
z-index: 1;
top:0
}
.mouselocation{
position: fixed;
right: 0px;
top: 50px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/1.7.2/jquery.min.js"></script>
<div class="canvas-container">
<canvas id="canvas" class="canvasLeft" width="30" height="2000"></canvas>
</div>
<div class="mouselocation">
</div>

JS Canvas movement animation loop

I have an object rendered to a canvas. I'm trying to get the object to move along a set path on a loop. Here is what I have:
// Canvas Element
var canvas = null;
// Canvas Draw
var ctx = null;
// Static Globals
var tileSize = 16,
mapW = 10,
mapH = 10;
// Instances of entities
var entities = [
// A single entity that starts at tile 28, and uses the setPath() function
{
id: 0,
tile: 28,
xy: tileToCoords(28),
width: 16,
height: 24,
speedX: 0,
speedY: 0,
logic: {
func: 'setPath',
// These are the parameters that go into the setPath() function
data: [0, ['down', 'up', 'left', 'right'], tileToCoords(28), 0]
},
dir: {up:false, down:false, left:false, right:false}
}
];
// Array for tile data
var map = [];
window.onload = function(){
// Populate the map array with a blank map and 4 walls
testMap();
canvas = document.getElementById('save');
ctx = canvas.getContext("2d");
// Add all the entities to the map array and start their behavior
for(var i = 0; i < entities.length; ++i){
map[entities[i].tile].render.object = entities[i].id;
if(entities[i].logic){
window[entities[i].logic.func].apply(null, entities[i].logic.data);
}
}
drawGame(map);
window.requestAnimationFrame(function(){
mainLoop();
});
};
function drawGame(map){
ctx.clearRect(0, 0, canvas.width, canvas.height);
// We save all the entity data for later so the background colors don't get rendered on top
var tileObjData = [];
for(var y = 0; y < mapH; ++y){
for(var x = 0; x < mapW; ++x){
var currentPos = ((y*mapW)+x);
ctx.fillStyle = map[currentPos].render.base;
ctx.fillRect(x*tileSize, y*tileSize, tileSize, tileSize);
var thisObj = map[currentPos].render.object;
if(thisObj !== false){
thisObj = entities[thisObj];
var originX = thisObj.xy.x;
var originY = thisObj.xy.y;
tileObjData.push(
{
id: thisObj.id,
originX: originX,
originY: originY,
width: thisObj.width,
height: thisObj.height,
}
);
}
}
}
// Draw all the entities after the background tiles are drawn
for(var i = 0; i < tileObjData.length; ++i){
drawEntity(tileObjData[i].id, tileObjData[i].originX, tileObjData[i].originY, tileObjData[i].width, tileObjData[i].height);
}
}
// Draws the entity data
function drawEntity(id, posX, posY, sizeX, sizeY){
var offX = posX + entities[id].speedX;
var offY = posY + entities[id].speedY;
ctx.fillStyle = '#00F';
ctx.fillRect(offX, offY + sizeX - sizeY, sizeX, sizeY);
entities[id].xy.x = offX;
entities[id].xy.y = offY;
}
// Redraws the canvas with the browser framerate
function mainLoop(){
drawGame(map);
for(var i = 0; i < entities.length; ++i){
animateMove(i, entities[i].dir.up, entities[i].dir.down, entities[i].dir.left, entities[i].dir.right);
}
window.requestAnimationFrame(function(){
mainLoop();
});
}
// Sets the speed, direction, and collision detection of an entity
function animateMove(id, up, down, left, right){
var prevTile = entities[id].tile;
if(up){
var topLeft = {x: entities[id].xy.x, y: entities[id].xy.y};
var topRight = {x: entities[id].xy.x + entities[id].width - 1, y: entities[id].xy.y};
if(!map[coordsToTile(topLeft.x, topLeft.y - 1)].state.passable || !map[coordsToTile(topRight.x, topRight.y - 1)].state.passable){
entities[id].speedY = 0;
}
else{
entities[id].speedY = -1;
}
}
else if(down){
var bottomLeft = {x: entities[id].xy.x, y: entities[id].xy.y + entities[id].width - 1};
var bottomRight = {x: entities[id].xy.x + entities[id].width - 1, y: entities[id].xy.y + entities[id].width - 1};
if(!map[coordsToTile(bottomLeft.x, bottomLeft.y + 1)].state.passable || !map[coordsToTile(bottomRight.x, bottomRight.y + 1)].state.passable){
entities[id].speedY = 0;
}
else{
entities[id].speedY = 1;
}
}
else{
entities[id].speedY = 0;
}
if(left){
var bottomLeft = {x: entities[id].xy.x, y: entities[id].xy.y + entities[id].width - 1};
var topLeft = {x: entities[id].xy.x, y: entities[id].xy.y};
if(!map[coordsToTile(bottomLeft.x - 1, bottomLeft.y)].state.passable || !map[coordsToTile(topLeft.x - 1, topLeft.y)].state.passable){
entities[id].speedX = 0;
}
else{
entities[id].speedX = -1;
}
}
else if(right){
var bottomRight = {x: entities[id].xy.x + entities[id].width - 1, y: entities[id].xy.y + entities[id].width - 1};
var topRight = {x: entities[id].xy.x + entities[id].width - 1, y: entities[id].xy.y};
if(!map[coordsToTile(bottomRight.x + 1, bottomRight.y)].state.passable || !map[coordsToTile(topRight.x + 1, topRight.y)].state.passable){
entities[id].speedX = 0;
}
else{
entities[id].speedX = 1;
}
}
else{
entities[id].speedX = 0;
}
entities[id].tile = coordsToTile(entities[id].xy.x + (entities[id].width / 2), entities[id].xy.y + (tileSize / 2));
map[entities[id].tile].render.object = id;
if(prevTile !== entities[id].tile){
map[prevTile].render.object = false;
}
}
//////////////////////////////////////
// THIS IS WHERE I'M HAVING TROUBLE //
//////////////////////////////////////
// A function that can be used by an entity to move along a set path
// id = The id of the entity using this function
// path = An array of strings that determine the direction of movement for a single tile
// originPoint = Coordinates of the previous tile this entity was at. This variable seems to be where problems happen with this logic. It should get reset for every tile length moved, but it only gets reset once currently.
// step = The current index of the path array
function setPath(id, path, originPoint, step){
// Determine if the entity has travelled one tile from the origin
var destX = Math.abs(entities[id].xy.x - originPoint.x);
var destY = Math.abs(entities[id].xy.y - originPoint.y);
if(destX >= tileSize || destY >= tileSize){
// Go to the next step in the path array
step = step + 1;
if(step >= path.length){
step = 0;
}
// Reset the origin to the current tile coordinates
originPoint = entities[id].xy;
}
// Set the direction based on the current index of the path array
switch(path[step]) {
case 'up':
entities[id].dir.up = true;
entities[id].dir.down = false;
entities[id].dir.left = false;
entities[id].dir.right = false;
break;
case 'down':
entities[id].dir.up = false;
entities[id].dir.down = true;
entities[id].dir.left = false;
entities[id].dir.right = false;
break;
case 'left':
entities[id].dir.up = false;
entities[id].dir.down = false;
entities[id].dir.left = true;
entities[id].dir.right = false;
break;
case 'right':
entities[id].dir.up = false;
entities[id].dir.down = false;
entities[id].dir.left = false;
entities[id].dir.right = true;
break;
};
window.requestAnimationFrame(function(){
setPath(id, path, originPoint, step);
});
}
// Take a tile index and return x,y coordinates
function tileToCoords(tile){
var yIndex = Math.floor(tile / mapW);
var xIndex = tile - (yIndex * mapW);
var y = yIndex * tileSize;
var x = xIndex * tileSize;
return {x:x, y:y};
}
// Take x,y coordinates and return a tile index
function coordsToTile(x, y){
var tile = ((Math.floor(y / tileSize)) * mapW) + (Math.floor(x / tileSize));
return tile;
}
// Generate a map array with a blank map and 4 walls
function testMap(){
for(var i = 0; i < (mapH * mapW); ++i){
// Edges
if (
// top
i < mapW ||
// left
(i % mapW) == 0 ||
// right
((i + 1) % mapW) == 0 ||
// bottom
i > ((mapW * mapH) - mapW)
) {
map.push(
{
id: i,
render: {
base: '#D35',
object: false,
sprite: false
},
state: {
passable: false
}
},
);
}
else{
// Grass
map.push(
{
id: i,
render: {
base: '#0C3',
object: false,
sprite: false
},
state: {
passable: true
}
},
);
}
}
}
<!DOCTYPE html>
<html>
<head>
<style>
body{
background-color: #000;
display: flex;
align-items: center;
justify-content: center;
color: #FFF;
font-size: 18px;
padding: 0;
margin: 0;
}
main{
width: 100%;
max-width: 800px;
margin: 10px auto;
display: flex;
align-items: flex-start;
justify-content: center;
flex-wrap: wrap;
}
.game{
width: 1000px;
height: 1000px;
position: relative;
}
canvas{
image-rendering: -moz-crisp-edges;
image-rendering: -webkit-crisp-edges;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.game canvas{
position: absolute;
top: 0;
left: 0;
width: 800px;
height: 800px;
}
</style>
</head>
<body>
<main>
<div class="game">
<canvas id="save" width="200" height="200" style="z-index: 1;"></canvas>
</div>
</main>
</body>
</html>
The problem is with the setPath() function, and more specifically I think it's something with the originPoint variable. The idea is that setPath() moves the object one tile per path string, and originPoint should be the coordinates of the last tile visited (so it should only get updated once the object coordinates are one tile length away from the originPoint). Right now it only gets updated the first time and then stops. Hopefully someone can point out what I got wrong here.
Your condition to change the path direction I change it to have conditions for each direction, something like:
if ((entities[id].dir.left && entities[id].xy.x <= tileSize) ||
(entities[id].dir.right && entities[id].xy.x >= tileSize*8) ||
(entities[id].dir.up && entities[id].xy.y <= tileSize) ||
(entities[id].dir.down && entities[id].xy.y >= tileSize*8)) {
and the originPoint was just a reference you should do:
originPoint = JSON.parse(JSON.stringify(entities[id].xy));
See the working code below
// Canvas Element
var canvas = null;
// Canvas Draw
var ctx = null;
// Static Globals
var tileSize = 16,
mapW = 10,
mapH = 10;
// Instances of entities
var entities = [
// A single entity that starts at tile 28, and uses the setPath() function
{
id: 0,
tile: 28,
xy: tileToCoords(28),
width: 16,
height: 24,
speedX: 0,
speedY: 0,
logic: {
func: 'setPath',
// These are the parameters that go into the setPath() function
data: [0, ['down', 'left', 'down', 'left', 'up', 'left', 'left', 'right', 'up', 'right', 'down','right', "up"], tileToCoords(28), 0]
},
dir: {up:false, down:false, left:false, right:false}
}
];
// Array for tile data
var map = [];
window.onload = function(){
// Populate the map array with a blank map and 4 walls
testMap();
canvas = document.getElementById('save');
ctx = canvas.getContext("2d");
// Add all the entities to the map array and start their behavior
for(var i = 0; i < entities.length; ++i){
map[entities[i].tile].render.object = entities[i].id;
if(entities[i].logic){
window[entities[i].logic.func].apply(null, entities[i].logic.data);
}
}
drawGame(map);
window.requestAnimationFrame(function(){
mainLoop();
});
};
function drawGame(map){
ctx.clearRect(0, 0, canvas.width, canvas.height);
// We save all the entity data for later so the background colors don't get rendered on top
var tileObjData = [];
for(var y = 0; y < mapH; ++y){
for(var x = 0; x < mapW; ++x){
var currentPos = ((y*mapW)+x);
ctx.fillStyle = map[currentPos].render.base;
ctx.fillRect(x*tileSize, y*tileSize, tileSize, tileSize);
var thisObj = map[currentPos].render.object;
if(thisObj !== false){
thisObj = entities[thisObj];
var originX = thisObj.xy.x;
var originY = thisObj.xy.y;
tileObjData.push(
{
id: thisObj.id,
originX: originX,
originY: originY,
width: thisObj.width,
height: thisObj.height,
}
);
}
}
}
// Draw all the entities after the background tiles are drawn
for(var i = 0; i < tileObjData.length; ++i){
drawEntity(tileObjData[i].id, tileObjData[i].originX, tileObjData[i].originY, tileObjData[i].width, tileObjData[i].height);
}
}
// Draws the entity data
function drawEntity(id, posX, posY, sizeX, sizeY){
var offX = posX + entities[id].speedX;
var offY = posY + entities[id].speedY;
ctx.fillStyle = '#00F';
ctx.fillRect(offX, offY + sizeX - sizeY, sizeX, sizeY);
entities[id].xy.x = offX;
entities[id].xy.y = offY;
}
// Redraws the canvas with the browser framerate
function mainLoop(){
drawGame(map);
for(var i = 0; i < entities.length; ++i){
animateMove(i, entities[i].dir.up, entities[i].dir.down, entities[i].dir.left, entities[i].dir.right);
}
window.requestAnimationFrame(function(){
mainLoop();
});
}
// Sets the speed, direction, and collision detection of an entity
function animateMove(id, up, down, left, right){
var prevTile = entities[id].tile;
if(up){
var topLeft = {x: entities[id].xy.x, y: entities[id].xy.y};
var topRight = {x: entities[id].xy.x + entities[id].width - 1, y: entities[id].xy.y};
if(!map[coordsToTile(topLeft.x, topLeft.y - 1)].state.passable || !map[coordsToTile(topRight.x, topRight.y - 1)].state.passable){
entities[id].speedY = 0;
}
else{
entities[id].speedY = -1;
}
}
else if(down){
var bottomLeft = {x: entities[id].xy.x, y: entities[id].xy.y + entities[id].width - 1};
var bottomRight = {x: entities[id].xy.x + entities[id].width - 1, y: entities[id].xy.y + entities[id].width - 1};
if(!map[coordsToTile(bottomLeft.x, bottomLeft.y + 1)].state.passable || !map[coordsToTile(bottomRight.x, bottomRight.y + 1)].state.passable){
entities[id].speedY = 0;
}
else{
entities[id].speedY = 1;
}
}
else{
entities[id].speedY = 0;
}
if(left){
var bottomLeft = {x: entities[id].xy.x, y: entities[id].xy.y + entities[id].width - 1};
var topLeft = {x: entities[id].xy.x, y: entities[id].xy.y};
if(!map[coordsToTile(bottomLeft.x - 1, bottomLeft.y)].state.passable || !map[coordsToTile(topLeft.x - 1, topLeft.y)].state.passable){
entities[id].speedX = 0;
}
else{
entities[id].speedX = -1;
}
}
else if(right){
var bottomRight = {x: entities[id].xy.x + entities[id].width - 1, y: entities[id].xy.y + entities[id].width - 1};
var topRight = {x: entities[id].xy.x + entities[id].width - 1, y: entities[id].xy.y};
if(!map[coordsToTile(bottomRight.x + 1, bottomRight.y)].state.passable || !map[coordsToTile(topRight.x + 1, topRight.y)].state.passable){
entities[id].speedX = 0;
}
else{
entities[id].speedX = 1;
}
}
else{
entities[id].speedX = 0;
}
entities[id].tile = coordsToTile(entities[id].xy.x + (entities[id].width / 2), entities[id].xy.y + (tileSize / 2));
map[entities[id].tile].render.object = id;
if(prevTile !== entities[id].tile){
map[prevTile].render.object = false;
}
}
//////////////////////////////////////
// THIS IS WHERE I'M HAVING TROUBLE //
//////////////////////////////////////
// A function that can be used by an entity to move along a set path
// id = The id of the entity using this function
// path = An array of strings that determine the direction of movement for a single tile
// originPoint = Coordinates of the previous tile this entity was at. This variable seems to be where problems happen with this logic. It should get reset for every tile length moved, but it only gets reset once currently.
// step = The current index of the path array
function setPath(id, path, originPoint, step){
if ((entities[id].dir.left && entities[id].xy.x <= originPoint.x - tileSize) ||
(entities[id].dir.right && entities[id].xy.x >= originPoint.x + tileSize) ||
(entities[id].dir.up && entities[id].xy.y <= originPoint.y - tileSize) ||
(entities[id].dir.down && entities[id].xy.y >= originPoint.y + tileSize)) {
// Go to the next step in the path array
step = step + 1;
if(step >= path.length){
step = 0;
}
// Reset the origin to the current tile coordinates
originPoint = JSON.parse(JSON.stringify(entities[id].xy));
}
// Set the direction based on the current index of the path array
switch(path[step]) {
case 'up':
entities[id].dir.up = true;
entities[id].dir.down = false;
entities[id].dir.left = false
entities[id].dir.right = false;
break;
case 'down':
entities[id].dir.up = false;
entities[id].dir.down = true;
entities[id].dir.left = false;
entities[id].dir.right = false;
break;
case 'left':
entities[id].dir.up = false;
entities[id].dir.down = false;
entities[id].dir.left = true;
entities[id].dir.right = false;
break;
case 'right':
entities[id].dir.up = false;
entities[id].dir.down = false;
entities[id].dir.left = false;
entities[id].dir.right = true;
break;
};
window.requestAnimationFrame(function(){
setPath(id, path, originPoint, step);
});
}
// Take a tile index and return x,y coordinates
function tileToCoords(tile){
var yIndex = Math.floor(tile / mapW);
var xIndex = tile - (yIndex * mapW);
var y = yIndex * tileSize;
var x = xIndex * tileSize;
return {x:x, y:y};
}
// Take x,y coordinates and return a tile index
function coordsToTile(x, y){
var tile = ((Math.floor(y / tileSize)) * mapW) + (Math.floor(x / tileSize));
return tile;
}
// Generate a map array with a blank map and 4 walls
function testMap(){
for(var i = 0; i < (mapH * mapW); ++i){
// Edges
if (
// top
i < mapW ||
// left
(i % mapW) == 0 ||
// right
((i + 1) % mapW) == 0 ||
// bottom
i > ((mapW * mapH) - mapW)
) {
map.push(
{
id: i,
render: {
base: '#D35',
object: false,
sprite: false
},
state: {
passable: false
}
},
);
}
else{
// Grass
map.push(
{
id: i,
render: {
base: '#0C3',
object: false,
sprite: false
},
state: {
passable: true
}
},
);
}
}
}
<!DOCTYPE html>
<html>
<head>
<style>
body{
background-color: #000;
display: flex;
align-items: center;
justify-content: center;
color: #FFF;
font-size: 18px;
padding: 0;
margin: 0;
}
main{
width: 100%;
max-width: 800px;
margin: 10px auto;
display: flex;
align-items: flex-start;
justify-content: center;
flex-wrap: wrap;
}
.game{
width: 1000px;
height: 1000px;
position: relative;
}
canvas{
image-rendering: -moz-crisp-edges;
image-rendering: -webkit-crisp-edges;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.game canvas{
position: absolute;
top: 0;
left: 0;
width: 800px;
height: 800px;
}
</style>
</head>
<body>
<main>
<div class="game">
<canvas id="save" width="200" height="200" style="z-index: 1;"></canvas>
</div>
</main>
</body>
</html>
As someone has already solved your bug...
This is more than a solution to your problem as the real problem you are facing is complexity, long complex if statements using data structures representing the same information in different ways making it difficult to see simple errors in logic.
On top of that you have some poor style habits that compound the problem.
A quick fix will just mean you will be facing the next problem sooner. You need to write in a ways that reduces the chances of logic errors due to increasing complexity
Style
First style. Good style is very important
Don't assign null to declared variables. JavaScript should not need to use null, the exception to the rule is that some C++ coders infected the DOM API with null returns because they did not understand JavaScipt (or was a cruel joke), and now we are stuck with null
window is the default this (global this) and is seldom needed. Eg window.requestAnimationFrame is identical to just requestAnimationFrame and window.onload is identical to onload
Don't pollute your code with inaccurate, redundant and/or obvious comments, use good naming to provide the needed information. eg:
var map[]; has the comment // array of tile data Well really its an array that has data, who would have guessed, so the comment can be // tiles but then map is a rather ambiguous name. Remove the comment and give the variable a better name.
The comment // Static Globals above some vars. Javascript does not have static as a type so the comment is wrong and the "global's" part is "duh..."
Use const to declare constants, move all the magic numbers to the top and define them as named const. A name has meaning, an number in some code has next to no meaning.
Don't assign listener to the event name, it is unreliable and can be hijacked or overwritten. Always use addEventListener to assign an event listener
Be very careful with your naming. eg the function named coordsToTile is confusing as it does not return a tile, it returns a tile index, either change the function name to match the functions behavior, or change the behavior to match the name.
Don't use redundant intermediate functions, examples:
Your frame request requestAnimationFrame(function(){mainLoop()}); should skip the middle man and be requestAnimationFrame(mainLoop);
You use Function.apply to call the function window[entities[i].logic.func].apply(null, entities[i].logic.data);. apply is used to bind context this to the call, you don't use this in the function so you don't need use the apply. eg window[entities[i].logic.func](...entities[i].logic.data);
BTW being forced to use bracket notation to access a global is a sign of poor data structure. You should never do that.
JavaScript has an unofficial idiomatic styles, you should try to write JS in this style. Some examples from your code
else on the same line as closing }
Space after if, else, for, function() and befor else, opening block {
An id and an index are not the same, use idx or index for an index and id for an identifier
Keep it simple
The more complex you make your data structures the harder it is for you to maintain them.
Structured
Define objects to encapsulate and organize your data.
A global config object, that is transprotable ie can converted be to and from JSON. it contains all the magic numbers, defaults, type descriptions, and what not needed in the game.
Create a set of global utilities that do common repeated tasks, ie create coordinates, list of directions.
Define object that encapsulate the settings and behaviors specific only to that object.
Use polymorphic object design, meaning that different objects use named common behaviors and properties. In the example all drawable object have a function called draw that takes an argument ctx, all objects that can be updated have a function called update
Example
This example is a complete rewrite of your code and fixing your problem. It may be a little advanced, but it is only an example to look though an pick up some tips.
A quick description of the objects used.
Objects
config is transportable config data
testMap is an example map description
tileMap does map related stuff
Path Object encapsulating path logic
Entity Object a single moving entity
Tile Object representing a single tile
game The game state manager
Games have states, eg loading, intro, inPlay, gameOver etc. If you do not plan ahead and create a robust state manager you will find it very difficult to move from one state to the next
I have included the core of a finite state manager. The state manager is responsible for updating and rendering. it is also responsible for all state changes.
setTimeout(() => game.state = "setup", 0); // Will start the game
const canvas = document.getElementById('save');
const ctx = canvas.getContext("2d");
const point = (x = 0, y = 0) => ({x,y});
const dirs = Object.assign(
[point(0, -1), point(1), point(0,1), point(-1)], { // up, right, down, left
"u": 0, // defines index for direction string characters
"r": 1,
"d": 2,
"l": 3,
strToDirIdxs(str) { return str.toLowerCase().split("").map(char => dirs[char]) },
}
);
const config = {
pathIdx: 28,
pathTypes: {
standard: "dulr",
complex: "dulrldudlruldrdlrurdlurd",
},
tile: {size: 16},
defaultTileName: "grass",
entityTypes: {
e: {
speed: 1 / 32, // in fractions of a tile per frame
color: "#00F",
size: {x:16, y:24},
pathName: "standard",
},
f: {
speed: 1 / 16, // in fractions of a tile per frame
color: "#08F",
size: {x:18, y:18},
pathName: "complex",
},
},
tileTypes: {
grass: {
style: {baseColor: "#0C3", object: false, sprite: false},
state: {passable: true}
},
wall: {
style: {baseColor: "#D35", object: false, sprite: false},
state: {passable: false}
},
},
}
const testMap = {
displayChars: {
" " : "grass", // what characters mean
"#" : "wall",
"E" : "grass", // also entity spawn
"F" : "grass", // also entity spawn
},
special: { // spawn enties and what not
"E"(idx) { entities.push(new Entity(config.entityTypes.e, idx)) },
"F"(idx) { entities.push(new Entity(config.entityTypes.f, idx)) }
},
map: // I double the width and ignor every second characters as text editors tend to make chars thinner than high
// 0_1_2_3_4_5_6_7_8_9_ x coord
"####################\n" +
"##FF ## ##\n" +
"## ## ##\n" +
"## #### ##\n" +
"## ##\n" +
"## #### ##\n" +
"## ##\n" +
"## ##\n" +
"## EE##\n" +
"####################",
// 0_1_2_3_4_5_6_7_8_9_ x coord
}
const entities = Object.assign([],{
update() {
for (const entity of entities) { entity.update() }
},
draw(ctx) {
for (const entity of entities) { entity.draw(ctx) }
},
});
const tileMap = {
map: [],
mapToIndex(x, y) { return x + y * tileMap.width },
pxToIndex(x, y) { return x / config.tile.size | 0 + (y / config.tile.size | 0) * tileMap.width },
tileByIdx(idx) { return tileMap.map[idx] },
tileByIdxDir(idx, dir) { return tileMap.map[idx + dir.x + dir.y * tileMap.width] },
idxByDir(dir) { return dir.x + dir.y * tileMap.width },
create(mapConfig) {
tileMap.length = 0;
const rows = mapConfig.map.split("\n");
tileMap.width = rows[0].length / 2 | 0;
tileMap.height = rows.length;
canvas.width = tileMap.width * config.tile.size;
canvas.height = tileMap.height * config.tile.size;
var x, y = 0;
while (y < tileMap.height) {
const row = rows[y];
for (x = 0; x < tileMap.width; x += 1) {
const char = row[x * 2];
tileMap.map.push(new Tile(mapConfig.displayChars[char], x, y));
if (mapConfig.special[char]) {
mapConfig.special[char](tileMap.mapToIndex(x, y));
}
}
y++;
}
},
update () {}, // stub
draw(ctx) {
for (const tile of tileMap.map) { tile.draw(ctx) }
},
};
function Tile(typeName, x, y) {
typeName = config.tileTypes[typeName] ? typeName : config.defaultTileName;
const t = config.tileTypes[typeName];
this.idx = x + y * tileMap.width;
this.coord = point(x * config.tile.size, y * config.tile.size);
this.style = {...t.style};
this.state = {...t.state};
}
Tile.prototype = {
draw(ctx) {
ctx.fillStyle = this.style.baseColor;
ctx.fillRect(this.coord.x, this.coord.y, config.tile.size, config.tile.size);
}
};
function Path(pathName) {
if (typeof config.pathTypes[pathName] === "string") {
config.pathTypes[pathName] = dirs.strToDirIdxs(config.pathTypes[pathName]);
}
this.indexes = config.pathTypes[pathName];
this.current = -1;
}
Path.prototype = {
nextDir(tileIdx) {
var len = this.indexes.length;
while (len--) { // make sure we dont loop forever
const dirIdx = this.indexes[this.current];
if (dirIdx > - 1) {
const canMove = tileMap.tileByIdxDir(tileIdx, dirs[dirIdx]).state.passable;
if (canMove) { return dirs[dirIdx] }
}
this.current = (this.current + 1) % this.indexes.length;
}
}
};
function Entity(type, tileIdx) {
this.coord = point();
this.move = point();
this.color = type.color;
this.speed = type.speed;
this.size = {...type.size};
this.path = new Path(type.pathName);
this.pos = this.nextTileIdx = tileIdx;
this.traveled = 1; // unit dist between tiles 1 forces update to find next direction
}
Entity.prototype = {
set dir(dir) {
if (dir === undefined) { // dont move
this.move.y = this.move.x = 0;
this.nextTileIdx = this.tileIdx;
} else {
this.move.x = dir.x * config.tile.size;
this.move.y = dir.y * config.tile.size;
this.nextTileIdx = this.tileIdx + tileMap.idxByDir(dir);
}
},
set pos(tileIdx) {
this.tileIdx = tileIdx;
const tile = tileMap.map[tileIdx];
this.coord.x = tile.coord.x + config.tile.size / 2;
this.coord.y = tile.coord.y + config.tile.size / 2;
this.traveled = 0;
},
draw(ctx) {
const ox = this.move.x * this.traveled;
const oy = this.move.y * this.traveled;
ctx.fillStyle = this.color;
ctx.fillRect(ox + this.coord.x - this.size.x / 2, oy + this.coord.y - this.size.y / 2, this.size.x, this.size.y)
},
update(){
this.traveled += this.speed;
if (this.traveled >= 1) {
this.pos = this.nextTileIdx;
this.dir = this.path.nextDir(this.tileIdx);
}
}
};
const game = {
currentStateName: undefined,
currentState: undefined,
set state(str) {
if (game.states[str]) {
if (game.currentState && game.currentState.end) { game.currentState.end() }
game.currentStateName = str;
game.currentState = game.states[str];
if (game.currentState.start) { game.currentState.start() }
}
},
states: {
setup: {
start() {
tileMap.create(testMap);
game.state = "play";
},
end() {
requestAnimationFrame(game.render); // start the render loop
delete game.states.setup; // MAKE SURE THIS STATE never happens again
},
},
play: {
render(ctx) {
tileMap.update();
entities.update();
tileMap.draw(ctx);
entities.draw(ctx);
}
}
},
renderTo: ctx,
startTime: undefined,
time: 0,
render(time) {
if (game.startTime === undefined) { game.startTime = time }
game.time = time - game.startTime;
if (game.currentState && game.currentState.render) { game.currentState.render(game.renderTo) }
requestAnimationFrame(game.render);
}
};
body{
background-color: #000;
}
canvas{
image-rendering: pixelated;
position: absolute;
top: 0;
left: 0;
width: 400px;
height: 400px;
}
<canvas id="save" width="200" height="200" style="z-index: 1;"></canvas>
Please Note that there are some running states that have not been tested and as such may have a typo.
Also the tile map must be walled to contain entities or they will throw when they try to leave the playfield.
The code is designed to run in the snippet. To make it work in a standard page add above the very first line setTimeout(() => game.state = "setup", 0); the line addEventListener(load", () = { and after the very last line add the line });

finding coordinates of center of rectangle svg

This is the troublesome code find coordinates of the middle of a square svg
The code snippet below doesn't work here. You might want to use another editor or check the lin above for easy access.
$(function() {
var ss = {
"y": 40,
"x": 4,
"n": 3, // Speed
"xD": 0,
"yD": 0,
"rotation": 0,
/*"cx":, This is where I want my coords to change
"cy":,*/
};
var move;
var bbox = document.getElementById("block_green").getBBox();
var ctm = document.getElementById("block_green").getCTM()
var cx = bbox.x + bbox.width/2;
var cy = bbox.y + bbox.height/2;
var pt = document.getElementById("svg").createSVGPoint();
pt.x = cx;
pt.y = cy;
pt = pt.matrixTransform(ctm);
setInterval(move, .01);
setInterval(alert(pt.x + ", " pt.y), 20000);
function move() {
ss.x = ss.x + (ss.xD * ss.n);
ss.y = ss.y + (ss.yD * ss.n);
$("#block_green").attr({
y: ss.y,
x: ss.x
}).css({
"-webkit-transform" : "rotate("+ ss.rotation +"deg)",
"-moz-transform": "rotate(" + ss.rotation + "deg)",
"-ms-transform": "rotate(" + ss.rotation + "deg)",
"transform": "rotate(" + ss.rotation + "deg)"
});
}
$(document).keydown(function(e) {
ss.rotation = e.which == 37 ? ss.rotation -2 : ss.rotation;
ss.rotation = e.which == 39 ? ss.rotation +2 : ss.rotation
ss.yD = e.which == 38 ? -1 : ss.yD;
ss.yD = e.which == 40 ? 1 : ss.yD;
ss.xD = e.which == 69 ? 1 : ss.xD;
ss.xD = e.which == 81 ? -1 : ss.xD;
e.preventDefault();
}).keyup(function(e) {
ss.yD = e.which == 38 ? 0 : ss.yD;
ss.yD = e.which == 40 ? 0 : ss.yD;
ss.xD = e.which == 69 ? 0 : ss.xD;
ss.xD = e.which == 81 ? 0 : ss.xD;
e.preventDefault();
});
});
body {
margin: 0;
overflow: hidden;
}
svg {
background-color: black;
width: 100vw;
height: 100vh;
z-index: 1;
}
#block_green {
fill: black;
stroke: #00ff00;
stroke-width: .5px;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<svg id="svg">
<rect x="4" y="4" width="80" height="60" id="block_green"/>
</svg>
This code should alert every 20 seconds, the coordinates of the square. In order to get the coordinates of the center of the square, I've tryied this question:
How to get Mid point of <g> tag in svg using javascript
If you are using codepen, you'll find the proble in lines 9-16 & 18.
First of all, there is a syntax error in your code which will prevent its execution:
setInterval(alert(pt.x + ", " pt.y), 20000);
needs to be
setInterval(alert(pt.x + ", " + pt.y), 20000);
But that's not the major point here. The way you are trying to set up your interval will not work as expected. It will not repeatedly call alert() but instead evaluate the expression alert(pt.x + ", " + pt.y) once when you call setInterval(). For this reason you will see one initial alert popup printing the starting values for pt. The expression will eventually evaluate to undefined which will be taken as the first argument to setInterval(), i.e. the function to call repeatedly is referred to as undefined, which obviously is not what you intended in the first place.
You need to wrap the calculations of the rect's center in a function if you want to have it alert the updated values on repeated calls. You are then able to pass the reference to a function, not to undefined to setInterval(). Check my JSFiddle for the way it might work for you:
function getCenter() {
var bbox = document.getElementById("block_green").getBBox();
var ctm = document.getElementById("block_green").getCTM()
var cx = bbox.x + bbox.width/2;
var cy = bbox.y + bbox.height/2;
var pt = document.getElementById("svg").createSVGPoint();
pt.x = cx;
pt.y = cy;
return pt.matrixTransform(ctm);
}
setInterval(function() {
var pt = getCenter();
alert(pt.x + ", " + pt.y);
}, 20000);
You can do that by applying the transformation of the element to its center. This way is a general approach to obtain absolute coordinates from a local space. The idea is: Compute the center of the rectangle in local coordinates, by using the attribute values directly. Afterwards one applies the transformation of the svg element to that point.
var svg = document.getElementById('root'),
rect = document.getElementById('block_green'),
x = rect.x.baseVal.value,
y = rect.x.baseVal.value,
w = rect.width.baseVal.value,
h = rect.height.baseVal.value,
c = svg.createSVGPoint(),
mtr = rect.getTransformToElement(svg);
c.x = x + w/2;
c.y = y + h/2;
c = c.matrixTransform(mtr);
FIDDLE

Zoom on selected svg element using javascript

i am working on an application which require Zoom In, Zoom out and Panning. I have achieved all these functionality using viewBox property of svg.
My current Zoom In works fine but it zoom toward the center of screen. I want to add additional functionality of Zoom in toward a selected element. I know i can set viewBox to the selected element bbox values, but i want sequential/smooth zoom in which does not conflict with my current/default zoom in.
how i can achieve this?
here is jsfiddle for the sample code:-
http://jsfiddle.net/55G9c/
HTML Code
<div onclick="zoomin()" style="display: block;float: left;border: 1px solid;cursor: pointer">
ZoomIn
</div>
<div onclick="zoomout()" style="display: block;float: left;border: 1px solid;cursor: pointer;margin-left: 10px">
ZoomOut
</div>
<svg id="mainsvg" width="600px" height="500px" viewBox="0 0 600 500">
<g id="gnode">
<rect id="boundry" x="0" y="0" width="599" height="499" fill="none" stroke='black'/>
<circle id="centernode" cx="300" cy="250" r="5" fill="red" stroke="none" />
<rect id="selected" x="450" y="100" width="50" height="50" fill="blue" stroke='none'/>
</g>
</svg>
Javascript Code
var svg=document.getElementById('mainsvg');
var gnode=document.getElementById('gnode');
var zoomPercentage=0.25;
var MAXIMUM_ZOOM_HEIGHT = 1400;
var baseBox={};
var level=0;
var widthRatio,heightRatio;
var clientheight = document.documentElement.clientHeight;
var clientwidth = document.documentElement.clientWidth;
function setup(){
var
baseX,
baseY,
baseWidth,
baseHeight,
percentageDifference,
heightDifference;
svg.setAttribute('height', clientheight);
svg.setAttribute('width', clientwidth);
var boundry=document.getElementById('boundry');
boundry.setAttribute('height', clientheight-1);
boundry.setAttribute('width', clientwidth-1);
var centernode=document.getElementById('centernode');
centernode.setAttribute('cy', clientheight/2);
centernode.setAttribute('cx', clientwidth/2);
if (svg.height.baseVal.value >= MAXIMUM_ZOOM_HEIGHT)
baseHeight = MAXIMUM_ZOOM_HEIGHT;
else
baseHeight = Math.round(gnode.getBBox().height) + 60;
baseY = (svg.height.baseVal.value - baseHeight) / 2;
percentageDifference = baseHeight / svg.height.baseVal.value;
baseWidth = percentageDifference * svg.width.baseVal.value;
baseX = (svg.width.baseVal.value - baseWidth) / 2;
baseBox.x = baseX;
baseBox.y = baseY;
baseBox.width = baseWidth;
baseBox.height = baseHeight;
level = 0;
heightDifference = MAXIMUM_ZOOM_HEIGHT - baseHeight;
zoomPercentage = (heightDifference / 10) / heightDifference;
setViewBox(baseBox);
}
function setViewBox(viewBox) {
svg.viewBox.baseVal.x = Math.round(viewBox.x);
svg.viewBox.baseVal.y = Math.round(viewBox.y);
svg.viewBox.baseVal.width = Math.round(viewBox.width);
svg.viewBox.baseVal.height = Math.round(viewBox.height);
setRatios();
}
function setRatios () {
widthRatio = svg.viewBox.baseVal.width / svg.width.baseVal.value;
heightRatio = svg.viewBox.baseVal.height / svg.height.baseVal.value;
}
function calculateViewBox(level) {
var
height = baseBox.height - (zoomPercentage * level * baseBox.height),
y = baseBox.y + (baseBox.height - height) / 2,
width = baseBox.width - (zoomPercentage * level * baseBox.width),
x = baseBox.x + (baseBox.width - width) / 2,
viewBox = {
x: x,
y: y,
width: width,
height: height
}
return viewBox;
}
function zoomin(){
level++;
if(level>5)
level=5;
var
x,
y,
paperViewBox = svg.viewBox.baseVal,
previousViewBox = calculateViewBox(level - 1),
newViewBox = calculateViewBox(level);
//callback = this.afterZoom;
if (Math.round(paperViewBox.x) > Math.round(newViewBox.x))
/**
* is panned left
*/
x = paperViewBox.x - (previousViewBox.width - newViewBox.width) / 2;
else if (Math.round(paperViewBox.x) < Math.round(previousViewBox.x) - (Math.round(newViewBox.x) - Math.round(previousViewBox.x)))
/**
* is panned right
*/
x = paperViewBox.x + (previousViewBox.width - newViewBox.width) + (previousViewBox.width - newViewBox.width) / 2;
else
x = newViewBox.x;
if (Math.round(paperViewBox.y) > Math.round(newViewBox.y))
/**
* is panned up
*/
y = paperViewBox.y - (previousViewBox.height - newViewBox.height) / 2;
else if (Math.round(paperViewBox.y) < Math.round(previousViewBox.y) - (Math.round(newViewBox.y) - Math.round(previousViewBox.y)))
/**
* is panned down
*/
y = paperViewBox.y + (previousViewBox.height - newViewBox.height) + (previousViewBox.height - newViewBox.height) / 2;
else
y = newViewBox.y;
var data = {
viewBox: {
x: x,
y: y,
width: newViewBox.width,
height: newViewBox.height
}
}
SetZoomViewBox(data);
}
function SetZoomViewBox(data){
var viewBox = data.viewBox;
svg.viewBox.baseVal.x = Math.round(viewBox.x);
svg.viewBox.baseVal.y = Math.round(viewBox.y);
svg.viewBox.baseVal.width = Math.round(viewBox.width);
svg.viewBox.baseVal.height = Math.round(viewBox.height);
setRatios();
}
function zoomout(){
level--;
if(level<0)
level=0;
var
x,
y,
paperViewBox = svg.viewBox.baseVal,
previousViewBox = calculateViewBox(level + 1),
newViewBox = calculateViewBox(level);
if (Math.round(paperViewBox.x) > Math.round(previousViewBox.x) + (Math.round(previousViewBox.x) - Math.round(newViewBox.x)))
/**
* is panned left
*/
x = paperViewBox.x - (newViewBox.width - previousViewBox.width);
else if (Math.round(paperViewBox.x) < Math.round(previousViewBox.x))
/**
* is panned right
*/
x = paperViewBox.x;
else
x = newViewBox.x;
if (Math.round(paperViewBox.y) > Math.round(previousViewBox.y) + (Math.round(previousViewBox.y) - Math.round(newViewBox.y)))
/**
* is panned up
*/
y = paperViewBox.y - (newViewBox.height - previousViewBox.height);
else if (Math.round(paperViewBox.y) < Math.round(previousViewBox.y))
/**
* is panned down
*/
y = paperViewBox.y;
else
y = newViewBox.y;
var data = {
viewBox: {
x: x,
y: y,
width: newViewBox.width,
height: newViewBox.height
}
}
SetZoomViewBox(data);
}
setup();
Here's an example that shows an SVG file with a zoom/pan control.
It sets the currentScale and currentTranslate attributes on the SVG root element to perform zooming/panning.
try running on firefox. click the center green circle to zoom out and red circle to zoom in. Probably you'll get some idea.

Snap edges of objects to each other and prevent overlap

My goal is to prevent overlapping of two or more rectangles inside my FabricJS canvas.
Imagine two rectangles having info on position and size and you can drag and drop any rectangle inside the canvas.
If rectangle A gets close enough to rectangle B, the position of rectangle A should snap to the edge of rectangle B. This should work for any edge of rectangle B. The vertices do not have to match, cause the sizes of the rectangles are variable.
I have a working example for this snapping on one dimension (x-axes).
My best jsfiddle attempt
See jsfiddle.
But I need it to work around the rectangle on both dimensions. I am quite sure, that my code is not well enough to manage this.
Code-snippets which might help:
object.oCoords.tl.x //top-left corner x position. similar goes for top-right (tr), bottom-left (bl), bottom-right (br) and .y for y-position
mouse_pos = canvas.getPointer(e.e);
mouse_pos.x //pointer x.position
mouse_pos.y //pointer y.position
object.intersectsWithObject(targ) // object = dragged rectangle, targ = targeted rectangle
The snapping should work for an unlimited amount of objects (not only for two rectangles).
I solved the problem on my own.
See jsfiddle: http://jsfiddle.net/gcollect/FD53A/
This is the code:
this.canvas.on('object:moving', function (e) {
var obj = e.target;
obj.setCoords(); //Sets corner position coordinates based on current angle, width and height
canvas.forEachObject(function (targ) {
var objects = this.canvas.getObjects(),
i = objects.length;
activeObject = canvas.getActiveObject();
if (targ === activeObject) return;
if (Math.abs(activeObject.oCoords.tr.x - targ.oCoords.tl.x) < edgedetection) {
activeObject.left = targ.left - activeObject.currentWidth;
}
if (Math.abs(activeObject.oCoords.tl.x - targ.oCoords.tr.x) < edgedetection) {
activeObject.left = targ.left + targ.currentWidth;
}
if (Math.abs(activeObject.oCoords.br.y - targ.oCoords.tr.y) < edgedetection) {
activeObject.top = targ.top - activeObject.currentHeight;
}
if (Math.abs(targ.oCoords.br.y - activeObject.oCoords.tr.y) < edgedetection) {
activeObject.top = targ.top + targ.currentHeight;
}
if (activeObject.intersectsWithObject(targ) && targ.intersectsWithObject(activeObject)) {
targ.strokeWidth = 10;
targ.stroke = 'red';
} else {
targ.strokeWidth = 0;
targ.stroke = false;
}
if (!activeObject.intersectsWithObject(targ)) {
activeObject.strokeWidth = 0;
activeObject.stroke = false;
}
});
Works pretty legit! Cheers!
This is based on gco's answer, updated to work with FabricJS 1.5.0, with the following improvements:
Shapes don't overlap.
Snapping is more responsive.
Shapes are contained within the canvas.
JS Fiddle: https://jsfiddle.net/aphillips8/31qbr0vn/1/
var canvas = new fabric.Canvas('canvas'),
canvasWidth = document.getElementById('canvas').width,
canvasHeight = document.getElementById('canvas').height,
counter = 0,
rectLeft = 0,
snap = 20; //Pixels to snap
canvas.selection = false;
plusrect();
plusrect();
plusrect();
function plusrect(top, left, width, height, fill) {
var rect = new fabric.Rect({
top: 300,
name: 'rectangle ' + counter,
left: 0 + rectLeft,
width: 100,
height: 100,
fill: 'rgba(' + (Math.floor(Math.random() * 256)) + ',' + (Math.floor(Math.random() * 256)) + ',' + (Math.floor(Math.random() * 256)) + ', 0.75)',
lockRotation: true,
originX: 'left',
originY: 'top',
cornerSize: 15,
hasRotatingPoint: false,
perPixelTargetFind: true,
minScaleLimit: 1,
maxWidth: canvasWidth,
maxHeight: canvasHeight
});
rect.custom = {};
rect.custom.counter = counter;
canvas.add(rect);
counter++;
rectLeft += 200;
}
function findNewPos(distX, distY, target, obj) {
// See whether to focus on X or Y axis
if(Math.abs(distX) > Math.abs(distY)) {
if (distX > 0) {
target.setLeft(obj.getLeft() - target.getWidth());
} else {
target.setLeft(obj.getLeft() + obj.getWidth());
}
} else {
if (distY > 0) {
target.setTop(obj.getTop() - target.getHeight());
} else {
target.setTop(obj.getTop() + obj.getHeight());
}
}
}
canvas.on('object:moving', function (options) {
// Sets corner position coordinates based on current angle, width and height
options.target.setCoords();
// Don't allow objects off the canvas
if(options.target.getLeft() < snap) {
options.target.setLeft(0);
}
if(options.target.getTop() < snap) {
options.target.setTop(0);
}
if((options.target.getWidth() + options.target.getLeft()) > (canvasWidth - snap)) {
options.target.setLeft(canvasWidth - options.target.getWidth());
}
if((options.target.getHeight() + options.target.getTop()) > (canvasHeight - snap)) {
options.target.setTop(canvasHeight - options.target.getHeight());
}
// Loop through objects
canvas.forEachObject(function (obj) {
if (obj === options.target) return;
// If objects intersect
if (options.target.isContainedWithinObject(obj) || options.target.intersectsWithObject(obj) || obj.isContainedWithinObject(options.target)) {
var distX = ((obj.getLeft() + obj.getWidth()) / 2) - ((options.target.getLeft() + options.target.getWidth()) / 2);
var distY = ((obj.getTop() + obj.getHeight()) / 2) - ((options.target.getTop() + options.target.getHeight()) / 2);
// Set new position
findNewPos(distX, distY, options.target, obj);
}
// Snap objects to each other horizontally
// If bottom points are on same Y axis
if(Math.abs((options.target.getTop() + options.target.getHeight()) - (obj.getTop() + obj.getHeight())) < snap) {
// Snap target BL to object BR
if(Math.abs(options.target.getLeft() - (obj.getLeft() + obj.getWidth())) < snap) {
options.target.setLeft(obj.getLeft() + obj.getWidth());
options.target.setTop(obj.getTop() + obj.getHeight() - options.target.getHeight());
}
// Snap target BR to object BL
if(Math.abs((options.target.getLeft() + options.target.getWidth()) - obj.getLeft()) < snap) {
options.target.setLeft(obj.getLeft() - options.target.getWidth());
options.target.setTop(obj.getTop() + obj.getHeight() - options.target.getHeight());
}
}
// If top points are on same Y axis
if(Math.abs(options.target.getTop() - obj.getTop()) < snap) {
// Snap target TL to object TR
if(Math.abs(options.target.getLeft() - (obj.getLeft() + obj.getWidth())) < snap) {
options.target.setLeft(obj.getLeft() + obj.getWidth());
options.target.setTop(obj.getTop());
}
// Snap target TR to object TL
if(Math.abs((options.target.getLeft() + options.target.getWidth()) - obj.getLeft()) < snap) {
options.target.setLeft(obj.getLeft() - options.target.getWidth());
options.target.setTop(obj.getTop());
}
}
// Snap objects to each other vertically
// If right points are on same X axis
if(Math.abs((options.target.getLeft() + options.target.getWidth()) - (obj.getLeft() + obj.getWidth())) < snap) {
// Snap target TR to object BR
if(Math.abs(options.target.getTop() - (obj.getTop() + obj.getHeight())) < snap) {
options.target.setLeft(obj.getLeft() + obj.getWidth() - options.target.getWidth());
options.target.setTop(obj.getTop() + obj.getHeight());
}
// Snap target BR to object TR
if(Math.abs((options.target.getTop() + options.target.getHeight()) - obj.getTop()) < snap) {
options.target.setLeft(obj.getLeft() + obj.getWidth() - options.target.getWidth());
options.target.setTop(obj.getTop() - options.target.getHeight());
}
}
// If left points are on same X axis
if(Math.abs(options.target.getLeft() - obj.getLeft()) < snap) {
// Snap target TL to object BL
if(Math.abs(options.target.getTop() - (obj.getTop() + obj.getHeight())) < snap) {
options.target.setLeft(obj.getLeft());
options.target.setTop(obj.getTop() + obj.getHeight());
}
// Snap target BL to object TL
if(Math.abs((options.target.getTop() + options.target.getHeight()) - obj.getTop()) < snap) {
options.target.setLeft(obj.getLeft());
options.target.setTop(obj.getTop() - options.target.getHeight());
}
}
});
options.target.setCoords();
// If objects still overlap
var outerAreaLeft = null,
outerAreaTop = null,
outerAreaRight = null,
outerAreaBottom = null;
canvas.forEachObject(function (obj) {
if (obj === options.target) return;
if (options.target.isContainedWithinObject(obj) || options.target.intersectsWithObject(obj) || obj.isContainedWithinObject(options.target)) {
var intersectLeft = null,
intersectTop = null,
intersectWidth = null,
intersectHeight = null,
intersectSize = null,
targetLeft = options.target.getLeft(),
targetRight = targetLeft + options.target.getWidth(),
targetTop = options.target.getTop(),
targetBottom = targetTop + options.target.getHeight(),
objectLeft = obj.getLeft(),
objectRight = objectLeft + obj.getWidth(),
objectTop = obj.getTop(),
objectBottom = objectTop + obj.getHeight();
// Find intersect information for X axis
if(targetLeft >= objectLeft && targetLeft <= objectRight) {
intersectLeft = targetLeft;
intersectWidth = obj.getWidth() - (intersectLeft - objectLeft);
} else if(objectLeft >= targetLeft && objectLeft <= targetRight) {
intersectLeft = objectLeft;
intersectWidth = options.target.getWidth() - (intersectLeft - targetLeft);
}
// Find intersect information for Y axis
if(targetTop >= objectTop && targetTop <= objectBottom) {
intersectTop = targetTop;
intersectHeight = obj.getHeight() - (intersectTop - objectTop);
} else if(objectTop >= targetTop && objectTop <= targetBottom) {
intersectTop = objectTop;
intersectHeight = options.target.getHeight() - (intersectTop - targetTop);
}
// Find intersect size (this will be 0 if objects are touching but not overlapping)
if(intersectWidth > 0 && intersectHeight > 0) {
intersectSize = intersectWidth * intersectHeight;
}
// Set outer snapping area
if(obj.getLeft() < outerAreaLeft || outerAreaLeft == null) {
outerAreaLeft = obj.getLeft();
}
if(obj.getTop() < outerAreaTop || outerAreaTop == null) {
outerAreaTop = obj.getTop();
}
if((obj.getLeft() + obj.getWidth()) > outerAreaRight || outerAreaRight == null) {
outerAreaRight = obj.getLeft() + obj.getWidth();
}
if((obj.getTop() + obj.getHeight()) > outerAreaBottom || outerAreaBottom == null) {
outerAreaBottom = obj.getTop() + obj.getHeight();
}
// If objects are intersecting, reposition outside all shapes which touch
if(intersectSize) {
var distX = (outerAreaRight / 2) - ((options.target.getLeft() + options.target.getWidth()) / 2);
var distY = (outerAreaBottom / 2) - ((options.target.getTop() + options.target.getHeight()) / 2);
// Set new position
findNewPos(distX, distY, options.target, obj);
}
}
});
});
I based this fiddle off #Anna Phillips' and #gco's examples. It includes:
Corner snapping
Edge snapping
Objects can overlap
Objects are fully contained within the canvas
Objects cannot have a size larger than the canvas area
Here is the code:
window.canvas = new fabric.Canvas('fabriccanvas');
window.counter = 0;
var newleft = 0,
edgedetection = 20, //pixels to snap
canvasWidth = document.getElementById('fabriccanvas').width,
canvasHeight = document.getElementById('fabriccanvas').height;
canvas.selection = false;
plusrect();
plusrect();
plusrect();
function plusrect(top, left, width, height, fill) {
window.canvas.add(new fabric.Rect({
top: 300,
name: 'rectangle ' + window.counter,
left: 0 + newleft,
width: 100,
height: 100,
fill: 'rgba(' + (Math.floor(Math.random() * 256)) + ',' + (Math.floor(Math.random() * 256)) + ',' + (Math.floor(Math.random() * 256)) + ', 0.75)',
lockRotation: true,
originX: 'left',
originY: 'top',
cornerSize: 15,
hasRotatingPoint: false,
perPixelTargetFind: true,
minScaleLimit: 1,
maxHeight: document.getElementById("fabriccanvas").height,
maxWidth: document.getElementById("fabriccanvas").width,
}));
window.counter++;
newleft += 200;
}
this.canvas.on('object:moving', function (e) {
var obj = e.target;
obj.setCoords(); //Sets corner position coordinates based on current angle, width and height
if(obj.getLeft() < edgedetection) {
obj.setLeft(0);
}
if(obj.getTop() < edgedetection) {
obj.setTop(0);
}
if((obj.getWidth() + obj.getLeft()) > (canvasWidth - edgedetection)) {
obj.setLeft(canvasWidth - obj.getWidth());
}
if((obj.getHeight() + obj.getTop()) > (canvasHeight - edgedetection)) {
obj.setTop(canvasHeight - obj.getHeight());
}
canvas.forEachObject(function (targ) {
activeObject = canvas.getActiveObject();
if (targ === activeObject) return;
if (Math.abs(activeObject.oCoords.tr.x - targ.oCoords.tl.x) < edgedetection) {
activeObject.left = targ.left - activeObject.currentWidth;
}
if (Math.abs(activeObject.oCoords.tl.x - targ.oCoords.tr.x) < edgedetection) {
activeObject.left = targ.left + targ.currentWidth;
}
if (Math.abs(activeObject.oCoords.br.y - targ.oCoords.tr.y) < edgedetection) {
activeObject.top = targ.top - activeObject.currentHeight;
}
if (Math.abs(targ.oCoords.br.y - activeObject.oCoords.tr.y) < edgedetection) {
activeObject.top = targ.top + targ.currentHeight;
}
if (activeObject.intersectsWithObject(targ) && targ.intersectsWithObject(activeObject)) {
targ.strokeWidth = 10;
targ.stroke = 'red';
} else {
targ.strokeWidth = 0;
targ.stroke = false;
}
if (!activeObject.intersectsWithObject(targ)) {
activeObject.strokeWidth = 0;
activeObject.stroke = false;
}
});
});
What I'd like to know is if it's possible to extend this to add the following features:
Dynamic snapping. Continuing to drag an object after the initial snap will temporarily disable snapping until the object stops moving. For example, if I drag one box next to another, they will snap together once they are within range. However if I continue moving the first box, I can "drop" it in a position where it is within the snapping range but not aligned to the other box.
Show guide lines when selected object is within range of another object. Currently we add a border around the target object, but it would be better to show guidelines that extend outwards (possibly to the edge of the canvas) to more easily visualize the bounds of the target object.
Parallel snapping. When moving an object that is already snapped to the target object, the selected object should snap to the target object in such a way that the tops, bottoms, or sides of both objects are parallel. For example, assume that the selected square is snapped to the left of the target square and that the top of the selected square is below the top of the target square. Moving the selected square up should cause its top to snap into alignment with the top of the target once in range. The same logic should apply when moving it down, or if the selected object is above/below the target and being moves horizontally.
I needed snapping of unequal sized areas. jsfiddle
var canvas = new fabric.Canvas('c');
canvas.setDimensions({width:window.innerWidth});
var edge_detection_external = 21;
var corner_detection = 5;
canvas.selection = false;
canvas.on('object:moving', function (e) {
var obj = e.target;
obj.setCoords();
function update_position(obj){
return function(targ){
if(targ === obj) return;
// Check overlap case https://www.geeksforgeeks.org/find-two-rectangles-overlap/
if(!(function(targ,obj){
if(obj.aCoords.tl.x > targ.aCoords.br.x || targ.aCoords.tl.x > obj.aCoords.br.x)
return false;
if(targ.aCoords.tl.y > obj.aCoords.br.y || obj.aCoords.tl.y > targ.aCoords.br.y)
return false;
return true;
})(targ,obj)){
// is on RIGHT or LEFT?
if((obj.top > targ.top && obj.top < targ.top + targ.height)
|| (targ.top > obj.top && targ.top < obj.top + obj.height)){
// Object is to the RIGHT and Edge detection
if(obj.aCoords.tl.x > targ.aCoords.br.x
&& obj.aCoords.tl.x - targ.aCoords.br.x < edge_detection_external){
obj.set({left:targ.aCoords.br.x});
// Corner detection
obj.setCoords();
if(Math.abs(targ.aCoords.tr.y - obj.aCoords.tl.y) < corner_detection)
obj.set({top:targ.top});
else if(Math.abs(targ.aCoords.br.y - obj.aCoords.bl.y) < corner_detection)
obj.set({top:targ.top + targ.height - obj.height});
}
// LEFT
if(targ.aCoords.tl.x > obj.aCoords.br.x
&& targ.aCoords.tl.x - obj.aCoords.br.x < edge_detection_external){
obj.set({left:targ.aCoords.tl.x - obj.width});
obj.setCoords();
if(Math.abs(targ.aCoords.tl.y - obj.aCoords.tr.y) < corner_detection)
obj.set({top:targ.top});
else if(Math.abs(targ.aCoords.bl.y - obj.aCoords.br.y) < corner_detection)
obj.set({top:targ.top + targ.height - obj.height});
}
}
// is on TOP or BOTTOM?
if((obj.left > targ.left && obj.left < targ.left + targ.width)
|| (targ.left > obj.left && targ.left < obj.left + obj.width)){
// TOP
if(targ.aCoords.tl.y > obj.aCoords.br.y
&& targ.aCoords.tl.y - obj.aCoords.br.y < edge_detection_external){
obj.set({top:targ.aCoords.tl.y - obj.height});
obj.setCoords();
if(Math.abs(targ.aCoords.tl.x - obj.aCoords.bl.x) < corner_detection)
obj.set({left:targ.left});
else if(Math.abs(targ.aCoords.tr.x - obj.aCoords.br.x) < corner_detection)
obj.set({left:targ.left + targ.width - obj.width});
}
// BOTTOM
if(obj.aCoords.tl.y > targ.aCoords.br.y
&& obj.aCoords.tl.y - targ.aCoords.br.y < edge_detection_external){
obj.set({top:targ.aCoords.br.y});
obj.setCoords();
if(Math.abs(targ.aCoords.bl.x - obj.aCoords.tl.x) < corner_detection)
obj.set({left:targ.left});
else if(Math.abs(targ.aCoords.br.x - obj.aCoords.tr.x) < corner_detection)
obj.set({left:targ.left + targ.width - obj.width});
}
}
}
}
}
canvas.getObjects('group').some(update_position(obj));
});
String.prototype.to_inches = function(){
return this.split('-').map(function(value,index){
value = Number(value);
if(index == 0)
return value * 12
else
return value
}).reduce(function(total,current){
return total + current;
});
}
Array.prototype.to_object_list = function(){
const preserved = [...this];
var header = this.splice(0,1)[0];
for(var i = 0;i < this.length; i++){
var obj = {};
for(var j = 0;j < header.length; j++){
obj[header[j].toLowerCase()] = this[i][j];
}
this[i] = obj;
}
return preserved;
}
function draw_areas(){
var offset = 0;
return function(area_params,index){
if(area_params.area.indexOf('>') === -1){
var area = new fabric.Rect({
fill: 'red',
width:area_params.width.to_inches(),
height:area_params.length.to_inches(),
});
var text = new fabric.Text(area_params.area + '\n' + area_params.width + ' x ' + area_params.length,{
fontSize:12,
fill:"white"
});
if(text.width - area.width > 0){
text.set('width',area.width);
}
if(text.height - area.height > 0){
text.set('height',area.height);
}
var group_name = 'group_' + area_params.area.split(' ').join('-');
var group = new fabric.Group([area,text],{
name: group_name,
left: 5,
top: 5*(index++) + offset,
});
canvas.add(group);
offset = area_params.length.to_inches() + offset;
canvas.setDimensions({height:5*(index++) + offset});
}
}
}
function handler_get_data(data){
data = JSON.parse(data);
data.to_object_list();
data.forEach(draw_areas());
}
var d = '[["Area","Width","Length"],["Bedroom 1","19-5.5","14"],["Kitchen","14","16-3"],["Bedroom 2","13-6","12-9"]]';
handler_get_data(d);

Categories