Subtracting SVG paths programmatically - javascript

I'm trying to find a way to subtract a SVG path from another, similar to an inverse clip mask. I can not use filters because I will need to find the intersection points of the compound path with other paths. Illustrator does this with the 'minus front' pathfinder tool like this:
The path of the red square before subtracting:
<rect class="cls-1" x="0.5" y="0.5" width="184.93" height="178.08"/>
After subtraction:
<polygon class="cls-1" points="112.83 52.55 185.43 52.55 185.43 0.5 0.5 0.5 0.5 178.58 112.83 178.58 112.83 52.55"/>
I need this to work with all types of shapes, including curves. If it matters, the input SVGs will all be transformed into generic paths.

You might use paper.js for this task.
The following example also employs Jarek Foksa's pathData polyfill.
paper.js example
var svg = document.querySelector("#svgSubtract");
// set auto ids for processing
function setAutoIDs(svg) {
let svgtEls = svg.querySelectorAll(
"path, polygon, rect, circle, line, text, g"
);
svgtEls.forEach(function(el, i) {
if (!el.getAttribute("id")) {
el.id = el.nodeName + "-" + i;
}
});
}
setAutoIDs(svg);
function shapesToPath(svg) {
let els = svg.querySelectorAll('rect, circle, polygon');
els.forEach(function(el, i) {
let className = el.getAttribute('class');
let id = el.id;
let d = el.getAttribute('d');
let fill = el.getAttribute('fill');
let pathData = el.getPathData({
normalize: true
});
let pathTmp = document.createElementNS("http://www.w3.org/2000/svg", 'path');
pathTmp.id = id;
pathTmp.setAttribute('class', className);
pathTmp.setAttribute('fill', fill);
pathTmp.setPathData(pathData);
svg.insertBefore(pathTmp, el);
el.remove();
})
};
shapesToPath(svg);
function subtract(svg) {
// init paper.js and add mandatory canvas
canvas = document.createElement('canvas');
canvas.id = "canvasPaper";
canvas.setAttribute('style', 'display:none')
document.body.appendChild(canvas);
paper.setup("canvasPaper");
let all = paper.project.importSVG(svg, function(item, i) {
let items = item.getItems();
// remove first item not containing path data
items.shift();
// get id names for selecting svg elements after processing
let ids = Object.keys(item._namedChildren);
if (items.length) {
let lastEl = items[items.length - 1];
// subtract paper.js objects
let subtracted = items[0].subtract(lastEl);
// convert subtracted paper.js object to svg pathData
let subtractedData = subtracted
.exportSVG({
precision: 3
})
.getAttribute("d");
let svgElFirst = svg.querySelector('#' + ids[0]);
let svgElLast = svg.querySelector('#' + ids[ids.length - 1]);
// overwrite original svg path
svgElFirst.setAttribute("d", subtractedData);
// delete subtracted svg path
svgElLast.remove();
}
});
}
svg {
display: inline-block;
width: 25%;
border: 1px solid #ccc
}
<script src="https://cdn.jsdelivr.net/npm/path-data-polyfill#1.0.3/path-data-polyfill.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/paper.js/0.12.0/paper-full.min.js"></script>
<p>
<button type="button" onclick="subtract(svg)">Subtract Path </button>
</p>
<svg id="svgSubtract" viewBox="0 0 100 100">
<rect class="cls-1" x="0" y="0" width="80" height="80" fill="red" />
<path d="M87.9,78.7C87.9,84,86,88,82.2,91c-3.8,2.9-8.9,4.4-15.4,4.4c-7,0-12.5-0.9-16.2-2.7v-6.7c2.4,1,5.1,1.8,8,2.4
c2.9,0.6,5.7,0.9,8.5,0.9c4.6,0,8.1-0.9,10.4-2.6c2.3-1.7,3.5-4.2,3.5-7.3c0-2.1-0.4-3.7-1.2-5.1c-0.8-1.3-2.2-2.5-4.1-3.6
c-1.9-1.1-4.9-2.4-8.8-3.8c-5.5-2-9.5-4.3-11.8-7c-2.4-2.7-3.6-6.2-3.6-10.6c0-4.6,1.7-8.2,5.2-10.9c3.4-2.7,8-4.1,13.6-4.1
c5.9,0,11.3,1.1,16.3,3.2l-2.2,6c-4.9-2.1-9.7-3.1-14.3-3.1c-3.7,0-6.5,0.8-8.6,2.4c-2.1,1.6-3.1,3.8-3.1,6.6
c0,2.1,0.4,3.7,1.1,5.1c0.8,1.3,2,2.5,3.8,3.6c1.8,1.1,4.6,2.3,8.3,3.6c6.2,2.2,10.5,4.6,12.9,7.1C86.7,71.4,87.9,74.7,87.9,78.7z"
/>
</svg>
Path normalization (using getPathData() polyfill)
We need to convert svg primitives (<rect>, <circle>, <polygon>)
to <path> elements – at least when using paper.js Boolean operations.
This step is not needed for shapes natively created as paper.js objects.
The pathData polyfill provides a method of normalizing svg elements.
This normalization will output a d attribute (for every selected svg child element) containing only a reduced set of cubic path commands (M, C, L, Z) – all based on absolute coordinates.
Example 2 (multiple elements to be subtracted)
const svg = document.querySelector("#svgSubtract");
const btnDownload = document.querySelector("#btnDownload");
const decimals = 1;
// set auto ids for processing
function setAutoIDs(svg) {
let svgtEls = svg.querySelectorAll(
"path, polygon, rect, circle, line, text, g"
);
svgtEls.forEach(function(el, i) {
if (!el.getAttribute("id")) {
el.id = el.nodeName + "-" + i;
}
});
}
setAutoIDs(svg);
function shapesToPathMerged(svg) {
let els = svg.querySelectorAll('path, rect, circle, polygon, ellipse ');
let pathsCombinedData = '';
let className = els[1].getAttribute('class');
let id = els[1].id;
let d = els[1].getAttribute('d');
let fill = els[1].getAttribute('fill');
els.forEach(function(el, i) {
let pathData = el.getPathData({
normalize: true
});
if (i == 0 && el.nodeName.toLowerCase() != 'path') {
let firstTmp = document.createElementNS("http://www.w3.org/2000/svg", 'path');
let firstClassName = els[1].getAttribute('class');
let firstId = el.id;
let firstFill = el.getAttribute('fill');
firstTmp.setPathData(pathData);
firstTmp.id = firstId;
firstTmp.setAttribute('class', firstClassName);
firstTmp.setAttribute('fill', firstFill);
svg.insertBefore(firstTmp, el);
el.remove();
}
if (i > 0) {
pathData.forEach(function(command, c) {
pathsCombinedData += ' ' + command['type'] + '' + command['values'].join(' ');
});
el.remove();
}
})
let pathTmp = document.createElementNS("http://www.w3.org/2000/svg", 'path');
pathTmp.id = id;
pathTmp.setAttribute('class', className);
pathTmp.setAttribute('fill', fill);
pathTmp.setAttribute('d', pathsCombinedData);
svg.insertBefore(pathTmp, els[0].nextElementSibling);
};
shapesToPathMerged(svg);
function subtract(svg) {
// init paper.js and add mandatory canvas
canvas = document.createElement('canvas');
canvas.id = "canvasPaper";
canvas.setAttribute('style', 'display:none')
document.body.appendChild(canvas);
paper.setup("canvasPaper");
let all = paper.project.importSVG(svg, function(item, i) {
let items = item.getItems();
// remove first item not containing path data
items.shift();
// get id names for selecting svg elements after processing
let ids = Object.keys(item._namedChildren);
if (items.length) {
let lastEl = items[items.length - 1];
// subtract paper.js objects
let subtracted = items[0].subtract(lastEl);
// convert subtracted paper.js object to svg pathData
let subtractedData = subtracted
.exportSVG({
precision: decimals
})
.getAttribute("d");
let svgElFirst = svg.querySelector('#' + ids[0]);
let svgElLast = svg.querySelector('#' + ids[ids.length - 1]);
// overwrite original svg path
svgElFirst.setAttribute("d", subtractedData);
// delete subtracted svg path
svgElLast.remove();
}
});
// get data URL
getdataURL(svg)
}
function getdataURL(svg) {
let markup = svg.outerHTML;
markupOpt = 'data:image/svg+xml;utf8,' + markup.replaceAll('"', '\'').
replaceAll('\t', '').
replaceAll('\n', '').
replaceAll('\r', '').
replaceAll('></path>', '/>').
replaceAll('<', '%3C').
replaceAll('>', '%3E').
replaceAll('#', '%23').
replaceAll(',', ' ').
replaceAll(' -', '-').
replace(/ +(?= )/g, '');
let btn = document.createElement('a');
btn.href = markupOpt;
btn.innerText = 'Download Svg';
btn.setAttribute('download', 'subtracted.svg');
document.body.insertAdjacentElement('afterbegin', btn);
return markupOpt;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/paper.js/0.12.0/paper-full.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/path-data-polyfill#1.0.3/path-data-polyfill.min.js"></script>
<p>
<button type="button" onclick="subtract(svg)">Subtract Path </button>
</p>
<svg id="svgSubtract" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<rect class="cls-1" x="0" y="0" width="80" height="80" fill="red" />
<path id="s"
d="M87.9,78.7C87.9,84,86,88,82.2,91c-3.8,2.9-8.9,4.4-15.4,4.4c-7,0-12.5-0.9-16.2-2.7v-6.7c2.4,1,5.1,1.8,8,2.4
c2.9,0.6,5.7,0.9,8.5,0.9c4.6,0,8.1-0.9,10.4-2.6c2.3-1.7,3.5-4.2,3.5-7.3c0-2.1-0.4-3.7-1.2-5.1c-0.8-1.3-2.2-2.5-4.1-3.6
c-1.9-1.1-4.9-2.4-8.8-3.8c-5.5-2-9.5-4.3-11.8-7c-2.4-2.7-3.6-6.2-3.6-10.6c0-4.6,1.7-8.2,5.2-10.9c3.4-2.7,8-4.1,13.6-4.1
c5.9,0,11.3,1.1,16.3,3.2l-2.2,6c-4.9-2.1-9.7-3.1-14.3-3.1c-3.7,0-6.5,0.8-8.6,2.4c-2.1,1.6-3.1,3.8-3.1,6.6
c0,2.1,0.4,3.7,1.1,5.1c0.8,1.3,2,2.5,3.8,3.6c1.8,1.1,4.6,2.3,8.3,3.6c6.2,2.2,10.5,4.6,12.9,7.1C86.7,71.4,87.9,74.7,87.9,78.7z" />
<path id="o" d="M30.2,22.4c0,8.3-5.8,12-11.2,12c-6.1,0-10.8-4.5-10.8-11.6c0-7.5,4.9-12,11.2-12C25.9,10.8,30.2,15.5,30.2,22.4z
M12.4,22.6c0,4.9,2.8,8.7,6.8,8.7c3.9,0,6.8-3.7,6.8-8.7c0-3.8-1.9-8.7-6.7-8.7C14.5,13.8,12.4,18.3,12.4,22.6z" />
<circle cx="50%" cy="50%" r="10%"></circle>
</svg>

This is a nontrivial problem in general.
It can be solved easily (little code) if you can accept rasterizing the shapes to pixels, perform the boolean operation there, and then vectorize back the result using marching squares + simplification.
Known algorithms to compute instead a somewhat exact* geometric result are quite complex and difficult to implement correctly while keeping them fast.
Clipper is an easy to use library to perform this kind of computation in C++, with ports to Javascript.
Please note that what is difficult is to correctly handle edge cases (e.g. when input lines are partially overlapping or vertices fall exactly on a line and when the result includes zero-area parts).
Code that only reasons about cases in which crossings are clear is a lot easier to write, but unfortunately can produce results that are macroscopically wrong when those edge cases do actually happen.
Floating point math is too unpredictable to be used for these computations... see for example https://hal.inria.fr/inria-00344310/document for a detailed discussion of the kind of issues that will be present when using floating point math for exact geometric computation.
Even a "simple" equation like the one that tells if three points are collinear, clock-wise or counter-clokwise behave crazily when computed with floating point math... (images from the paper)
(*) The coordinates of the intersection of two segments with integer coordinates cannot, in general, be represented exactly by double-precision numbers; thus the result will still be an approximation unless you use a numeric library with support for rationals.

Related

d3.js insert alternated siblings with selection.join

I'm on d3.js v.7.
I need to build a linear gradient with hard stops (i.e., color 0 from 0 to x, color 1 from x to y, ... color n from z to 1). stop list has an arbitrary length and it is defined by a matrix like the following:
const matrix = [
[{start: 0, stop:0.5, color: 'blue'}, {start: 0.5, stop:1, color: 'teal'}],
[{start: 0, stop:0.2, color: 'blue'}, {start: 0.2, stop:1, color: 'teal'}]
];
that should be rendered as follows:
<defs>
<lineargradient id="grad_0">
<!--stop 0 -->
<stop offset="0" stop-color="blue"></stop>
<stop offset="0.5" stop-color="blue"></stop>
<!--stop 1 -->
<stop offset="0.5" stop-color="teal"></stop>
<stop offset="1" stop-color="teal"></stop>
</lineargradient>
<lineargradient id="grad_1">
<!--stop 0 -->
<stop offset="0" stop-color="blue"></stop>
<stop offset="0.2" stop-color="blue"></stop>
<!--stop 1 -->
<stop offset="0.2" stop-color="teal"></stop>
<stop offset="1" stop-color="teal"></stop>
</lineargradient>
</defs>
NB: look at the stop-color elements: stops follow the matrix nesting
Starting from Mike Bostock's Nested Selections, I tried with this solution:
const defs = d3.select('body').append('defs');
const linearGradients = defs
.selectAll('linearGradient')
.data(matrix)
.join('linearGradient')
.attr('id', (d, i) => `grad_${i}`);
linearGradients
.selectAll('stop')
.data(d => d) //matrix[j]
.join((enter) => {
enter
.append('stop')
.attr('offset', (d) => d.start)
.attr('stop-color', (d) => d.color);
enter
.append('stop')
.attr('offset', (d) => d.stop)
.attr('stop-color', (d) => d.color);
});
However, stops do not follow the matrix nesting and they are not intercalated. This is what I get with the code above:
<defs>
<lineargradient id="grad_0">
<stop offset="0" stop-color="blue"></stop>
<stop offset="0.5" stop-color="teal"></stop>
<stop offset="0.5" stop-color="blue"></stop>
<stop offset="1" stop-color="teal"></stop>
</lineargradient>
<lineargradient id="grad_1">
<stop offset="0" stop-color="blue"></stop>
<stop offset="0.2" stop-color="teal"></stop>
<stop offset="0.2" stop-color="blue"></stop>
<stop offset="1" stop-color="teal"></stop>
</lineargradient>
</defs>
...and it's wrong!
I have already checked out answers like this one, but they do not work anymore with newer versions of d3.
Here is a working example to play with: https://codepen.io/floatingpurr/pen/RwpvvPq
Is there any idiomatic and working solution?
As noted in the links: D3 generally expects one item in a data array to one element in the DOM. This is part of the foundation of its data binding method. This approach still allows:
multiple children to be appended to parents where the parent datum contains an array (nested data), or
multiple different types of children (eg: label, node) that have a one to one relationship with their parent.
We can't do one because our nested data doesn't contain an array of all the elements we wish to enter.
We also can't do two because we don't have a one to one relationship between parent and child - we have nested data after all.
Sometimes you can cheat and reuse the enter selection to double elements or use classes to enter elements many times, but these approaches are predicated on indifference to the order of the elements in the DOM.
But, when order matters, and we need multiple elements in the DOM for each item in the data array, we need a different approach. As noted in the comments, an equivalent problem is update/exiting/entering pairs of <dd> and <dt> elements since strictly speaking a described list doesn't allow any parent that could group these pairs for ordering and organization. Same as <stop> elements in a <linearGradient>, as is your case.
There are four classes of solution that come to mind:
Modify the data so one element corresponds to one item in the data array
Abuse d3-selection
Get creative in sorting
Use selection.html() to manually add the child elements
Modify the data so one element corresponds to one item in the data array.
The first is probably the most straightforward in that it would be the most canonical and require little consideration about how to modify the D3 code. Below I use a function to take your input data and create one data array item for each stop - essentially cracking each existing item into two. To do so we should probably use common properties, so instead of start and end properties we'll just have offset values in all:
const matrix = [
[{start: 0, stop:0.5, color: 'blue'}, {start: 0.5, stop:1, color: 'teal'}],
[{start: 0, stop:0.2, color: 'blue'}, {start: 0.2, stop:1, color: 'teal'}]
];
// produce new data array:
function processData(data) {
let newData = data.map(function(gradient) {
let stops = [];
gradient.forEach(function(stop) {
stops.push({offset: stop.start, color: stop.color})
stops.push({offset: stop.stop, color: stop.color})
})
return stops;
})
return newData;
}
console.log(processData(matrix));
Here it is in work below with some random data that is constantly updated:
// produce new data array:
function processData(data) {
let newData = data.map(function(gradient) {
let stops = [];
gradient.forEach(function(stop) {
stops.push({offset: stop.start, color: stop.color})
stops.push({offset: stop.stop, color: stop.color})
})
return stops;
})
return newData;
}
//Basic set up:
var defs = d3.select("defs");
var svg = d3.select("svg");
update();
setInterval(update, 1000);
// Update with new data:
function update() {
var data = randomGradients();
data = processData(data);
// update gradients:
var grads = defs.selectAll("linearGradient")
.data(data)
.join("linearGradient")
.attr("id",(d,i)=>"grad_"+i);
var pairs = grads.selectAll("stop")
.data(d=>d)
.join("stop")
.transition()
.attr("stop-color",d=>d.color)
.attr("offset",d=>d.offset);
// update rectangles:
svg.selectAll("rect")
.data(grads.data())
.join("rect")
.attr("y", (d,i)=>i*60+5)
.attr("x", 10)
.attr("width", 360)
.attr("height", 50)
.attr("fill",(d,i)=>"url(#grad_"+i+")");
}
// Random Data Generator Functions:
function createStop(a,b,c) {
return {start: a, stop: b, color: c};
}
function randomData(n) {
let colorSchemes = [
d3.schemeBlues,
d3.schemeReds,
d3.schemePurples,
d3.schemeGreens,
d3.schemeGreys,
d3.schemeOranges
];
let points = d3.range(n-1).map(function(i) {
return Math.random()
}).sort()
points.push(1);
let stops = [];
let start = 0;
let end = 1;
let r = Math.floor(Math.random()*(colorSchemes.length));
let colors = colorSchemes[r][n];
for (let i = 0; i < n; i++) {
stops.push(createStop(start,points[i],colors[i]));
start = points[i];
}
return stops;
}
function randomGradients() {
let gradients = [];
let n = Math.floor(Math.random()*5)+1
let m = Math.floor(Math.random()*3)+3;
for(var i = 0; i < n; i++) {
gradients.push(randomData(m));
}
return gradients;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<svg width="500" height="400">
<defs>
</defs>
<rect width="300" height="50" fill="url(#grad_0)">
</rect>
</svg>
This works when we have the same type of element to append for each item in the data array (stop) elements, but we'd need to be more creative if we had different types of items as in the dd/dt case. append() does allow variable tags (fourth approach listed in linked answer) to be appended, but things will get a bit more complicated when updating.
Abuse d3-selection
I add this for completeness and because it is an interesting case, you should in most circumstances change the data structure as in the option above, though extending D3 can at times be the clearly preferred option
You could do this in a number of ways. I've decided to create a new d3-selection method, but this isn't necessary. This approach requires a bit more care and may have some possible unintentional consequences (I think I've done well in avoiding most) - but preserves your data structure. I've created a method selection.joinPair() which takes a single parameter: the type of element we'd like to have two of for each item in the data array. It returns a selection of all the elements, but also has two methods: first() and second() which return the first or second set of elements respectively. It assumes the parent's datum is the data array for the child elements as is our case here. (I may revise the snippet below later to make it a bit to be a bit more flexible, but it is provided more as a demonstration rather than as a final product or potential d3 module.
// New d3 method to join pairs:
d3.selection.prototype.joinPairs = function(type) {
let parents = this; // selection this is called on.
// select every even, odd element in two selections, set data
let a = parents.selectAll(type+":nth-child(2n+1)")
.data((d,i)=>parents.data()[i]);
let b = parents.selectAll(type+":nth-child(2n+2)")
.data((d,i)=>parents.data()[i]);
// remove unneeded children:
a.exit().remove();
b.exit().remove();
// enter, as we enter in pairs, we can use selection.clone()
// which enters a new element immediately after the cloned node in the DOM.
enterA = a.enter().append(type);
enterB = enterA.clone();
// return the selection of all elements, but allow access to odds/evens separately:
let sel = parents.selectAll(type);
sel.first=()=>a.merge(enterA);
sel.second=()=>b.merge(enterB);
return sel;
}
And here we have at work:
// New d3 method to join pairs:
d3.selection.prototype.joinPairs = function(type) {
let parents = this;
let a = parents.selectAll(type+":nth-child(2n+1)")
.data((d,i)=>parents.data()[i]);
let b = parents.selectAll(type+":nth-child(2n+2)")
.data((d,i)=>parents.data()[i]);
a.exit().remove();
b.exit().remove();
enterA = a.enter().append(type);
enterB = enterA.clone();
let sel = parents.selectAll(type);
sel.first=()=>a.merge(enterA);
sel.second=()=>b.merge(enterB);
return sel;
}
//Basic set up:
var defs = d3.select("defs");
var svg = d3.select("svg");
update();
setInterval(update, 1000);
// Update with new data:
function update() {
// update gradients:
var grads = defs.selectAll("linearGradient")
.data(randomGradients())
.join("linearGradient")
.attr("id",(d,i)=>"grad_"+i);
var pairs = grads.joinPairs("stop")
.attr("stop-color",d=>d.color)
// set first element in pair's offset:
pairs.first().transition()
.attr("offset",d=>d.start)
// set second element in pair's offset:
pairs.second().transition()
.attr("offset",d=>d.stop)
// update rectangles:
svg.selectAll("rect")
.data(grads.data())
.join("rect")
.attr("y", (d,i)=>i*60+5)
.attr("x", 10)
.attr("width", 360)
.attr("height", 50)
.attr("fill",(d,i)=>"url(#grad_"+i+")");
}
// Random Data Generator Functions:
function createStop(a,b,c) {
return {start: a, stop: b, color: c};
}
function randomData(n) {
let colorSchemes = [
d3.schemeBlues,
d3.schemeReds,
d3.schemePurples,
d3.schemeGreens,
d3.schemeGreys,
d3.schemeOranges
];
let points = d3.range(n-1).map(function(i) {
return Math.random()
}).sort()
points.push(1);
let stops = [];
let start = 0;
let end = 1;
let r = Math.floor(Math.random()*(colorSchemes.length));
let colors = colorSchemes[r][n];
for (let i = 0; i < n; i++) {
stops.push(createStop(start,points[i],colors[i]));
start = points[i];
}
return stops;
}
function randomGradients() {
let gradients = [];
let n = Math.floor(Math.random()*5)+1
let m = Math.floor(Math.random()*3)+3;
for(var i = 0; i < n; i++) {
gradients.push(randomData(m));
}
return gradients;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<svg width="500" height="400">
<defs>
</defs>
<rect width="300" height="50" fill="url(#grad_0)">
</rect>
</svg>
This way is way less portable though, unless further developed.
Get creative in sorting
This way fits within standard D3 methods, but isn't the most typical approach. It might get messy in certain circumstances, but in this use case is rather clean - though with many items might be slower than others.
We'll enter/update/exit data based on classes, and sort data based on a property of the element. However, it is a bit challenging in that we can't use the datum to store an index as the datum is shared between elements. Luckily, as we append the "start" stop first and the "stop" stop second sorting by d.start will work as long as we append all the "starts" before all the "stops":
//Basic set up:
var defs = d3.select("defs");
var svg = d3.select("svg");
update();
setInterval(update, 1000);
// Update with new data:
function update() {
// update gradients, same as before:
var grads = defs.selectAll("linearGradient")
.data(randomGradients())
.join("linearGradient")
.attr("id",(d,i)=>"grad_"+i);
// starts:
grads.selectAll(".start")
.data(d=>d)
.join("stop")
.transition()
.attr("stop-color",d=>d.color)
.attr("offset",d=>d.start)
.attr("class","start");
// ends:
grads.selectAll(".end")
.data(d=>d)
.join("stop")
.transition()
.attr("stop-color",d=>d.color)
.attr("offset",d=>d.stop)
.attr("class","end")
// sort based on start value (where they are the same, first appended item is first):
grads.each(function() {
d3.select(this).selectAll("stop")
.sort(function(a,b) { return a.start-b.start })
})
// update rectangles:
svg.selectAll("rect")
.data(grads.data())
.join("rect")
.attr("y", (d,i)=>i*60+5)
.attr("x", 10)
.attr("width", 360)
.attr("height", 50)
.attr("fill",(d,i)=>"url(#grad_"+i+")");
}
// Random Data Generator Functions:
function createStop(a,b,c) {
return {start: a, stop: b, color: c};
}
function randomData(n) {
let colorSchemes = [
d3.schemeBlues,
d3.schemeReds,
d3.schemePurples,
d3.schemeGreens,
d3.schemeGreys,
d3.schemeOranges
];
let points = d3.range(n-1).map(function(i) {
return Math.random()
}).sort()
points.push(1);
let stops = [];
let start = 0;
let end = 1;
let r = Math.floor(Math.random()*(colorSchemes.length));
let colors = colorSchemes[r][n];
for (let i = 0; i < n; i++) {
stops.push(createStop(start,points[i],colors[i]));
start = points[i];
}
return stops;
}
function randomGradients() {
let gradients = [];
let n = Math.floor(Math.random()*5)+1
let m = Math.floor(Math.random()*3)+3;
for(var i = 0; i < n; i++) {
gradients.push(randomData(m));
}
return gradients;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<svg width="500" height="400">
<defs>
</defs>
<rect width="300" height="50" fill="url(#grad_0)">
</rect>
</svg>
I'm not going to go into depth on this option, it should be relatively straightforward as is.
Use selection.html() to manually add the child elements
This is really outside the D3 idiom, but at times such an approach may be justifiable. We'll join the linearGradients as normal, then use selection.html to create the child stops. I include it only for completeness, it is shown below:
This is even transitionable! (barely)
//Basic set up:
var defs = d3.select("defs");
var svg = d3.select("svg");
update();
setInterval(update,1000);
// Update with new data:
function update() {
// update gradients:
var grads = defs.selectAll("linearGradient")
.data(randomGradients())
.join("linearGradient")
.attr("id",(d,i)=>"grad_"+i);
grads.transition().attrTween("anything", function(d) {
var el = this;
var end = "";
d.forEach(function(s) {
end += '<stop offset="'+s.start+'" stop-color="'+d3.color(s.color).formatRgb()+'"></stop>';
end += '<stop offset="'+s.stop+'" stop-color="'+d3.color(s.color).formatRgb()+'"></stop>';
})
var start = d3.select(this).html() || end;
function interpolator(t) {
var i = d3.interpolate(start,end)(t);
d3.select(el).html(i);
return 1;
}
return interpolator
});
// update rectangles:
svg.selectAll("rect")
.data(grads.data())
.join("rect")
.attr("y", (d,i)=>i*60+5)
.attr("x", 10)
.attr("width", 360)
.attr("height", 50)
.attr("fill",(d,i)=>"url(#grad_"+i+")");
}
// Random Data Generator Functions:
function createStop(a,b,c) {
return {start: a, stop: b, color: c};
}
function randomData(n) {
let colorSchemes = [
d3.schemeBlues,
d3.schemeReds,
d3.schemePurples,
d3.schemeGreens,
d3.schemeGreys,
d3.schemeOranges
];
let points = d3.range(n-1).map(function(i) {
return Math.random()
}).sort()
points.push(1);
let stops = [];
let start = 0;
let end = 1;
let r = Math.floor(Math.random()*(colorSchemes.length));
let colors = colorSchemes[r][n];
for (let i = 0; i < n; i++) {
stops.push(createStop(start,points[i],colors[i]));
start = points[i];
}
return stops;
}
function randomGradients() {
let gradients = [];
let n = Math.floor(Math.random()*5)+1
let m = Math.floor(Math.random()*3)+3;
for(var i = 0; i < n; i++) {
gradients.push(randomData(m));
}
return gradients;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<svg width="500" height="400">
<defs>
</defs>
<rect width="300" height="50" fill="url(#grad_0)">
</rect>
</svg>
Options not included
I haven't included any options where you can't easily transition from old data to new data, so anything that uses selection.remove() to wipe the slate, or part of the slate clean in order to build from scratch is not included here.

How to render two level elements dynamically based on some conditions

suppose svg is the parent element and rect will be the child element in the final rendering.
i want to render it on different changing states
for every 5 rects there will be a single svg parent,so if rect value is 8 ,it would render it as first svg with 5 child elements and second svg with remaining 3 elements
const Svg = ({total}) => {
let rects = total
let totalSvgs = Math.ceil(rects/5)
return(
// i have tried using map and for-loops here
)
}
Summary Divide your Rects into an array of rectChunks ... which would make map straight forward...
const rects = []; /** your rect objs */
const svgs = []; /** your svg objs */
const chunkSize = 5;
const rectLength = rects.length;
const svgLength = Math.ceil(rectLength / chunkSize);
/** 1. Divide your array of rects into an array of small chunks of size 5 */
const rectChunks = [];
for (let i = 0; i < rectLength; i += chunkSize) {
const chunk = rects.slice(i, i + chunkSize);
rectChunks.push(chunk);
}
/** 2. Render */
return svgs.map((svgItem, index) => (
<Svg {...svgItem}>
{rectChunks[index].map(rectItem => (
<Rect {...rectItem} />
))}
</Svg>
));

Parse SVG transform attribute with typescript

How do I parse the transform attribute of svg elements using typescript?
That is, how can I parse all the numbers and operations in the string at svg.g.transform in the following:
<svg viewBox="-40 0 150 100" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g fill="grey"
transform="rotate(-10 50 100)
translate(-36 45.5)
skewX(40)
scale(1 0.5)">
<path id="heart" d="M 10,30 A 20,20 0,0,1 50,30 A 20,20 0,0,1 90,30 Q 90,60 50,90 Q 10,60 10,30 z" />
</g>
<use xlink:href="#heart" fill="none" stroke="red"/>
</svg>
Use the https://developer.mozilla.org/en-US/docs/Web/API/SVGGraphicsElement aka. SVGLocatable and SVGTransformable interfaces/API that is implemented by the native DOM elements.
These elements have a .transform property that corresponds to the transform attribute. This property has the type https://developer.mozilla.org/en-US/docs/Web/API/SVGAnimatedTransformList and you want to look at the statically defined baseVal.
The transform list has an attribute numberOfItems and a getItem method. It may have a .lengthproperty and [] array accessor and it may be iterable in your browser, but don't count on that.
Each item has the type https://developer.mozilla.org/en-US/docs/Web/API/SVGTransform
The .type property tells you which instruction was used.
Therefore, here is how you can parse and then manually synthesize the transform attribute again:
// javascript js equivalent declaration:
// function getAttributeTransform_js(nativeSVGElement) {
// typescript ts declaration
function getAttributeTransform_ts(nativeSVGElement: SVGGraphicsElement) {
// this definition works in ts and js
const tl = nativeSVGElement.transform.baseVal;
const st = [];
for (let i = 0; i < tl.numberOfItems; i++) {
const t/*: SVGTransform*/ = tl.getItem(i);
switch (t.type) {
case SVGTransform.SVG_TRANSFORM_UNKNOWN: break;
case SVGTransform.SVG_TRANSFORM_MATRIX: {
// A matrix(…) transformation
// Note: this is the most general transformation, capable of representing more transformations than the other combined.
// For SVG_TRANSFORM_MATRIX, the matrix contains the a, b, c, d, e, f values supplied by the user.
//
// Note: instead of comma (,), whitespace separation would also be allowed
st.push(`matrix(${t.matrix.a}, ${t.matrix.b}, ${t.matrix.c}, ${t.matrix.d}, ${t.matrix.e}, ${t.matrix.f})`);
break;
}
case SVGTransform.SVG_TRANSFORM_TRANSLATE: {
// A translate(…) transformation
// For SVG_TRANSFORM_TRANSLATE, e and f represent the translation amounts (a=1, b=0, c=0 and d=1).
st.push(`translate(${t.matrix.e}, ${t.matrix.f})`);
break;
}
case SVGTransform.SVG_TRANSFORM_SCALE: {
// A scale(…) transformation
// For SVG_TRANSFORM_SCALE, a and d represent the scale amounts (b=0, c=0, e=0 and f=0).
st.push(`scale(${t.matrix.a}, ${t.matrix.d})`);
break;
}
case SVGTransform.SVG_TRANSFORM_ROTATE: {
// A rotate(…) transformation
// For SVG_TRANSFORM_ROTATE, a, b, c, d, e and f together represent the matrix which will result in the given rotation.
// When the rotation is around the center point (0, 0), e and f will be zero.
/*
angle float A convenience attribute for SVG_TRANSFORM_ROTATE, SVG_TRANSFORM_SKEWX and SVG_TRANSFORM_SKEWY. It holds the angle that was specified.
For SVG_TRANSFORM_MATRIX, SVG_TRANSFORM_TRANSLATE and SVG_TRANSFORM_SCALE, angle will be zero.
*/
/*
This is the hardest case since the origin information is lost!
We need to recompute it from the matrix.
from https://math.stackexchange.com/questions/2093314/rotation-matrix-of-rotation-around-a-point-other-than-the-origin
matrix.a = cos_angle = c;
matrix.b = sin_angle = s;
Note that by the laws of geometry: c^2+s^2 = 1 (c and s are coordinates on the unit circle)
matrix.e = -x*c + y*s + x;
matrix.f = -x*s - y*c + y;
Using Mathematica/Wolfram Language:
"Assuming[c^2+s^2==1,Solve[e == -x*c + y*s + x&& f == -x*s - y*c + y,{x,y},Reals]//Simplify]//InputForm"
(you can use WL for free here: https://develop.wolframcloud.com/objects/c26e16f7-44e7-4bb6-81b3-bc07782f9cc5)
{{x -> (e + (f*s)/(-1 + c))/2, y -> (f - c*f + e*s)/(2 - 2*c)}}
*/
const e = t.matrix.e, f = t.matrix.f, c = t.matrix.a, s = t.matrix.b;
const originx = (e + (f*s)/(-1 + c))/2;
const originy = (f - c*f + e*s)/(2 - 2*c);
st.push(`rotate(${t.angle}, ${originx}, ${originy})`);
break;
}
case SVGTransform.SVG_TRANSFORM_SKEWX: {
// A skewx(…) transformation
// For SVG_TRANSFORM_SKEWX and SVG_TRANSFORM_SKEWY, a, b, c and d represent the matrix which will result in the given skew (e=0 and f=0).
/*
angle float A convenience attribute for SVG_TRANSFORM_ROTATE, SVG_TRANSFORM_SKEWX and SVG_TRANSFORM_SKEWY. It holds the angle that was specified.
For SVG_TRANSFORM_MATRIX, SVG_TRANSFORM_TRANSLATE and SVG_TRANSFORM_SCALE, angle will be zero.
*/
st.push(`skewx(${t.angle})`);
break;
}
case SVGTransform.SVG_TRANSFORM_SKEWY: {
// A skewy(…) transformation
// For SVG_TRANSFORM_SKEWX and SVG_TRANSFORM_SKEWY, a, b, c and d represent the matrix which will result in the given skew (e=0 and f=0).
/*
angle float A convenience attribute for SVG_TRANSFORM_ROTATE, SVG_TRANSFORM_SKEWX and SVG_TRANSFORM_SKEWY. It holds the angle that was specified.
For SVG_TRANSFORM_MATRIX, SVG_TRANSFORM_TRANSLATE and SVG_TRANSFORM_SCALE, angle will be zero.
*/
st.push(`skewy(${t.angle})`);
break;
}
}
}
return st.join(','); // instead of comma (,), whitespace separation is also allowed
}
// example
const r = <SVGRectElement>document.createElementNS("http://www.w3.org/2000/svg", "rect");
// the parseable syntax for the transform attribute is pretty relaxed
r.setAttribute("transform", "translate(1, 0),rotate(0.5), scale(1 2)");
// note that the browser may canonicalize your syntax
// EDGE canonicalizes the transform to read:
// 'translate(1) rotate(0.5) scale(1, 2)'
console.log(r.getAttribute("transform"));
// basically equivalent:
console.log(getAttributeTransform_ts(r));
Your example:
function createElementFromHTML(htmlString) {
var div = document.createElement('div');
div.innerHTML = htmlString.trim();
// Change this to div.childNodes to support multiple top-level nodes
return div.firstChild;
}
getAttributeTransform_ts(createElementFromHTML(`
<g fill="grey"
transform="rotate(-10 50 100)
translate(-36 45.5)
skewX(40)
scale(1 0.5)">
<path id="heart" d="M 10,30 A 20,20 0,0,1 50,30 A 20,20 0,0,1 90,30 Q 90,60 50,90 Q 10,60 10,30 z" />
</g>
`))
// gives
// 'rotate(-10, 49.99999999999982, 99.99999999999972),translate(-36, 45.5),skewx(40),scale(1, 0.5)'
Note that you should use .getAttribute("transform") to let the browser synthesize the string form of an SVGTransformList for you, instead of using my script above!
Note that we cannot retrieve the origin argument of "rotate" perfectly, because there is no API for it. It has to be computed from the 2d-homogeneous (rotation) matrix.
Inspired by:
Parse SVG transform attribute with javascript
https://stackoverflow.com/a/41102221/524504
See also:
https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/transform

Obtaining rendered borders of a crooked svg-object

I need to know whether an svg-object is adjacent to another one or not. Therefore I need to get its borders.
Those objects can be a rect or a path. This is fairly easy when they are straight, but I don't know how to do this when they're curved or crooked.
An example of what I mean can be found here or a short example here:
<svg id="mysvg" xmlns="http://www.w3.org/2000/svg" viewBox="0 200 512 512">
<path d="m 223.40414,282.21605 35.53211,-3.88909 0,-18.73833 -19.79899,-0.17678 c -5.83251,7.19542 -10.70707,15.0451 -15.73312,22.8042 z" id="pB"></path>
</svg>
If I use the Box corners, I create a virtual straight rectangular, which adjacent objects are not equal to the adjacents of the rendered object.
How can I do that?
The Snap library offers some nice utilities for your problem. Your question does not define what "adjacent" should really mean. Here is a function that sets a threshold for the maximal distance between to paths:
var threshold = 1;
function isAdjacent (id1, id2) {
var s = Snap("#mysvg");
var first = s.select('#' + id1);
var second = s.select('#' + id2);
var len = first.getTotalLength(first);
var p1, p2;
for (var at = 0; at <= len; at += threshold) {
p1 = first.getPointAtLength(at);
if ( Snap.closestPoint(second, p1.x, p1.y).distance <= threshold) {
return true;
}
}
return false;
}
JsFiddle

Slice path into two separate paths using paper.js

I need to 'slice' a big path into two smaller paths using a line. For example, you may have the following configuration:
After the operation, I should have two distinct closed paths. I probably should find two intersections and use Path.split function to split rectangle path, but I don't fully understand paper.js API and I am not sure about the best way to do that using exactly paper.js API.
For example, I split the original rectangle by doing the following commands:
var starting_shape = new paper.Path.Rectangle([paper.view.center.x - 200, paper.view.center.y - 200], 400);
starting_shape.strokeColor = "#aaa";
starting_shape.strokeWidth = 2;
starting_shape.fullySelected = true;
var p1 = starting_shape.split(starting_shape.getNearestLocation([paper.view.center.x - 40, paper.view.center.y - 250]));
var p2 = starting_shape.split(starting_shape.getNearestLocation([paper.view.center.x + 50, paper.view.center.y + 250]));
And I get the following:
I tried to do the following:
p1.closed = true;
p2.closed = true;
p1.position.x += 10;
I got the necessary result:
But is there a way to make it more clever?
Yes, you can use path.divide(path2) to perform a division boolean operation. If you clone the project from github, there's a test for all boolean functions in Examples > Scripts > BooleanOperations.html
I don't believe this currently works as you would like with just a line. It seems to be more stable with closed paths.
The splitUsingPath function here can split in two a complex shape using path, even one with a curve.
const rectangle = new Shape.Rectangle(new Point(200, 200), new Size(300, 300)).toPath();
const path = new Path([
new Point(300, 150),
new Segment(new Point(325, 350), new Point(-90, -90), new Point(90, 90)),
new Point(400, 550)
])
rectangle.strokeColor = 'black'
path.strokeColor = 'black'
const splitUsingPath = (target, path) => {
const paths = [path];
const targets = [target];
const originalTarget = target.clone({ insert: false })
const intersections = target.getIntersections(path)
intersections.forEach(location => {
const newTarget = target.splitAt(location)
const isNew = newTarget !== target
if (isNew) targets.push(newTarget)
paths.forEach(path => {
const offset = path.getOffsetOf(location.point)
const pathLocation = path.getLocationAt(offset)
if (pathLocation) {
paths.push(path.splitAt(pathLocation))
}
})
})
const innerPath = paths.find(p =>
originalTarget.contains(p.bounds.center))
paths
.filter(path => path !== innerPath)
.forEach(item => item.remove())
targets.forEach((target, i) => {
const isFirst = i === 0
const innerPathCopy = isFirst ? innerPath : innerPath.clone()
target.join(innerPathCopy, innerPathCopy.length)
target.closed = true
})
return targets
}
const splitPaths = splitUsingPath(rectangle, path)
splitPaths.forEach((path, i) => {
path.position.x += i * -10
})
This is a great answer that I've used many times. I noticed a little room for improvement though. Sometimes the resulting sliced path has segments (those originated from the slicing path) in wrong order. That causes segment handles pointing to the opposite directions than intended and results in path deformation.
I added a check and fix:
...
targets.forEach((target, i) => {
const isFirst = i === 0
const innerPathCopy = isFirst ? innerPath : innerPath.clone()
// THE FIX -------------------------------
// Check if the starting point of the slicing path and the ending point of the target path are at the same point (or very near).
// If so, reverse the slicing path direction and fix the segment handle directions.
if (innerPathCopy.getPointAt(0).isClose(target.getPointAt(target.length), 0.1)) innerPathCopy.reverse()
// THE FIX -------------------------------
target.join(innerPathCopy, innerPathCopy.length)
target.closed = true
...

Categories

Resources