How can I calculate the area of a bezier curve? - javascript
Given the following path (for example) which describes a SVG cubic bezier curve: "M300,140C300,40,500,40,500,140",
and assuming a straight line connecting the end points 300,140 to 500,140 (closing the area under the curve), is it possible to calculate the area so enclosed?
Can anyone suggest a formula (or JavaScript) to accomplish this?
Convert the path to a polygon of arbitrary precision, and then calculate the area of the polygon.
Interactive Demo: Area of Path via Subdivision (broken)
At its core the above demo uses functions for adaptively subdividing path into a polygon and computing the area of a polygon:
// path: an SVG <path> element
// threshold: a 'close-enough' limit (ignore subdivisions with area less than this)
// segments: (optional) how many segments to subdivisions to create at each level
// returns: a new SVG <polygon> element
function pathToPolygonViaSubdivision(path,threshold,segments){
if (!threshold) threshold = 0.0001; // Get really, really close
if (!segments) segments = 3; // 2 segments creates 0-area triangles
var points = subdivide( ptWithLength(0), ptWithLength( path.getTotalLength() ) );
for (var i=points.length;i--;) points[i] = [points[i].x,points[i].y];
var doc = path.ownerDocument;
var poly = doc.createElementNS('http://www.w3.org/2000/svg','polygon');
poly.setAttribute('points',points.join(' '));
return poly;
// Record the distance along the path with the point for later reference
function ptWithLength(d) {
var pt = path.getPointAtLength(d); pt.d = d; return pt;
}
// Create segments evenly spaced between two points on the path.
// If the area of the result is less than the threshold return the endpoints.
// Otherwise, keep the intermediary points and subdivide each consecutive pair.
function subdivide(p1,p2){
var pts=[p1];
for (var i=1,step=(p2.d-p1.d)/segments;i<segments;i++){
pts[i] = ptWithLength(p1.d + step*i);
}
pts.push(p2);
if (polyArea(pts)<=threshold) return [p1,p2];
else {
var result = [];
for (var i=1;i<pts.length;++i){
var mids = subdivide(pts[i-1], pts[i]);
mids.pop(); // We'll get the last point as the start of the next pair
result = result.concat(mids)
}
result.push(p2);
return result;
}
}
// Calculate the area of an polygon represented by an array of points
function polyArea(points){
var p1,p2;
for(var area=0,len=points.length,i=0;i<len;++i){
p1 = points[i];
p2 = points[(i-1+len)%len]; // Previous point, with wraparound
area += (p2.x+p1.x) * (p2.y-p1.y);
}
return Math.abs(area/2);
}
}
// Return the area for an SVG <polygon> or <polyline>
// Self-crossing polys reduce the effective 'area'
function polyArea(poly){
var area=0,pts=poly.points,len=pts.numberOfItems;
for(var i=0;i<len;++i){
var p1 = pts.getItem(i), p2=pts.getItem((i+-1+len)%len);
area += (p2.x+p1.x) * (p2.y-p1.y);
}
return Math.abs(area/2);
}
Following is the original answer, which uses a different (non-adaptive) technique for converting the <path> to a <polygon>.
Interactive Demo: http://phrogz.net/svg/area_of_path.xhtml (broken)
At its core the above demo uses functions for approximating a path with a polygon and computing the area of a polygon.
// Calculate the area of an SVG polygon/polyline
function polyArea(poly){
var area=0,pts=poly.points,len=pts.numberOfItems;
for(var i=0;i<len;++i){
var p1 = pts.getItem(i), p2=pts.getItem((i+len-1)%len);
area += (p2.x+p1.x) * (p2.y-p1.y);
}
return Math.abs(area/2);
}
// Create a <polygon> approximation for an SVG <path>
function pathToPolygon(path,samples){
if (!samples) samples = 0;
var doc = path.ownerDocument;
var poly = doc.createElementNS('http://www.w3.org/2000/svg','polygon');
// Put all path segments in a queue
for (var segs=[],s=path.pathSegList,i=s.numberOfItems-1;i>=0;--i)
segs[i] = s.getItem(i);
var segments = segs.concat();
var seg,lastSeg,points=[],x,y;
var addSegmentPoint = function(s){
if (s.pathSegType == SVGPathSeg.PATHSEG_CLOSEPATH){
}else{
if (s.pathSegType%2==1 && s.pathSegType>1){
x+=s.x; y+=s.y;
}else{
x=s.x; y=s.y;
}
var last = points[points.length-1];
if (!last || x!=last[0] || y!=last[1]) points.push([x,y]);
}
};
for (var d=0,len=path.getTotalLength(),step=len/samples;d<=len;d+=step){
var seg = segments[path.getPathSegAtLength(d)];
var pt = path.getPointAtLength(d);
if (seg != lastSeg){
lastSeg = seg;
while (segs.length && segs[0]!=seg) addSegmentPoint( segs.shift() );
}
var last = points[points.length-1];
if (!last || pt.x!=last[0] || pt.y!=last[1]) points.push([pt.x,pt.y]);
}
for (var i=0,len=segs.length;i<len;++i) addSegmentPoint(segs[i]);
for (var i=0,len=points.length;i<len;++i) points[i] = points[i].join(',');
poly.setAttribute('points',points.join(' '));
return poly;
}
I hesitated to just make a comment or a full reply. But a simple Google search of "area bezier curve" results in the first three links (the first one being this same post), in :
http://objectmix.com/graphics/133553-area-closed-bezier-curve.html (archived)
that provides the closed form solution, using the divergence theorem. I am surprised that this link has not been found by the OP.
Copying the text in case the website goes down, and crediting the author of the reply Kalle Rutanen:
An interesting problem. For any piecewise differentiable curve in 2D,
the following general procedure gives you the area inside the curve /
series of curves. For polynomial curves (Bezier curves), you will get
closed form solutions.
Let g(t) be a piecewise differentiable curve, with 0 <= t <= 1. g(t)
is oriented clockwise and g(1) = g(0).
Let F(x, y) = [x, y] / 2
Then div(F(x, y)) = 1 where div is for divergence.
Now the divergence theorem gives you the area inside the closed curve
g (t) as a line integral along the curve:
int(dot(F(g(t)), perp(g'(t))) dt, t = 0..1)
= (1 / 2) * int(dot(g(t), perp(g'(t))) dt, t = 0..1)
perp(x, y) = (-y, x)
where int is for integration, ' for differentiation and dot for dot
product. The integration has to be pieced to the parts corresponding
to the smooth curve segments.
Now for examples. Take the Bezier degree 3 and one such curve with
control points (x0, y0), (x1, y1), (x2, y2), (x3, y3). The integral
over this curve is:
I := 3 / 10 * y1 * x0 - 3 / 20 * y1 * x2 - 3 / 20 * y1 * x3 - 3 / 10 *
y0 * x1 - 3 / 20 * y0 * x2 - 1 / 20 * y0 * x3 + 3 / 20 * y2 * x0 + 3 /
20 * y2 * x1 - 3 / 10 * y2 * x3 + 1 / 20 * y3 * x0 + 3 / 20 * y3 * x1
+ 3 / 10 * y3 * x2
Calculate this for each curve in the sequence and add them up. The sum
is the area enclosed by the curves (assuming the curves form a loop).
If the curve consists of just one Bezier curve, then it must be x3 =
x0 and y3 = y0, and the area is:
Area := 3 / 20 * y1 * x0 - 3 / 20 * y1 * x2 - 3 / 20 * y0 * x1 + 3 /
20 * y0 * x2 - 3 / 20 * y2 * x0 + 3 / 20 * y2 * x1
Hope I did not do mistakes.
--
Kalle Rutanen
http://kaba.hilvi.org
I had the same problem but I am not using javascript so I cannot use the accepted answer of #Phrogz. In addition the SVGPathElement.getPointAtLength() which is used in the accepted answer is deprecated according to Mozilla.
When describing a Bézier curve with the points (x0/y0), (x1/y1), (x2/y2) and (x3/y3) (where (x0/y0) is the start point and (x3/y3) the end point) you can use the parametrized form:
(source: Wikipedia)
with B(t) being the point on the Bézier curve and Pi the Bézier curve defining point (see above, P0 is the starting point, ...). t is the running variable with 0 ≤ t ≤ 1.
This form makes it very easy to approximate a Bézier curve: You can generate as much points as you want by using t = i / npoints. (Note that you have to add the start and the end point). The result is a polygon. You can then use the shoelace formular (like #Phrogz did in his solution) to calculate the area. Note that for the shoelace formular the order of the points is important. By using t as the parameter the order will always be correct.
To match the question here is an interactive example in the code snippet, also written in javascript. This can be adopted to other languages. It does not use any javascript (or svg) specific commands (except for the drawings). Note that this requires a browser which supports HTML5 to work.
/**
* Approximate the bezier curve points.
*
* #param bezier_points: object, the points that define the
* bezier curve
* #param point_number: int, the number of points to use to
* approximate the bezier curve
*
* #return Array, an array which contains arrays where the
* index 0 contains the x and the index 1 contains the
* y value as floats
*/
function getBezierApproxPoints(bezier_points, point_number){
if(typeof bezier_points == "undefined" || bezier_points === null){
return [];
}
var approx_points = [];
// add the starting point
approx_points.push([bezier_points["x0"], bezier_points["y0"]]);
// implementation of the bezier curve as B(t), for futher
// information visit
// https://wikipedia.org/wiki/B%C3%A9zier_curve#Cubic_B%C3%A9zier_curves
var bezier = function(t, p0, p1, p2, p3){
return Math.pow(1 - t, 3) * p0 +
3 * Math.pow(1 - t, 2) * t * p1 +
3 * (1 - t) * Math.pow(t, 2) * p2 +
Math.pow(t, 3) * p3;
};
// Go through the number of points, divide the total t (which is
// between 0 and 1) by the number of points. (Note that this is
// point_number - 1 and starting at i = 1 because of adding the
// start and the end points.)
// Also note that using the t parameter this will make sure that
// the order of the points is correct.
for(var i = 1; i < point_number - 1; i++){
let t = i / (point_number - 1);
approx_points.push([
// calculate the value for x for the current t
bezier(
t,
bezier_points["x0"],
bezier_points["x1"],
bezier_points["x2"],
bezier_points["x3"]
),
// calculate the y value
bezier(
t,
bezier_points["y0"],
bezier_points["y1"],
bezier_points["y2"],
bezier_points["y3"]
)
]);
}
// Add the end point. Note that it is important to do this
// **after** the other points. Otherwise the polygon will
// have a weird form and the shoelace formular for calculating
// the area will get a weird result.
approx_points.push([bezier_points["x3"], bezier_points["y3"]]);
return approx_points;
}
/**
* Get the bezier curve values of the given path.
*
* The returned array contains objects where each object
* describes one cubic bezier curve. The x0/y0 is the start
* point and the x4/y4 is the end point. x1/y1 and x2/y2 are
* the control points.
*
* Note that a path can also contain other objects than
* bezier curves. Arcs, quadratic bezier curves and lines
* are ignored.
*
* #param svg: SVGElement, the svg
* #param path_id: String, the id of the path element in the
* svg
*
* #return array, an array of plain objects where each
* object represents one cubic bezier curve with the values
* x0 to x4 and y0 to y4 representing the x and y
* coordinates of the points
*/
function getBezierPathPoints(svg, path_id){
var path = svg.getElementById(path_id);
if(path === null || !(path instanceof SVGPathElement)){
return [];
}
var path_segments = splitPath(path);
var points = [];
var x = 0;
var y = 0;
for(index in path_segments){
if(path_segments[index]["type"] == "C"){
let bezier = {};
// start is the end point of the last element
bezier["x0"] = x;
bezier["y0"] = y;
bezier["x1"] = path_segments[index]["x1"];
bezier["y1"] = path_segments[index]["y1"];
bezier["x2"] = path_segments[index]["x2"];
bezier["y2"] = path_segments[index]["y2"];
bezier["x3"] = path_segments[index]["x"];
bezier["y3"] = path_segments[index]["y"];
points.push(bezier);
}
x = path_segments[index]["x"];
y = path_segments[index]["y"];
}
return points;
}
/**
* Split the given path to the segments.
*
* #param path: SVGPathElement, the path
*
* #return object, the split path `d`
*/
function splitPath(path){
let d = path.getAttribute("d");
d = d.split(/\s*,|\s+/);
let segments = [];
let segment_names = {
"M": ["x", "y"],
"m": ["dx", "dy"],
"H": ["x"],
"h": ["dx"],
"V": ["y"],
"v": ["dy"],
"L": ["x", "y"],
"l": ["dx", "dy"],
"Z": [],
"C": ["x1", "y1", "x2", "y2", "x", "y"],
"c": ["dx1", "dy1", "dx2", "dy2", "dx", "dy"],
"S": ["x2", "y2", "x", "y"],
"s": ["dx2", "dy2", "dx", "dy"],
"Q": ["x1", "y1", "x", "y"],
"q": ["dx1", "dy1", "dx", "dy"],
"T": ["x", "y"],
"t": ["dx", "dy"],
"A": ["rx", "ry", "rotation", "large-arc", "sweep", "x", "y"],
"a": ["rx", "ry", "rotation", "large-arc", "sweep", "dx", "dy"]
};
let current_segment_type;
let current_segment_value;
let current_segment_index;
for(let i = 0; i < d.length; i++){
if(typeof current_segment_value == "number" && current_segment_value < segment_names[current_segment_type].length){
let segment_values = segment_names[current_segment_type];
segments[current_segment_index][segment_values[current_segment_value]] = d[i];
current_segment_value++;
}
else if(typeof segment_names[d[i]] !== "undefined"){
current_segment_index = segments.length;
current_segment_type = d[i];
current_segment_value = 0;
segments.push({"type": current_segment_type});
}
else{
delete current_segment_type;
delete current_segment_value;
delete current_segment_index;
}
}
return segments;
}
/**
* Calculate the area of a polygon. The pts are the
* points which define the polygon. This is
* implementing the shoelace formular.
*
* #param pts: Array, the points
*
* #return float, the area
*/
function polyArea(pts){
var area = 0;
var n = pts.length;
for(var i = 0; i < n; i++){
area += (pts[i][1] + pts[(i + 1) % n][1]) * (pts[i][0] - pts[(i + 1) % n][0]);
}
return Math.abs(area / 2);
}
// only for the demo
(function(){
document.getElementById('number_of_points').addEventListener('change', function(){
var svg = document.getElementById("svg");
var bezier_points = getBezierPathPoints(svg, "path");
// in this example there is only one bezier curve
bezier_points = bezier_points[0];
// number of approximation points
var approx_points_num = parseInt(this.value);
var approx_points = getBezierApproxPoints(bezier_points, approx_points_num);
var doc = svg.ownerDocument;
// remove polygon
var polygons;
while((polygons = doc.getElementsByTagName("polygon")).length > 0){
polygons[0].parentNode.removeChild(polygons[0]);
}
// remove old circles
var circles;
while((circles = doc.getElementsByTagName("circle")).length > 0){
circles[0].parentNode.removeChild(circles[0]);
}
// add new circles and create polygon
var polygon_points = [];
for(var i = 0; i < approx_points.length; i++){
let circle = doc.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', approx_points[i][0]);
circle.setAttribute('cy', approx_points[i][1]);
circle.setAttribute('r', 1);
circle.setAttribute('fill', '#449944');
svg.appendChild(circle);
polygon_points.push(approx_points[i][0], approx_points[i][1]);
}
var polygon = doc.createElementNS('http://www.w3.org/2000/svg', 'polygon');
polygon.setAttribute("points", polygon_points.join(" "));
polygon.setAttribute("stroke", "transparent");
polygon.setAttribute("fill", "#cccc00");
polygon.setAttribute("opacity", "0.7");
svg.appendChild(polygon);
doc.querySelector("output[name='points']").innerHTML = approx_points_num;
doc.querySelector("output[name='area']").innerHTML = polyArea(approx_points);
});
var event = new Event("change");
document.getElementById("number_of_points").dispatchEvent(event);
})();
<html>
<body>
<div style="width: 100%; text-align: center;">
<svg width="250px" height="120px" viewBox="-5 -5 45 30" id="svg">
<path d="M 0 0 C 10 15 50 40 30 0 Z" fill="transparent" stroke="black" id="path" />
</svg>
<br />
<input type="range" min="3" max="100" value="5" class="slider" id="number_of_points">
<br />
Approximating with
<output name="points" for="number_of_points"></output>
points, area is
<output name="area"></output>
</div>
</body>
</html>
I like the solution in the accepted answer by Phrogz, but I also looked a little further and found a way to do the same with Paper.js using the CompoundPath class and area property. See my Paper.js demo.
The result (surface area = 11856) is the exact same as with Phrogz's demo when using threshold 0, but the processing seems a lot quicker! I know it's overkill to load Paper.js just to calculate the surface area, but if you are considering implementing a framework or feel like investigating how Paper.js does it...
Firstly, I am not so familiar with Bézier curves, but I know that they are continuous functions. If you ensure that your cubic curve does not intersect itself, you may integrate it in closed form (I mean by using analytic integrals) on the given enclosing domain ([a-b]) and subtract the area of triangle that is formed by the the end joining straight line and the X axis. In case of intersection with the Bézier curve and end joining straight line, you may divide into sections and try to calculate each area separately in a consistent manner..
For me suitable search terms are "continuous function integration" "integrals" "area under a function" "calculus"
Of course you may generate discrete data from your Bézier curve fn and obtain discrete X-Y data and calculate the integral approximately.
Couldn't you use an application of Gauss's magic shoelace theorem by getting a set of data points by changing T, then simply inputting that into the equation?
Here's a simple video demo https://www.youtube.com/watch?v=0KjG8Pg6LGk&ab_channel=Mathologer
And then here's the wiki https://en.wikipedia.org/wiki/Shoelace_formula
I can suggest a formula to do this numerically.
Starting with the general.
Cubic Bezier Equation
You can expand it out and you will end up with
this.
You can sub in your coordinates and simplify, then integrate with this formula.
This should give you the area between the curve and the x-axis. You can then subtract the area under the line,using standard integration, and this should give you the area enclosed.
Credit for the integration formula (image 3) and further info:https://math.libretexts.org/Courses/University_of_California_Davis/UCD_Mat_21C%3A_Multivariate_Calculus/10%3A_Parametric_Equations_and_Polar_Coordinates/10.2%3A_Calculus_with_Parametric_Curves#:~:text=The%20area%20between%20a%20parametric,%E2%80%B2(t)dt.
Inspired by James Godfrey-Kittle's suggestion in this bézierInfo thread: add section: area under a bézier curve I've wrapped this concept in a js helper function, that will get svg <path> and other elements' areas.
It's based on the same formula as suggested in #nbonneel's answer.
The main steps:
Parse and normalize a path's d attribute to an array of absolute and cubic commands. For this task, I'm using Jarek Foksa's path-data polyfill. The polyfill allows us to retrieve absolute coordinates from any path by its getPathData({normalize:true}) option. This way we don't have to bother about relative, cubic or shorthand commands.
Calculate the area for each curve segment (b0 and b1).
/**
* James Godfrey-Kittle#jamesgk
* https://github.com/Pomax/BezierInfo-2/issues/238
*/
function getBezierArea(coords) {
let x0 = coords[0];
let y0 = coords[1];
//if is cubic command
if (coords.length == 8) {
let x1 = coords[2];
let y1 = coords[3];
let x2 = coords[4];
let y2 = coords[5];
let x3 = coords[6];
let y3 = coords[7];
let area = (
x0 * (-2 * y1 - y2 + 3 * y3) +
x1 * (2 * y0 - y2 - y3) +
x2 * (y0 + y1 - 2 * y3) +
x3 * (-3 * y0 + y1 + 2 * y2)
) * 3 / 20;
return area;
} else {
return 0;
}
}
x0, y0 are the last coordinates of the command preceding the current C command. x1, y1, x2, y2, x3, y3 are the current pathdata values.
Since we don't need a polygon approximation based on the rather expensive getPointAtLength() method – the calculation is comparatively fast.
Add the remaining polygon's area to the bézier areas (p0). This step will also use the shoelace formula.
Example 1: semi circle with a radius of 50 (svg user units)
We can easily check, if the calculation works, since the expected result should be:
π·50²/2 = 3926.99
//example 1:
let svg = document.querySelector("svg");
let path = svg.querySelector("path");
let pathArea = getshapeAreaSimple(path);
let result = document.getElementById("result");
result.textContent = 'area: ' + pathArea;
function getshapeAreaSimple(el) {
let totalArea = 0;
let polyPoints = [];
let type = el.nodeName.toLowerCase();
let log = [];
let bezierArea = 0;
let pathData = el.getPathData({
normalize: true
});
pathData.forEach(function(com, i) {
let [type, values] = [com.type, com.values];
if (values.length) {
let prevC = i > 0 ? pathData[i - 1] : pathData[0];
let prevCVals = prevC.values;
let prevCValsL = prevCVals.length;
let [x0, y0] = [prevCVals[prevCValsL - 2], prevCVals[prevCValsL - 1]];
// C commands
if (values.length == 6) {
let area = getBezierArea([
x0,
y0,
values[0],
values[1],
values[2],
values[3],
values[4],
values[5]
]);
//push points to calculate inner/remaining polygon area
polyPoints.push([x0, y0], [values[4], values[5]]);
bezierArea += area;
}
// L commands
else {
polyPoints.push([x0, y0], [values[0], values[1]]);
}
}
});
let areaPoly = polygonArea(polyPoints, false);
//values have the same sign - subtract polygon area
if ((areaPoly < 0 && bezierArea < 0) || (areaPoly > 0 && bezierArea > 0)) {
totalArea = Math.abs(bezierArea) - Math.abs(areaPoly);
} else {
totalArea = Math.abs(bezierArea) + Math.abs(areaPoly);
}
return totalArea;
}
function getPathArea(pathData) {
let totalArea = 0;
let polyPoints = [];
pathData.forEach(function(com, i) {
let [type, values] = [com.type, com.values];
if (values.length) {
let prevC = i > 0 ? pathData[i - 1] : pathData[0];
let prevCVals = prevC.values;
let prevCValsL = prevCVals.length;
let [x0, y0] = [prevCVals[prevCValsL - 2], prevCVals[prevCValsL - 1]];
// C commands
if (values.length == 6) {
let area = getBezierArea([
x0,
y0,
values[0],
values[1],
values[2],
values[3],
values[4],
values[5]
]);
//push points to calculate inner/remaining polygon area
polyPoints.push([x0, y0], [values[4], values[5]]);
totalArea += area;
}
// L commands
else {
polyPoints.push([x0, y0], [values[0], values[1]]);
}
}
});
let areaPoly = polygonArea(polyPoints);
totalArea = Math.abs(areaPoly) + Math.abs(totalArea);
return totalArea;
}
/**
* James Godfrey-Kittle#jamesgk
* https://github.com/Pomax/BezierInfo-2/issues/238
*/
function getBezierArea(coords) {
let x0 = coords[0];
let y0 = coords[1];
//if is cubic command
if (coords.length == 8) {
let x1 = coords[2];
let y1 = coords[3];
let x2 = coords[4];
let y2 = coords[5];
let x3 = coords[6];
let y3 = coords[7];
let area =
((x0 * (-2 * y1 - y2 + 3 * y3) +
x1 * (2 * y0 - y2 - y3) +
x2 * (y0 + y1 - 2 * y3) +
x3 * (-3 * y0 + y1 + 2 * y2)) *
3) /
20;
return area;
} else {
return 0;
}
}
function polygonArea(points, absolute = true) {
let area = 0;
for (let i = 0; i < points.length; i++) {
const addX = points[i][0];
const addY = points[i === points.length - 1 ? 0 : i + 1][1];
const subX = points[i === points.length - 1 ? 0 : i + 1][0];
const subY = points[i][1];
area += addX * addY * 0.5 - subX * subY * 0.5;
}
if (absolute) {
area = Math.abs(area);
}
return area;
}
svg {
max-height: 20em;
max-width: 100%;
border: 1px solid #ccc;
fill: #ccc;
}
<p> Expected area: <br /> π·50²/2 = 3926.99</p>
<p id="result"></p>
<svg viewBox="0 0 100 50">
<path d="M50,0C22.383,0,0,22.385,0,49.998h100C100,22.385,77.613,0,50,0z" />
</svg>
<script src="https://cdn.jsdelivr.net/npm/path-data-polyfill#1.0.3/path-data-polyfill.min.js"></script>
Example 2: get areas of primitives and compound paths
For a more versatile helper function, we can include primitives like <circle>, <ellipse>, <polygon> etc. and skip the bézier calculation for these element types.
Compound paths – so shapes like the letters O or i will require to calculate the areas for each sub path. If a sub path is within the boundaries of another shape like the letter O, we also need to subtract inner shapes from the total area.
function getshapeArea(el, decimals = 0) {
let totalArea = 0;
let polyPoints = [];
let type = el.nodeName.toLowerCase();
switch (type) {
// 1. paths
case "path":
let pathData = el.getPathData({
normalize: true
});
//check subpaths
let subPathsData = splitSubpaths(pathData);
let isCompoundPath = subPathsData.length > 1 ? true : false;
let counterShapes = [];
// check intersections for compund paths
if (isCompoundPath) {
let bboxArr = getSubPathBBoxes(subPathsData);
bboxArr.forEach(function(bb, b) {
//let path1 = path;
for (let i = 0; i < bboxArr.length; i++) {
let bb2 = bboxArr[i];
if (bb != bb2) {
let intersects = checkBBoxIntersections(bb, bb2);
if (intersects) {
counterShapes.push(i);
}
}
}
});
}
subPathsData.forEach(function(pathData, d) {
//reset polygon points for each segment
polyPoints = [];
let bezierArea = 0;
let pathArea = 0;
let multiplier = 1;
pathData.forEach(function(com, i) {
let [type, values] = [com.type, com.values];
if (values.length) {
let prevC = i > 0 ? pathData[i - 1] : pathData[0];
let prevCVals = prevC.values;
let prevCValsL = prevCVals.length;
let [x0, y0] = [
prevCVals[prevCValsL - 2],
prevCVals[prevCValsL - 1]
];
// C commands
if (values.length == 6) {
let area = getBezierArea([
x0,
y0,
values[0],
values[1],
values[2],
values[3],
values[4],
values[5]
]);
//push points to calculate inner/remaining polygon area
polyPoints.push([x0, y0], [values[4], values[5]]);
bezierArea += area;
}
// L commands
else {
polyPoints.push([x0, y0], [values[0], values[1]]);
}
}
});
//get area of remaining polygon
let areaPoly = polygonArea(polyPoints, false);
//subtract area by negative multiplier
if (counterShapes.indexOf(d) !== -1) {
multiplier = -1;
}
//values have the same sign - subtract polygon area
if (
(areaPoly < 0 && bezierArea < 0) ||
(areaPoly > 0 && bezierArea > 0)
) {
pathArea = (Math.abs(bezierArea) - Math.abs(areaPoly)) * multiplier;
} else {
pathArea = (Math.abs(bezierArea) + Math.abs(areaPoly)) * multiplier;
}
totalArea += pathArea;
});
break;
// 2. primitives:
// 2.1 circle an ellipse primitives
case "circle":
case "ellipse":
totalArea = getEllipseArea(el);
break;
// 2.2 polygons
case "polygon":
case "polyline":
totalArea = getPolygonArea(el);
break;
// 2.3 rectancle primitives
case "rect":
totalArea = getRectArea(el);
break;
}
if (decimals > 0) {
totalArea = +totalArea.toFixed(decimals);
}
return totalArea;
}
function getPathArea(pathData) {
let totalArea = 0;
let polyPoints = [];
pathData.forEach(function(com, i) {
let [type, values] = [com.type, com.values];
if (values.length) {
let prevC = i > 0 ? pathData[i - 1] : pathData[0];
let prevCVals = prevC.values;
let prevCValsL = prevCVals.length;
let [x0, y0] = [prevCVals[prevCValsL - 2], prevCVals[prevCValsL - 1]];
// C commands
if (values.length == 6) {
let area = getBezierArea([
x0,
y0,
values[0],
values[1],
values[2],
values[3],
values[4],
values[5]
]);
//push points to calculate inner/remaining polygon area
polyPoints.push([x0, y0], [values[4], values[5]]);
totalArea += area;
}
// L commands
else {
polyPoints.push([x0, y0], [values[0], values[1]]);
}
}
});
let areaPoly = polygonArea(polyPoints);
totalArea = Math.abs(areaPoly) + Math.abs(totalArea);
return totalArea;
}
/**
* James Godfrey-Kittle/#jamesgk : https://github.com/Pomax/BezierInfo-2/issues/238
*/
function getBezierArea(coords) {
let x0 = coords[0];
let y0 = coords[1];
//if is cubic command
if (coords.length == 8) {
let x1 = coords[2];
let y1 = coords[3];
let x2 = coords[4];
let y2 = coords[5];
let x3 = coords[6];
let y3 = coords[7];
let area =
((x0 * (-2 * y1 - y2 + 3 * y3) +
x1 * (2 * y0 - y2 - y3) +
x2 * (y0 + y1 - 2 * y3) +
x3 * (-3 * y0 + y1 + 2 * y2)) *
3) /
20;
return area;
} else {
return 0;
}
}
function polygonArea(points, absolute = true) {
let area = 0;
for (let i = 0; i < points.length; i++) {
const addX = points[i][0];
const addY = points[i === points.length - 1 ? 0 : i + 1][1];
const subX = points[i === points.length - 1 ? 0 : i + 1][0];
const subY = points[i][1];
area += addX * addY * 0.5 - subX * subY * 0.5;
}
if (absolute) {
area = Math.abs(area);
}
return area;
}
function getPolygonArea(el) {
// convert point string to arra of numbers
let points = el
.getAttribute("points")
.split(/,| /)
.filter(Boolean)
.map((val) => {
return parseFloat(val);
});
let polyPoints = [];
for (let i = 0; i < points.length; i += 2) {
polyPoints.push([points[i], points[i + 1]]);
}
let area = polygonArea(polyPoints);
return area;
}
function getRectArea(el) {
let width = el.getAttribute("width");
let height = el.getAttribute("height");
let area = width * height;
return area;
}
function getEllipseArea(el) {
let r = el.getAttribute("r");
let rx = el.getAttribute("rx");
let ry = el.getAttribute("ry");
//if circle – take radius
rx = rx ? rx : r;
ry = ry ? ry : r;
let area = Math.PI * rx * ry;
return area;
}
//path data helpers
function splitSubpaths(pathData) {
let pathDataL = pathData.length;
let subPathArr = [];
let subPathMindex = [];
pathData.forEach(function(com, i) {
let [type, values] = [com["type"], com["values"]];
if (type == "M") {
subPathMindex.push(i);
}
});
//split subPaths
subPathMindex.forEach(function(index, i) {
let end = subPathMindex[i + 1];
let thisSeg = pathData.slice(index, end);
subPathArr.push(thisSeg);
});
return subPathArr;
}
function getSubPathBBoxes(subPaths) {
let ns = "http://www.w3.org/2000/svg";
let svgTmp = document.createElementNS(ns, "svg");
svgTmp.setAttribute("style", "position:absolute; width:0; height:0;");
document.body.appendChild(svgTmp);
let bboxArr = [];
subPaths.forEach(function(pathData) {
let pathTmp = document.createElementNS(ns, "path");
svgTmp.appendChild(pathTmp);
pathTmp.setPathData(pathData);
let bb = pathTmp.getBBox();
bboxArr.push(bb);
});
svgTmp.remove();
return bboxArr;
}
function checkBBoxIntersections(bb, bb1) {
let [x, y, width, height, right, bottom] = [
bb.x,
bb.y,
bb.width,
bb.height,
bb.x + bb.width,
bb.y + bb.height
];
let [x1, y1, width1, height1, right1, bottom1] = [
bb1.x,
bb1.y,
bb1.width,
bb1.height,
bb1.x + bb1.width,
bb1.y + bb1.height
];
let intersects = false;
if (width * height != width1 * height1) {
if (width * height > width1 * height1) {
if (x < x1 && right > right1 && y < y1 && bottom > bottom1) {
intersects = true;
}
}
}
return intersects;
}
svg {
max-height: 20em;
max-width: 100%;
border: 1px solid #ccc;
fill: #ccc;
}
<p><button type="button" onclick="getSingleArea(path0)">Get this area</button></p>
<svg class="svg0" viewBox="300 51.399147033691406 215.8272705078125 98.6994857788086">
<path id="curve" d="M 300 140 C 300 40 505 16 480 113 C544 47 523 235 411 100Z" />
</svg>
<p class="result0"></p>
<svg class="svg1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 25">
<path id="singleCurve" d="M0,12.667h25C25-4.222,0-4.222,0,12.667z" />
<path id="circle-two-quarter" d="M37.5,12.667c0,6.904,5.596,12.5,12.5,12.5c0-6.511,0-12.5,0-12.5l12.5,0c0-6.903-5.597-12.5-12.5-12.5
v12.5L37.5,12.667z" />
<path id="circle-three-quarters" d="M75,12.667c0,6.904,5.596,12.5,12.5,12.5c6.903,0,12.5-5.597,12.5-12.5
c0-6.903-5.597-12.5-12.5-12.5v12.5L75,12.667z" />
<circle id="circle" cx="125" cy="12.667" r="12.5" />
<ellipse id="ellipse" cx="162.5" cy="13.325" rx="12.5" ry="6.25" />
<rect id="rect" x="187.5" y="0.167" width="25" height="25" />
<polygon id="hexagon" points="231.25,23.493 225,12.667 231.25,1.842 243.75,1.842 250,12.667 243.75,23.493 " />
<path id="compound" d="M268.951,10.432c-3.452,0-6.25,2.798-6.25,6.25s2.798,6.25,6.25,6.25s6.25-2.798,6.25-6.25
S272.403,10.432,268.951,10.432z M268.951,19.807c-1.726,0-3.125-1.399-3.125-3.125s1.399-3.125,3.125-3.125
s3.125,1.399,3.125,3.125S270.677,19.807,268.951,19.807z M272.076,4.968c0,1.726-1.399,3.125-3.125,3.125s-3.125-1.399-3.125-3.125
c0-1.726,1.399-3.125,3.125-3.125S272.076,3.242,272.076,4.968z" />
</svg>
<p class="result1"></p>
<p><button type="button" onclick="getAllAreas(areaEls)">Get all areas</button></p>
<!--Dependency: path data polyfill -->
<script src="https://cdn.jsdelivr.net/npm/path-data-polyfill#1.0.3/path-data-polyfill.min.js"></script>
<script>
// 1st example: single path area
let svg0 = document.querySelector('.svg0');
let path0 = svg0.querySelector('path');
let result0 = document.querySelector('.result0');
function getSingleArea(shape) {
let shapeArea = getshapeArea(shape, 3);
result0.textContent = 'area: ' + shapeArea;
}
// 2nd example: multiple shape areas
let svg1 = document.querySelector('.svg1');
let areaEls = svg1.querySelectorAll('path, polygon, circle, ellipse, rect');
let result1 = document.querySelector('.result1');
//benchmark
let [t0, t1] = [0, 0];
function getAllAreas(areaEls) {
let results = []
perfStart();
areaEls.forEach(function(shape, i) {
let type = shape.nodeName.toLowerCase();
let id = shape.id ? '#' + shape.id : '<' + type + '/> [' + i + ']';
let shapeArea = getshapeArea(shape, 3);
let resultString = `<strong>${id}:</strong> ${shapeArea}`;
results.push(resultString);
let title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
title.textContent = `${id}: ${shapeArea}`;
shape.appendChild(title);
});
let totalTime = perfEnd();
result1.innerHTML = results.join('<br />') + '<br /><br /><strong>time: </strong>' + totalTime + 'ms';
}
/**
* helpers for performance testing
*/
function adjustViewBox(svg) {
let bb = svg.getBBox();
let [x, y, width, height] = [bb.x, bb.y, bb.width, bb.height];
svg.setAttribute('viewBox', [x, y, width, height].join(' '));
}
function perfStart() {
t0 = performance.now();
}
function perfEnd(text = '') {
t1 = performance.now();
total = t1 - t0;
return total;
}
</script>
Codepen example
Square area covered by radius vector of a point moving in 2D plane is 1/2*integral[(x-xc)*dy/dt - (y-yc)*dx/dt]dt. Here xc and yc are coordinates of the origin point (center). Derivation for the case of Bezier curves is rather cumbersome but possible. See functions squareAreaQuadr and squareAreaCubic below. I have tested and retested these formulae, rather sure, that there are no mistakes. This signature gives positive square area for clockwise rotation in SVG coordinates plane.
var xc=0.1, yc=0.2, x0=0.9, y0=0.1, x1=0.9, y1=0.9, x2=0.5, y2=0.5, x3=0.1, y3=0.9
var cubic = document.getElementById("cubic");
cubic.setAttribute("d", "M "+xc*500+" "+yc*500+" L "+x0*500+" "+y0*500+" C "+x1*500+" "+y1*500+" "+x2*500+" "+y2*500+" "+x3*500+" "+y3*500+" L "+xc*500+" "+yc*500);
var center1 = document.getElementById("center1");
center1.setAttribute("cx", xc*500);
center1.setAttribute("cy", yc*500);
function squareAreaCubic(xc, yc, x0, y0, x1, y1, x2, y2, x3, y3)
{
var s;
s = 3/4*( (x0-xc)*(y1-y0) + (x3-xc)*(y3-y2) ) +
1/4*(x3-x0)*(y1+y2-y0-y3) +
1/8*( (x0+x3-2*xc)*(3*y2-3*y1+y0-y3) + (x1+x2-x0-x3)*(y1-y0+y3-y2) ) +
3/40*( (2*x1-x0-x2)*(y1-y0) + (2*x2-x1-x3)*(y3-y2) ) +
1/20*( (2*x1-x0-x2)*(y3-y2) + (2*x2-x1-x3)*(y1-y0) + (x1+x2-x0-x3)*(3*y2-3*y1+y0-y3) ) +
1/40*(x1+x2-x0-x3)*(3*y2-3*y1+y0-y3) -
3/4*( (y0-yc)*(x1-x0) + (y3-yc)*(x3-x2) ) -
1/4*(y3-y0)*(x1+x2-x0-x3) -
1/8*( (y0+y3-2*yc)*(3*x2-3*x1+x0-x3) + (y1+y2-y0-y3)*(x1-x0+x3-x2) ) -
3/40*( (2*y1-y0-y2)*(x1-x0) + (2*y2-y1-y3)*(x3-x2) ) -
1/20*( (2*y1-y0-y2)*(x3-x2) + (2*y2-y1-y3)*(x1-x0) + (y1+y2-y0-y3)*(3*x2-3*x1+x0-x3) ) -
1/40*(y1+y2-y0-y3)*(3*x2-3*x1+x0-x3) ;
return s;
}
var s = squareAreaCubic(xc, yc, x0, y0, x1, y1, x2, y2, x3, y3);
document.getElementById("c").innerHTML = document.getElementById("c").innerHTML + s.toString();
<html>
<body>
<h1>Bezier square area</h1>
<p id="q">Quadratic: S = </p>
<svg height="500" width="500">
<rect width="500" height="500" style="fill:none; stroke-width:2; stroke:black" />
<path id="quadr" fill="lightgray" stroke="red" stroke-width="1" />
<circle id="q_center" r="5" fill="black" />
</svg>
<script>
var xc=0.1, yc=0.2, x0=0.9, y0=0.1, x1=0.9, y1=0.9, x2=0.1, y2=0.9;
var quadr = document.getElementById("quadr");
quadr.setAttribute("d", "M "+xc*500+" "+yc*500+" L "+x0*500+" "+y0*500+" Q "+x1*500+" "+y1*500+" "+x2*500+" "+y2*500+" L "+xc*500+" "+yc*500);
var center = document.getElementById("q_center");
q_center.setAttribute("cx", xc*500);
q_center.setAttribute("cy", yc*500);
function squareAreaQuadr(xc, yc, x0, y0, x1, y1, x2, y2)
{
var s = 1/2*( (x0-xc)*(y1-y0) + (x2-xc)*(y2-y1) - (y0-yc)*(x1-x0) - (y2-yc)*(x2-x1) ) +
1/12*( (x2-x0)*(2*y1-y0-y2) - (y2-y0)*(2*x1-x0-x2) );
return s;
}
var s = squareAreaQuadr(xc, yc, x0, y0, x1, y1, x2, y2);
document.getElementById("q").innerHTML = document.getElementById("q").innerHTML + s.toString();
</script>
<p id="c">Cubic: S = </p>
<svg height="500" width="500">
<rect width="500" height="500" style="fill:none; stroke-width:2; stroke:black" />
<path id="cubic" fill="lightgray" stroke="red" stroke-width="1" />
<circle id="center1" r="5" fill="black" />
</svg>
</body>
</html>
Related
How can i get the new X and Y position after rotating?
I need to make a Sunflowerplot and after rotating the lines the position of them has to be translated back. But i don't know how to get the new x,y pos after rotating. I want to rotate only the line but its position does ofcourse change too. var xOld = (save[i][0])/(xS.value/100/3.4); var yOld = (save[i][1])/(yS.value/100/3.5*-1); //Above code is to get and transform the position where to draw //and works very well without rotate var line = d3.select("svg") .append("line") .attr("stroke-width","1") //Backwardline .attr("x1",xOld-lineLength) .attr("y1",yOld) //I think that i need to translate the new position here .attr("transform", "translate(50, " + 360 +") rotate(" + 45 * -1+ ")") //Forwardline .attr("x2",(xOld)+lineLength) .attr("y2",(yOld)) .style("stroke","blue");
I added a snippet where you can determine the number of petals yourself, and play with the styling and rotation a little if you want const svg = d3.select('body').append('svg'); // The distance in pixels between the edge and the center of each petal const petalRadius = 20; // sin, cos, and tan work in radians const fullCircle = 2 * Math.PI; // Zero rads make the shape point to the right with the right angle // Use - 0.5 * pi rads to make the first petal point upwards instead // You can play with this offset to see what it does const offset = - Math.PI / 2; function drawSunflower(container, petals) { const radsPerPetal = fullCircle / petals; const path = container.append('path'); // We're going to need this a lot. M moves to the given coordinates, in this case // That is the center of the sunflower const goToCenter = ` M ${petalRadius},${petalRadius}`; // Construct the `d` attribute. Start in the center and work form there. let d = goToCenter; let counter = 0; while (counter < petals) { const rads = counter * radsPerPetal + offset; const dx = Math.cos(rads) * petalRadius; const dy = Math.sin(rads) * petalRadius; // Draw a relative line to dx, dy, then go to center d += `l ${dx},${dy}` + goToCenter; counter += 1; } path.attr('d', d); } const transform = 2 * petalRadius + 5; for (let i = 0; i < 5; i++) { for (let j = 0; j < 3; j++) { let container = svg.append('g').attr('transform', `translate(${i * transform}, ${j * transform})`); drawSunflower(container, i * 5 + j + 1); } } g > path { stroke: black; stroke-width: 1px; } <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
center of Raphael triangle
Let's say I need to put a text in the middle of the area of a triangle. I can calculate the coordinates of the triangle's center using getBBox(): var triangle = "M0,0 L100,0 100,50 z"; var BBox = paper.path(triangle).getBBox(); var middle; middle.x = BBox.x + BBox.width/2; middle.y = BBox.y + BBox.height/2; This results in the coordinates (50, 25) which are always on the long side of the triangle. How can I make sure the calculated "middle" is inside the triangle? The correct coordinates should be approximately: (75, 25). The code should of course be independent of this particular example, it should work for any kind of shape.
I've done some more research in the topic, and following an advice from another list I got here: https://en.wikipedia.org/wiki/Centroid There is an algorithm there to calculate the centroid of an irregular polygon, which I have translated into this code: function getCentroid(path) { var x = new Array(11); var y = new Array(11); var asum = 0, cxsum = 0, cysum = 0; var totlength = path.getTotalLength(); for (var i = 0; i < 11; i++) { var location = path.getPointAtLength(i*totlength/10); x[i] = location.x; y[i] = location.y; if (i > 0) { asum += x[i - 1]*y[i] - x[i]*y[i - 1]; cxsum += (x[i - 1] + x[i])*(x[i - 1]*y[i] - x[i]*y[i - 1]); cysum += (y[i - 1] + y[i])*(x[i - 1]*y[i] - x[i]*y[i - 1]); } } return({x: (1/(3*asum))*cxsum, y: (1/(3*asum))*cysum}); } It's basically an approximation of any path by 10 points (the 11th is equal to the starting point), and the function returns, for that triangle, the coordinates: Object {x: 65.32077336966377, y: 16.33111549955705} I've tested it with many other shapes, and it works pretty good. Hope it helps somebody.
This snippet will calculate the center of any polygon by averaging the vertices. var paper = Raphael(0,0, 320, 200); var triangle = "M0,0 L100,0 100,50 z"; var tri = paper.path(triangle); tri.attr('fill', 'blue'); var center = raphaelPathCenter( tri ); var circle = paper.circle( center.x, center.y, 5); circle.attr("fill", "#f00"); circle.attr("stroke", "#fff"); function raphaelPathCenter( path ) { path.getBBox(); // forces path to be traced so realPath is not null. var vertices = parseSVGVertices( path.realPath ); var center = vertices.reduce( function(prev,cur) { return { x: prev.x + cur.x, y: prev.y + cur.y } }, {x:0, y:0} ); center.x /= vertices.length; center.y /= vertices.length; return center; } function parseSVGVertices( svgPath ) { var vertices = []; for ( var i = 0; i < svgPath.length; i ++ ) { var vertex = svgPath[i]; if ( "ML".indexOf( vertex[0] ) > -1 ) // check SVG command vertices.push( { x: vertex[1], y: vertex[2] } ); } return vertices; } <script src="https://raw.githubusercontent.com/DmitryBaranovskiy/raphael/master/raphael-min.js"></script> <canvas id='canvas'></canvas> <pre id='output'></pre> However there are a few more triangle centers to choose from.
How to detect if a user has drawn a circle on a touch device using canvas and javascript?
I am creating a Tangram puzzle game using Javascript. And I need to detect when a user has drawn a circle (or circle like shape) with their finger. I have been able to gather hundreds (if not thousands) of x and y points with: var touchX = event.targetTouches[0].pageX - canvas.offsetLeft; var touchY = event.targetTouches[0].pageY - canvas.offsetTop; I then push each x and y coordinate into an array: touchMoveX.push(touchX); touchMoveY.push(touchY); I then loop through each array and create two points: for(var i = 0; i < touchMoveX.length; i++) { for(var l=0; l < touchMoveY.length; l++) { var xPosition = touchMoveX[i]; var yPosition = touchMoveY[l]; var v1x = touchMoveX[i]; var v2x = touchMoveX[i + 1]; var v1y = touchMoveY[l]; var v2y = touchMoveY[l + 1]; Then using those two points, I use the following formula to figure out the angle between these two points in degrees: var v1 = {x: v1x, y: v1y}, v2 = {x: v2x, y: v2y}, angleRad = Math.acos( (v1.x * v2.x + v1.y * v2.y) / (Math.sqrt(v1.x*v1.x + v1.y*v1.y) * Math.sqrt(v2.x*v2.x + v2.y*v2.y) ) ), angleDeg = angleRad * 180 / Math.PI; I then sum up all of the angles and see if they are around 360 degrees. But the above code I have described isn't working very well. Does someone out there have a better way to do this? Thank you very much.
yeah compute the average of all points (giving you a cheaply approximated center) then check if more than a certain percent of points are within a certain threshold. You can tune those values to adjust the precision until it feels right. edit: Didn't consider that the circle could have multiple sizes, but you could just add another step computing the average of all distances. Adjusted the example for that. var totalAmount = touchMoveX.length; // sum up all coordinates and divide them by total length // the average is a cheap approximation of the center. var averageX = touchMoveX.reduce( function ( previous, current) { return previous + current; } ) / totalAmount ; var averageY = touchMoveY.reduce( function ( previous, current) { return previous + current; } ) / totalAmount ; // compute distance to approximated center from each point var distances = touchMoveX.map ( function ( x, index ) { var y = touchMoveY[index]; return Math.sqrt( Math.pow(x - averageX, 2) + Math.pow(y - averageY, 2) ); } ); // average of those distance is var averageDistance = distances.reduce ( function ( previous, current ) { return previous + current; } ) / distances.length; var min = averageDistance * 0.8; var max = averageDistance * 1.2; // filter out the ones not inside the min and max boundaries var inRange = distances.filter ( function ( d ) { return d > min && d < max; } ).length; var minPercentInRange = 80; var percentInRange = inRange.length / totalAmount * 100; // by the % of points within those boundaries we can guess if it's circle if( percentInRange > minPercentInRange ) { //it's probably a circle }
Baking transforms into SVG Path Element commands
tl;dr summary: Give me the resources or help fix the below code to transform path commands for SVG <path> elements by an arbitrary matrix. details: I'm writing a library to convert any arbitrary SVG shape into a <path> element. I have it working when there are no transform="..." elements in the hierarchy, but now I want to bake the local transform of the object into the path data commands themselves. This is mostly working (code below) when dealing with the simple moveto/lineto commands. However, I'm not sure of the appropriate way to transform the bezier handles or arcTo parameters. For example, I am able to convert this rounded rectangle to a <path>: <rect x="10" y="30" rx="10" ry="20" width="80" height="70" /> --> <path d="M20,30 L80,30 A10,20,0,0,1,90,50 L90,80 A10,20,0,0,1,80,100 L20,100 A10,20,0,0,1,10,80 L10,50 A10,20,0,0,1,20,30" /> And I get a valid result when transforming without any round corners: <rect x="10" y="30" width="80" height="70" transform="translate(-200,0) scale(1.5) rotate(50)" /> --> <path d="M10,30 L90,30 L90,100 L10,100 L10,30" /> However, transforming only the x/y coords of the elliptical arc commands yields amusing results: The dotted line is the actual transformed rect, the green fill is my path. Following is the code I have so far (slightly pared-down). I also have a test page where I'm testing various shapes. Please help me determine how to properly transform the elliptical arc and various other bezier commands given an arbitrary transformation matrix. function flattenToPaths(el,transform,svg){ if (!svg) svg=el; while(svg && svg.tagName!='svg') svg=svg.parentNode; var doc = el.ownerDocument; var svgNS = svg.getAttribute('xmlns'); // Identity transform if nothing passed in if (!transform) transform= svg.createSVGMatrix(); // Calculate local transform matrix for the object var localMatrix = svg.createSVGMatrix(); for (var xs=el.transform.baseVal,i=xs.numberOfItems-1;i>=0;--i){ localMatrix = xs.getItem(i).matrix.multiply(localMatrix); } // Transform the local transform by whatever was recursively passed in transform = transform.multiply(localMatrix); var path = doc.createElementNS(svgNS,'path'); switch(el.tagName){ case 'rect': path.setAttribute('stroke',el.getAttribute('stroke')); var x = el.getAttribute('x')*1, y = el.getAttribute('y')*1, w = el.getAttribute('width')*1, h = el.getAttribute('height')*1, rx = el.getAttribute('rx')*1, ry = el.getAttribute('ry')*1; if (rx && !el.hasAttribute('ry')) ry=rx; else if (ry && !el.hasAttribute('rx')) rx=ry; if (rx>w/2) rx=w/2; if (ry>h/2) ry=h/2; path.setAttribute('d', 'M'+(x+rx)+','+y+ 'L'+(x+w-rx)+','+y+ ((rx||ry) ? ('A'+rx+','+ry+',0,0,'+(rx*ry<0?0:1)+','+(x+w)+','+(y+ry)) : '') + 'L'+(x+w)+','+(y+h-ry)+ ((rx||ry) ? ('A'+rx+','+ry+',0,0,'+(rx*ry<0?0:1)+','+(x+w-rx)+','+(y+h)) : '')+ 'L'+(x+rx)+','+(y+h)+ ((rx||ry) ? ('A'+rx+','+ry+',0,0,'+(rx*ry<0?0:1)+','+x+','+(y+h-ry)) : '')+ 'L'+x+','+(y+ry)+ ((rx||ry) ? ('A'+rx+','+ry+',0,0,'+(rx*ry<0?0:1)+','+(x+rx)+','+y) : '') ); break; case 'circle': var cx = el.getAttribute('cx')*1, cy = el.getAttribute('cy')*1, r = el.getAttribute('r')*1, r0 = r/2+','+r/2; path.setAttribute('d','M'+cx+','+(cy-r)+' A'+r0+',0,0,0,'+cx+','+(cy+r)+' '+r0+',0,0,0,'+cx+','+(cy-r) ); break; case 'ellipse': var cx = el.getAttribute('cx')*1, cy = el.getAttribute('cy')*1, rx = el.getAttribute('rx')*1, ry = el.getAttribute('ry')*1; path.setAttribute('d','M'+cx+','+(cy-ry)+' A'+rx+','+ry+',0,0,0,'+cx+','+(cy+ry)+' '+rx+','+ry+',0,0,0,'+cx+','+(cy-ry) ); break; case 'line': var x1=el.getAttribute('x1')*1, y1=el.getAttribute('y1')*1, x2=el.getAttribute('x2')*1, y2=el.getAttribute('y2')*1; path.setAttribute('d','M'+x1+','+y1+'L'+x2+','+y2); break; case 'polyline': case 'polygon': for (var i=0,l=[],pts=el.points,len=pts.numberOfItems;i<len;++i){ var p = pts.getItem(i); l[i] = p.x+','+p.y; } path.setAttribute('d',"M"+l.shift()+"L"+l.join(' ') + (el.tagName=='polygon') ? 'z' : ''); break; case 'path': path = el.cloneNode(false); break; } // Convert local space by the transform matrix var x,y; var pt = svg.createSVGPoint(); var setXY = function(x,y,xN,yN){ pt.x = x; pt.y = y; pt = pt.matrixTransform(transform); if (xN) seg[xN] = pt.x; if (yN) seg[yN] = pt.y; }; // Extract rotation and scale from the transform var rotation = Math.atan2(transform.b,transform.d)*180/Math.PI; var sx = Math.sqrt(transform.a*transform.a+transform.c*transform.c); var sy = Math.sqrt(transform.b*transform.b+transform.d*transform.d); // FIXME: Must translate any Horizontal or Vertical lineto commands into absolute moveto for (var segs=path.pathSegList,c=segs.numberOfItems,i=0;i<c;++i){ var seg = segs.getItem(i); // Odd-numbered path segments are all relative // http://www.w3.org/TR/SVG/paths.html#InterfaceSVGPathSeg var isRelative = (seg.pathSegType%2==1); var hasX = seg.x != null; var hasY = seg.y != null; if (hasX) x = isRelative ? x+seg.x : seg.x; if (hasY) y = isRelative ? y+seg.y : seg.y; if (hasX || hasY) setXY( x, y, hasX && 'x', hasY && 'y' ); if (seg.x1 != null) setXY( seg.x1, seg.y1, 'x1', 'y1' ); if (seg.x2 != null) setXY( seg.x2, seg.y2, 'x2', 'y2' ); if (seg.angle != null){ seg.angle += rotation; seg.r1 *= sx; // FIXME; only works for uniform scale seg.r2 *= sy; // FIXME; only works for uniform scale } } return path; }
I have made a general SVG flattener flatten.js, that supports all shapes and path commands: https://gist.github.com/timo22345/9413158 Basic usage: flatten(document.getElementById('svg')); What it does: Flattens elements (converts elements to paths and flattens transformations). If the argument element (whose id is above 'svg') has children, or it's descendants has children, these children elements are flattened also. What can be flattened: entire SVG document, individual shapes (path, circle, ellipse etc.) and groups. Nested groups are handled automatically. How about attributes? All attributes are copied. Only arguments that are not valid in path element, are dropped (eg. r, rx, ry, cx, cy), but they are not needed anymore. Also transform attribute is dropped, because transformations are flattened to path commands. If you want to modify path coordinates using non-affine methods (eg. perspective distort), you can convert all segments to cubic curves using: flatten(document.getElementById('svg'), true); There are also arguments 'toAbsolute' (convert coordinates to absolute) and 'dec', number of digits after decimal separator. Extreme path and shape tester: https://jsfiddle.net/fjm9423q/embedded/result/ Basic usage example: http://jsfiddle.net/nrjvmqur/embedded/result/ CONS: text element is not working. It could be my next goal.
If every object (circles etc) are converted first to paths, then taking transforms into account is rather easy. I made a testbed ( http://jsbin.com/oqojan/73 ) where you can test the functionality. The testbed creates random path commands and applies random transforms to paths and then flattens transforms. Of course in reality the path commands and transforms are not random, but for testing accuracy it is fine. There is a function flatten_transformations(), which makes the main task: function flatten_transformations(path_elem, normalize_path, to_relative, dec) { // Rounding coordinates to dec decimals if (dec || dec === 0) { if (dec > 15) dec = 15; else if (dec < 0) dec = 0; } else dec = false; function r(num) { if (dec !== false) return Math.round(num * Math.pow(10, dec)) / Math.pow(10, dec); else return num; } // For arc parameter rounding var arc_dec = (dec !== false) ? 6 : false; arc_dec = (dec && dec > 6) ? dec : arc_dec; function ra(num) { if (arc_dec !== false) return Math.round(num * Math.pow(10, arc_dec)) / Math.pow(10, arc_dec); else return num; } var arr; //var pathDOM = path_elem.node; var pathDOM = path_elem; var d = pathDOM.getAttribute("d").trim(); // If you want to retain current path commans, set normalize_path to false if (!normalize_path) { // Set to false to prevent possible re-normalization. arr = Raphael.parsePathString(d); // str to array arr = Raphael._pathToAbsolute(arr); // mahvstcsqz -> uppercase } // If you want to modify path data using nonAffine methods, // set normalize_path to true else arr = Raphael.path2curve(d); // mahvstcsqz -> MC var svgDOM = pathDOM.ownerSVGElement; // Get the relation matrix that converts path coordinates // to SVGroot's coordinate space var matrix = pathDOM.getTransformToElement(svgDOM); // The following code can bake transformations // both normalized and non-normalized data // Coordinates have to be Absolute in the following var i = 0, j, m = arr.length, letter = "", x = 0, y = 0, point, newcoords = [], pt = svgDOM.createSVGPoint(), subpath_start = {}; subpath_start.x = ""; subpath_start.y = ""; for (; i < m; i++) { letter = arr[i][0].toUpperCase(); newcoords[i] = []; newcoords[i][0] = arr[i][0]; if (letter == "A") { x = arr[i][6]; y = arr[i][7]; pt.x = arr[i][6]; pt.y = arr[i][7]; newcoords[i] = arc_transform(arr[i][4], arr[i][5], arr[i][6], arr[i][4], arr[i][5], pt, matrix); // rounding arc parameters // x,y are rounded normally // other parameters at least to 5 decimals // because they affect more than x,y rounding newcoords[i][7] = ra(newcoords[i][8]); //rx newcoords[i][9] = ra(newcoords[i][10]); //ry newcoords[i][11] = ra(newcoords[i][12]); //x-axis-rotation newcoords[i][6] = r(newcoords[i][6]); //x newcoords[i][7] = r(newcoords[i][7]); //y } else if (letter != "Z") { // parse other segs than Z and A for (j = 1; j < arr[i].length; j = j + 2) { if (letter == "V") y = arr[i][j]; else if (letter == "H") x = arr[i][j]; else { x = arr[i][j]; y = arr[i][j + 1]; } pt.x = x; pt.y = y; point = pt.matrixTransform(matrix); newcoords[i][j] = r(point.x); newcoords[i][j + 1] = r(point.y); } } if ((letter != "Z" && subpath_start.x == "") || letter == "M") { subpath_start.x = x; subpath_start.y = y; } if (letter == "Z") { x = subpath_start.x; y = subpath_start.y; } if (letter == "V" || letter == "H") newcoords[i][0] = "L"; } if (to_relative) newcoords = Raphael.pathToRelative(newcoords); newcoords = newcoords.flatten().join(" ").replace(/\s*([A-Z])\s*/gi, "$1").replace(/\s*([-])/gi, "$1"); return newcoords; } // function flatten_transformations // Helper tool to piece together Raphael's paths into strings again Array.prototype.flatten || (Array.prototype.flatten = function() { return this.reduce(function(a, b) { return a.concat('function' === typeof b.flatten ? b.flatten() : b); }, []); }); The code uses Raphael.pathToRelative(), Raphael._pathToAbsolute() and Raphael.path2curve(). The Raphael.path2curve() is bugfixed version. If flatten_transformations() is called using argument normalize_path=true, then all commands are converted to Cubics and everything is fine. And the code can be simplified by removing if (letter == "A") { ... } and also removing handling of H, V and Z. The simplified version can be something like this. But because someone may want to only bake transformations and not to make All Segs -> Cubics normalization, I added there a possibility to this. So, if you want to flatten transformations with normalize_path=false, this means that Elliptical Arc parameters have to be flattened also and it's not possible to handle them by simply applying matrix to coordinates. Two radiis (rx ry), x-axis-rotation, large-arc-flag and sweep-flag have to handle separately. So the following function can flatten transformations of Arcs. The matrix parameter is a relation matrix which comes from is used already in flatten_transformations(). // Origin: http://devmaster.net/forums/topic/4947-transforming-an-ellipse/ function arc_transform(a_rh, a_rv, a_offsetrot, large_arc_flag, sweep_flag, endpoint, matrix, svgDOM) { function NEARZERO(B) { if (Math.abs(B) < 0.0000000000000001) return true; else return false; } var rh, rv, rot; var m = []; // matrix representation of transformed ellipse var s, c; // sin and cos helpers (the former offset rotation) var A, B, C; // ellipse implicit equation: var ac, A2, C2; // helpers for angle and halfaxis-extraction. rh = a_rh; rv = a_rv; a_offsetrot = a_offsetrot * (Math.PI / 180); // deg->rad rot = a_offsetrot; s = parseFloat(Math.sin(rot)); c = parseFloat(Math.cos(rot)); // build ellipse representation matrix (unit circle transformation). // the 2x2 matrix multiplication with the upper 2x2 of a_mat is inlined. m[0] = matrix.a * +rh * c + matrix.c * rh * s; m[1] = matrix.b * +rh * c + matrix.d * rh * s; m[2] = matrix.a * -rv * s + matrix.c * rv * c; m[3] = matrix.b * -rv * s + matrix.d * rv * c; // to implict equation (centered) A = (m[0] * m[0]) + (m[2] * m[2]); C = (m[1] * m[1]) + (m[3] * m[3]); B = (m[0] * m[1] + m[2] * m[3]) * 2.0; // precalculate distance A to C ac = A - C; // convert implicit equation to angle and halfaxis: if (NEARZERO(B)) { a_offsetrot = 0; A2 = A; C2 = C; } else { if (NEARZERO(ac)) { A2 = A + B * 0.5; C2 = A - B * 0.5; a_offsetrot = Math.PI / 4.0; } else { // Precalculate radical: var K = 1 + B * B / (ac * ac); // Clamp (precision issues might need this.. not likely, but better save than sorry) if (K < 0) K = 0; else K = Math.sqrt(K); A2 = 0.5 * (A + C + K * ac); C2 = 0.5 * (A + C - K * ac); a_offsetrot = 0.5 * Math.atan2(B, ac); } } // This can get slightly below zero due to rounding issues. // it's save to clamp to zero in this case (this yields a zero length halfaxis) if (A2 < 0) A2 = 0; else A2 = Math.sqrt(A2); if (C2 < 0) C2 = 0; else C2 = Math.sqrt(C2); // now A2 and C2 are half-axis: if (ac <= 0) { a_rv = A2; a_rh = C2; } else { a_rv = C2; a_rh = A2; } // If the transformation matrix contain a mirror-component // winding order of the ellise needs to be changed. if ((matrix.a * matrix.d) - (matrix.b * matrix.c) < 0) { if (!sweep_flag) sweep_flag = 1; else sweep_flag = 0; } // Finally, transform arc endpoint. This takes care about the // translational part which we ignored at the whole math-showdown above. endpoint = endpoint.matrixTransform(matrix); // Radians back to degrees a_offsetrot = a_offsetrot * 180 / Math.PI; var r = ["A", a_rh, a_rv, a_offsetrot, large_arc_flag, sweep_flag, endpoint.x, endpoint.y]; return r; } OLD EXAMPLE: I made an example that has a path with segments M Q A A Q M, which has transformations applied. The path is inside g that also has trans applied. And to make very sure this g is inside another g which has different transformations applied. And the code can: A) First normalize those all path segments (thanks to Raphaël's path2curve, to which I made a bug fix, and after this fix all possible path segment combinations worked finally: http://jsbin.com/oqojan/42. The original Raphaël 2.1.0 has buggy behavior as you can see here, if not click paths few times to generate new curves.) B) Then flatten transformations using native functions getTransformToElement(), createSVGPoint() and matrixTransform(). The only one that lacks is the way to convert Circles, Rectangles and Polygons to path commands, but as far as I know, you have an excellent code for it.
This is an updated log of any forward progress I am making as an 'answer', to help inform others; if I somehow solve the problem on my own, I'll just accept this. Update 1: I've got the absolute arcto command working perfectly except in cases of non-uniform scale. Here were the additions: // Extract rotation and scale from the transform var rotation = Math.atan2(transform.b,transform.d)*180/Math.PI; var sx = Math.sqrt(transform.a*transform.a+transform.c*transform.c); var sy = Math.sqrt(transform.b*transform.b+transform.d*transform.d); //inside the processing of segments if (seg.angle != null){ seg.angle += rotation; // FIXME; only works for uniform scale seg.r1 *= sx; seg.r2 *= sy; } Thanks to this answer for a simpler extraction method than I was using, and for the math for extracting non-uniform scale.
As long as you translate all coordinates to absolute coordinates, all béziers will work just fine; there is nothing magical about the their handles. As for the elliptical arc commands, the only general solution (handling non-uniform scaling, as you point out, which the arc command can not represent, in the general case) is to first convert them to their bézier approximations. https://github.com/johan/svg-js-utils/blob/df605f3e21cc7fcd2d604eb318fb2466fd6d63a7/paths.js#L56..L113 (uses absolutizePath in the same file, a straight port of your Convert SVG Path to Absolute Commands hack) does the former, but not yet the latter. How to best approximate a geometrical arc with a Bezier curve? links the math for converting arcs to béziers (one bézier segment per 0 < α <= π/2 arc segment); this paper shows the equations at the end of the page (its prettier pdf rendition has it at the end of section 3.4.1).
How to interpolate hue values in HSV colour space?
I'm trying to interpolate between two colours in HSV colour space to produce a smooth colour gradient. I'm using a linear interpolation, eg: h = (1 - p) * h1 + p * h2 s = (1 - p) * s1 + p * s2 v = (1 - p) * v1 + p * v2 (where p is the percentage, and h1, h2, s1, s2, v1, v2 are the hue, saturation and value components of the two colours) This produces a good result for s and v but not for h. As the hue component is an angle, the calculation needs to work out the shortest distance between h1 and h2 and then do the interpolation in the right direction (either clockwise or anti-clockwise). What formula or algorithm should I use? EDIT: By following Jack's suggestions I modified my JavaScript gradient function and it works well. For anyone interested, here's what I ended up with: // create gradient from yellow to red to black with 100 steps var gradient = hsbGradient(100, [{h:0.14, s:0.5, b:1}, {h:0, s:1, b:1}, {h:0, s:1, b:0}]); function hsbGradient(steps, colours) { var parts = colours.length - 1; var gradient = new Array(steps); var gradientIndex = 0; var partSteps = Math.floor(steps / parts); var remainder = steps - (partSteps * parts); for (var col = 0; col < parts; col++) { // get colours var c1 = colours[col], c2 = colours[col + 1]; // determine clockwise and counter-clockwise distance between hues var distCCW = (c1.h >= c2.h) ? c1.h - c2.h : 1 + c1.h - c2.h; distCW = (c1.h >= c2.h) ? 1 + c2.h - c1.h : c2.h - c1.h; // ensure we get the right number of steps by adding remainder to final part if (col == parts - 1) partSteps += remainder; // make gradient for this part for (var step = 0; step < partSteps; step ++) { var p = step / partSteps; // interpolate h, s, b var h = (distCW <= distCCW) ? c1.h + (distCW * p) : c1.h - (distCCW * p); if (h < 0) h = 1 + h; if (h > 1) h = h - 1; var s = (1 - p) * c1.s + p * c2.s; var b = (1 - p) * c1.b + p * c2.b; // add to gradient array gradient[gradientIndex] = {h:h, s:s, b:b}; gradientIndex ++; } } return gradient; }
You should just need to find out which is the shortest path from starting hue to ending hue. This can be done easily since hue values range from 0 to 255. You can first subtract the lower hue from the higher one, then add 256 to the lower one to check again the difference with swapped operands. int maxCCW = higherHue - lowerHue; int maxCW = (lowerHue+256) - higherHue; So you'll obtain two values, the greater one decides if you should go clockwise or counterclockwise. Then you'll have to find a way to make the interpolation operate on modulo 256 of the hue, so if you are interpolating from 246 to 20 if the coefficient is >= 0.5f you should reset hue to 0 (since it reaches 256 and hue = hue%256 in any case). Actually if you don't care about hue while interpolating over the 0 but just apply modulo operator after calculating the new hue it should work anyway.
Although this answer is late, the accepted one is incorrect in stating that hue should be within [0, 255]; also more justice can be done with clearer explanation and code. Hue is an angular value in the interval [0, 360); a full circle where 0 = 360. The HSV colour space is easier to visualize and is more intuitive to humans then RGB. HSV forms a cylinder from which a slice is shown in many colour pickers, while RGB is really a cube and isn't really a good choice for a colour picker; most ones which do use it would have to employ more sliders than required for a HSV picker. The requirement when interpolating hue is that the smaller arc is chosen to reach from one hue to another. So given two hue values, there are four possibilities, given with example angles below: Δ | ≤ 180 | > 180 --|---------|--------- + | 40, 60 | 310, 10 − | 60, 40 | 10, 310 if Δ = 180 then both +/− rotation are valid options Lets take + as counter-clockwise and − as clockwise rotation. If the difference in absolute value exceeds 180 then normalize it by ± 360 to make sure the magnitude is within 180; this also reverses the direction, rightly. var d = h2 - h1; var delta = d + ((Math.abs(d) > 180) ? ((d < 0) ? 360 : -360) : 0); Now just divide delta by the required number of steps to get the weight of each loop iteration to add to the start angle during interpolating. var new_angle = start + (i * delta); Relevant function excerpted from the complete code that follows: function interpolate(h1, h2, steps) { var d = h2 - h1; var delta = (d + ((Math.abs(d) > 180) ? ((d < 0) ? 360 : -360) : 0)) / (steps + 1.0); var turns = []; for (var i = 1; d && i <= steps; ++i) turns.push(((h1 + (delta * i)) + 360) % 360); return turns; } "use strict"; function interpolate(h1, h2, steps) { var d = h2 - h1; var delta = (d + ((Math.abs(d) > 180) ? ((d < 0) ? 360 : -360) : 0)) / (steps + 1.0); var turns = []; for (var i = 1; d && i <= steps; ++i) turns.push(((h1 + (delta * i)) + 360) % 360); return turns; } function get_results(h1, h2, steps) { h1 = norm_angle(h1); h2 = norm_angle(h2); var r = "Start: " + h1 + "<br />"; var turns = interpolate(h1, h2, steps); r += turns.length ? "Turn: " : ""; r += turns.join("<br />Turn: "); r += (turns.length ? "<br />" : "") + "Stop: " + h2; return r; } function run() { var h1 = get_angle(document.getElementById('h1').value); var h2 = get_angle(document.getElementById('h2').value); var steps = get_num(document.getElementById('steps').value); var result = get_results(h1, h2, steps); document.getElementById('res').innerHTML = result; } function get_num(s) { var n = parseFloat(s); return (isNaN(n) || !isFinite(n)) ? 0 : n; } function get_angle(s) { return get_num(s) % 360; } function norm_angle(a) { a %= 360; a += (a < 0) ? 360 : 0; return a; } <h1 id="title">Hue Interpolation</h1> Angle 1 <input type="text" id="h1" /> <br />Angle 2 <input type="text" id="h2" /> <br /> <br />Intermediate steps <input type="text" id="steps" value="5" /> <br /> <br/> <input type="submit" value="Run" onclick="run()" /> <p id="res"></p>