d3.js insert alternated siblings with selection.join - javascript

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.

Related

Subtracting SVG paths programmatically

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.

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>
));

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

Conditional functions in Redux state

I have built an incrementing button to increase the string ( summing 20 to each number starting from n 3 ) in the path d for SVG elements.
The increment button does the following in my reduce function and it works fine
export default function svgs(state = [], action) {
switch(action.type) {
case 'INCREMENT_COORDINATES' :
console.log("incrementing coordinaates");
const a = action.index;
let string = state[a].d;
let array = string.split(" ");
let max = array.length;
let last = max - 2;
let i = (state[a].index || 3) + 1;
if ( i === last ) i = 3;
if (array[i] !== 'M' && array[i] !== 'L') array[i] = parseInt(array[i]) + 20;
return [
...state.slice(0,a), // before the one we are updating
{...state[a], d: array.join(' '), index: i}, // updating
...state.slice(a + 1), // after the one we are updating
]
default:
return state;
}
}
My struggle is that in the SVG component the path d can consume also different data objects
For example
apart from
path d={svg.d}
it can also have
path d={svg.d2}
path d={svg.d3}
See the snippet example
<svg>
<svg>
<g>
<path d={svg.d}></path>
</g>
</svg>
<g>
<path d={svg.d2}></path>
<path d={svg.d3}></path>
</g>
</svg>
How can I modify my redux function to increment only the string or strings of that particular SVG element
I have some svg elements which can have all svg objects ( svg.d, svg.d2, svg.d3), or only two ( svg.d, svg.d2 ) or one ( svg.d ).
For example, if I modify that redux function above, it can be something like
let string = state[a].d || state[a].d2 || state[a].d3;
and then
return [
...state.slice(0,a), // before the one we are updating
{...state[a], d: array.join(' '), index: i, d2: array.join(' '), index: i, d3: array.join(' '), index: i }, // updating
...state.slice(a + 1), // after the one we are updating
]
The only problem here is that if one of the SVG elements has one or two empty objects between svg.d, svg.d2, svg.d3, after the click action it gets the same values of the string modified, and I do not want this.
Hope explanation is clear, I will set up a jsBin if it is necessary.
You could map over the svg objects and transform them individually (if they exist):
const aObj = state[a];
const [d, d2, d3] = [aObj.d, aObj.d2, aObj.d3].map((string) => {
if(!string) return null;
let array = string.split(" ");
let max = array.length;
let last = max - 2;
let i = (state[a].index || 3) + 1;
if ( i === last ) i = 3;
if (array[i] !== 'M' && array[i] !== 'L') array[i] = parseInt(array[i]) + 20;
return array.join(" ");
});
return [
...state.slice(0,a), // before the one we are updating
{...aObj, d, d2, d3}, // updating
...state.slice(a + 1), // after the one we are updating
]
I'm not sure I completely understand the question though.

How to increment / decrement hex color values with javascript / jQuery

Is it possible to increment or decrement hex color values on a step-by-step basis in jQuery / javascript?
What I would like to do is something like this:
Adapting from a for-loop like
for (var i = 0; i <= 100; i++) {
console.log(i);
}
I would like to do something like
for (var color = 000000; color <= ffffff; color++) {
console.log(color);
}
without any conversion.
Is that possible? I've allready tried this:
for (var color = parseInt('000000', 16); color <= parseInt('ffffff', 16); color++){
console.log(color.toString(16));
}
and it works but it's terribly slow (I get the warning that the script is slowing down the website and if I want to stop the script).
The reason why I want to do this:
I would like to change the color stops of svg gradients in a certain interval. If I had for example this svg (simplified):
<svg>
...
<linearGradient>
<stop offset="0%" stop-color="#C8E3EF"/>
<stop offset="100%" stop-color="#C8E3EF"/>
</linearGradient>
...
</svg>
This gradient would of course appear as a solid color. Now I want to change it step by step to for example
<svg>
...
<linearGradient>
<stop offset="0%" stop-color="#dfcf99"/>
<stop offset="100%" stop-color="#c5b6ec"/>
</linearGradient>
...
</svg>
At each step or interval I want to go one value closer to the target color (through addition/substraction). In the end the result should be a smooth color-animation. Is that possible without the conversion? It does not have to be a for-loop btw., I just chose it to illustrate my idea.
I wrote a gradient-function some time ago, maybe it helps you (returns an Array):
function gradient(startColor, endColor, steps) {
var start = {
'Hex' : startColor,
'R' : parseInt(startColor.slice(1,3), 16),
'G' : parseInt(startColor.slice(3,5), 16),
'B' : parseInt(startColor.slice(5,7), 16)
}
var end = {
'Hex' : endColor,
'R' : parseInt(endColor.slice(1,3), 16),
'G' : parseInt(endColor.slice(3,5), 16),
'B' : parseInt(endColor.slice(5,7), 16)
}
diffR = end['R'] - start['R'];
diffG = end['G'] - start['G'];
diffB = end['B'] - start['B'];
stepsHex = new Array();
stepsR = new Array();
stepsG = new Array();
stepsB = new Array();
for(var i = 0; i <= steps; i++) {
stepsR[i] = start['R'] + ((diffR / steps) * i);
stepsG[i] = start['G'] + ((diffG / steps) * i);
stepsB[i] = start['B'] + ((diffB / steps) * i);
stepsHex[i] = '#' + Math.round(stepsR[i]).toString(16) + '' + Math.round(stepsG[i]).toString(16) + '' + Math.round(stepsB[i]).toString(16);
}
return stepsHex;
}
Well you can do it simply by this way:
var incrementColor = function(color, step){
var colorToInt = parseInt(color.substr(1), 16), // Convert HEX color to integer
nstep = parseInt(step); // Convert step to integer
if(!isNaN(colorToInt) && !isNaN(nstep)){ // Make sure that color has been converted to integer
colorToInt += nstep; // Increment integer with step
var ncolor = colorToInt.toString(16); // Convert back integer to HEX
ncolor = '#' + (new Array(7-ncolor.length).join(0)) + ncolor; // Left pad "0" to make HEX look like a color
if(/^#[0-9a-f]{6}$/i.test(ncolor)){ // Make sure that HEX is a valid color
return ncolor;
}
}
return color;
};
For steps:
1 by 1 to 256 increment last color
256 by 256 increment middle color
65536 by 65536 increment first color
A running example here: http://jsfiddle.net/a3JbB/
Use setInterval to remove exception(stack overflow). jsfiddle
var start = 0x000000,
end = 0xFFFFFF, temp;
var intervalId = setInterval(function(){
if(start== end){clearInterval(intervalId )};
temp = (start).toString(16);
if(temp.length < 8){
temp = "0000000".substring(0, 8-temp.length)+temp;
}
start++;
console.log(temp );
}, 10);

Categories

Resources