Skip to content

Instantly share code, notes, and snippets.

@vkuchinov
Last active July 17, 2022 09:22
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save vkuchinov/fd8dd71fa35873ea4d59a342c063b5df to your computer and use it in GitHub Desktop.
Save vkuchinov/fd8dd71fa35873ea4d59a342c063b5df to your computer and use it in GitHub Desktop.
D3.JS Curves v.δ

Work in progress...

/*
@author Vladimir V. KUCHINOV
@email helloworld@vkuchinov.co.uk
*/
var svg, width, height, gui, paths = [];
var categoricals = d3.scaleOrdinal(d3.schemeCategory20);
class Path{
constructor(id_, x0_, y0_, x1_, y1_){
this.g = svg.append("g").attr("id", id_);
this.id = id_;
this.points = [
{x: Number(x0_), y: Number(y0_), t: "e"},
{x: Number(x1_), y: Number(y1_), t: "e"}
];
this.segments = [
{t: "l", start: 0, median: null, end: 1}
];
// {t: "a", start: 0, c0: 1, c1: 2, end: 3},
this.addHelper0 = svg.append("line").attr("pointer-events", "none");
this.addHelper1 = svg.append("line").attr("pointer-events", "none");
this.addHelper2 = svg.append("circle").attr("pointer-events", "none");
this.tangentHelper0 = svg.append("circle");
this.tangentHelper1 = svg.append("line");
this.draw();
}
draw = function(){
var this_ = this;
d3.select("#" + this.id).selectAll("*").remove();
var hit = this.g.selectAll(".hitarea")
.data(this.getAllSegments())
.enter()
.append("path")
.attr("class", "hitarea")
.attr("d", function(d_) { return d_.d; })
.attr("fill", "none")
.attr("stroke-width", 10)
.attr("stroke", function(d_, i_) { return "#FF00FF"; })
.attr("opacity", 0.0)
.on("mouseout", function(d_){
this_.addHelper2.attr("r", 0);
this_.addHelper0.attr("x1", 0).attr("y1", 0).attr("x2", 0).attr("y2", 0);
this_.addHelper1.attr("x1", 0).attr("y1", 0).attr("x2", 0).attr("y2", 0);
})
.on("mousemove", function(d_){
var m = {x: d3.mouse(this)[0], y: d3.mouse(this)[1]};
var d0 = this_.dist2D(m, this_.points[d_.s]);
var d1 = this_.dist2D(m, this_.points[d_.e]);
d_.t = d0 / (d0 + d1);
var delta = this_.lerp2D(this_.points[d_.s], this_.points[d_.e], d_.t);
this_.addHelper0
.attr("x1", this_.points[d_.s].x)
.attr("y1", this_.points[d_.s].y)
.attr("x2", delta.x)
.attr("y2", delta.y)
.attr("stroke-width", 1)
.attr("stroke", function(){
var dist = this_.dist2D(delta, this_.points[d_.s]);
return dist > gui.__controllers[1].initialValue / 2.0 ? "none" : "#FF0000"
});
this_.addHelper1
.attr("x1", this_.points[d_.e].x)
.attr("y1", this_.points[d_.e].y)
.attr("x2", delta.x)
.attr("y2", delta.y)
.attr("stroke-width", 1)
.attr("stroke", function(){
var dist = this_.dist2D(delta, this_.points[d_.e]);
return dist > gui.__controllers[1].initialValue / 2.0 ? "none" : "#FF0000"
});
this_.addHelper2
.attr("r", 8)
.attr("cx", delta.x)
.attr("cy", delta.y)
.attr("fill", "url(#dragMark)");
})
.on("click", function(d_) {
this_.addHelper2.attr("r", 0);
this_.addHelper0.attr("x1", 0).attr("y1", 0).attr("x2", 0).attr("y2", 0);
this_.addHelper1.attr("x1", 0).attr("y1", 0).attr("x2", 0).attr("y2", 0);
if(d_.t != 0){
var delta = this_.lerp2D(this_.points[d_.s], this_.points[d_.e], d_.t);
if(this_.dist2D(delta, this_.points[d_.s]) > gui.__controllers[1].initialValue && this_.dist2D(delta, this_.points[d_.e]) > gui.__controllers[1].initialValue){
this_.points.push({x: delta.x, y: delta.y, t: "i" });
var split = this_.points.length - 1;
this_.points.push({x: 0, y: 0, t: "a" });
var median0 = this_.points.length - 1;
var leftSegment = {t: "l", start: d_.s, median: this_.segments[d_.id].median, end: split};
var rightSegment = {t: "l", start: split, median: median0, end: d_.e };
this_.segments.splice(d_.id, 1, leftSegment, rightSegment);
}
this_.draw();
}
})
var path = this.g.append("path")
.attr("id", "merged")
.attr("d", this.generatePath())
.attr("fill", "none")
.attr("stroke-weight", "1.5")
.attr("stroke", "#000000");
this.drawNodes();
}
reset = function(){
this.points = [this.points[0], this.points[1]];
this.segments = [{t: "l", start: 0, median: null, end: 1}];
this.tangentHelper0.attr("r", 0);
this.tangentHelper1.attr("x1", 0.0).attr("y1", 0.0).attr("x2", 0.0).attr("y2", 0.0);
this.draw();
}
drawHelpers = function(){
this.g.selectAll(".mindist").remove();
var mindist = this.g.selectAll(".mindist")
.data(this.getMinDistances())
.enter()
.append("line")
.attr("class", "mindist")
.attr("x1", function(d_) { return d_.line.x1; })
.attr("y1", function(d_) { return d_.line.y1; })
.attr("x2", function(d_) { return d_.line.x2; })
.attr("y2", function(d_) { return d_.line.y2; })
.attr("fill", "none")
.attr("stroke-width", 1)
.attr("stroke", function(d_, i_) { return d_.hex; })
.attr("opacity", 1.0);
}
drawNodes = function(){
var this_ = this;
this.g.selectAll(".node").remove();
var node = this.g.selectAll(".node")
.data(this.mergeAllNodes())
.enter()
.append("circle")
.attr("class", function(d_) { var suffix = "activeNode"; if(d_.t == "c") { suffix = "inactiveNode"; } return "node" + " " + suffix; })
.attr("id", function(d_, i_) { return "node_" + d_.id; })
.attr("cx", function(d_) { return d_.x; })
.attr("cy", function(d_) { return d_.y; })
.attr("r", 8)
.attr("pointer-events", function(d_) { if(d_.t == "a") { return "none" } else { return "all" }})
.attr("fill", function(d_) { return d_.t == "a" ? "url(#blueMark)" : "url(#redMark)"; })
.on("dblclick", function(d_){
if(d_.t != "e" && d_.t != "a"){
if(this_.segments.length == 2){
this_.segments = [{t: "l", start: 0, median: null, end: 1}];
}
else{
var newSegments = [];
for(var i = 0; i < this_.segments.length; i++){
if(this_.segments[i].end == d_.id){
if(i < this_.segments.length - 2){
newSegments.push({
t: "l",
start: this_.segments[i].start,
median: this_.segments[i].median,
end: this_.segments[i + 1].end
});
newSegments.push({
t: "l",
start: this_.segments[i + 2].start,
median: this_.segments[i + 2].median,
end: this_.segments[i + 2].end
});
i += 2; continue;
}else{
newSegments.push({
t: "l",
start: this_.segments[i].start,
median: this_.segments[i].median,
end: this_.segments[i + 1].end
});
i++; continue;
}
}else{
newSegments.push(this_.segments[i]);
}
}
newSegments.forEach(function(segment_){
if(segment_.start >= d_.id) { segment_.start -= 2; }
if(segment_.median >= d_.id) { segment_.median -= 2; }
if(segment_.end >= d_.id) { segment_.end -= 2; }
})
this_.segments = newSegments;
}
this_.points.splice(d_.id, 2);
this_.points.forEach(function(p_, i_){ p_.id = i_; });
this_.draw();
}
})
.call(
d3.drag().on("start", function(d_) { d3.select(this).attr("fill", "url(#greenMark)"); })
.on("drag", function(d_){ this_.dragged(this_, this); })
.on("end", function(d_) {
this_.tangentHelper0.attr("r", 0);
this_.tangentHelper1.attr("x1", 0).attr("y1", 0).attr("x2", 0).attr("y2", 0);
this_.g.selectAll(".mindist").attr("visibility", "hidden");
d3.select(this).attr("fill", "url(#redMark)");
})
);
}
findTerminalSegments = function(id_){
var out = {s: null, e: null};
this.segments.forEach(function(segment_, i_){
if(segment_.start == id_) { out.s = i_; }
else if(segment_.end == id_) { out.e = i_; }
})
return out;
}
dragged = function(parent_, node_){
var this_ = this;
var id = Number(d3.select(node_).attr("id").replace("node_", ""));
d3.select(node_).attr("cx", function() { return d3.event.x - 8; });
d3.select(node_).attr("cy", function() { return d3.event.y - 8; });
parent_.points[id].x = d3.select(node_).attr("cx");
parent_.points[id].y = d3.select(node_).attr("cy");
this.g.selectAll(".hitarea").data(this.getAllSegments()).attr("d", function(d_) { return d_.d; })
d3.select("#" + parent_.id).select("#merged").attr("d", this.generatePath());
var dir = -1.0;
var segment = this.findSegment(id, "end");
if(parent_.points[id].y > (parent_.points[segment.start].y + (parent_.points[segment.end].y - parent_.points[segment.start].y) / 2.0)) { dir = 1.0; }
var center = this.getPerpendicularPoint(parent_.points[segment.start], parent_.points[id], gui.__controllers[0].initialValue, dir);
this.tangentHelper0.attr("cx", center.x).attr("cy", center.y).attr("r", gui.__controllers[0].initialValue).attr("fill", "none").attr("stroke", "#FFFFFF");
this.tangentHelper1.attr("x1", center.x).attr("y1", center.y).attr("x2", parent_.points[id].x).attr("y2", parent_.points[id].y).attr("stroke", "#00FF00");
this_.drawHelpers();
this_.drawNodes();
}
findSegment = function(id_, position_){
var out = null;
this.segments.forEach(function(segment_){
if(segment_[position_] == id_) { out = segment_; }
})
return out;
}
parseArc = function(p0_, p1_, p2_, p3_){
var dir = -1.0;
if(p1_.y > (p0_.y + (p3_.y - p0_.y) / 2.0)) { dir = 1.0; }
var center = this.getPerpendicularPoint(p0_, p1_, gui.__controllers[0].initialValue, dir);
var hypot = this.dist2D(p3_, center);
var phi = Math.atan2(p3_.y - center.y, p3_.x - center.x);
var gamma = Math.atan2(p1_.y - center.y, p1_.x - center.x);
var theta = Math.acos( gui.__controllers[0].initialValue / hypot);
var x1 = center.x + gui.__controllers[0].initialValue * Math.cos(phi + dir * theta);
var y1 = center.y + gui.__controllers[0].initialValue * Math.sin(phi + dir * theta);
p2_.x = x1; p2_.y = y1;
var sigma0 = radiansToDegrees2(gamma);
var sigma1 = radiansToDegrees2(phi - theta);
if(dir > 0){ sigma0 = radiansToDegrees2(phi + theta); sigma1 = radiansToDegrees2(gamma); }
return {center: center, p: {x: Number(x1), y: Number(y1), t: "a" }, sigma0: sigma0, sigma1: sigma1};
}
generateArc(x_, y_, radius_, startTheta_, endTheta_){
var endAngleOriginal = endTheta_;
if(endAngleOriginal - startTheta_ === 360){ endTheta_ = 359; }
var start = this.polarToCartesian(x_, y_, radius_, endTheta_);
var end = this.polarToCartesian(x_, y_, radius_, startTheta_);
var arcSweep = endTheta_ - startTheta_ <= 180 ? "0" : "1";
if(endAngleOriginal - startTheta_ === 360){
var d = [
"M", start.x, start.y,
"A", radius_, radius_, 0, arcSweep, 0, end.x, end.y, "z"
].join(" ");
}
else{
var d = [
"M", start.x, start.y,
"A", radius_, radius_, 0, arcSweep, 0, end.x, end.y
].join(" ");
}
return d;
}
polarToCartesian = function(cx_, cy_, radius_, angleDeg_) {
var zeta = (angleDeg_ - 90) * Math.PI / 180.0;
return {
x: cx_ + (radius_ * Math.cos(zeta)),
y: cy_ + (radius_ * Math.sin(zeta))
};
}
getPerpendicularPoint = function(a_, b_, radius_, dir_){
var P = {x: Number(a_.x) - Number(b_.x), y: Number(a_.y) - Number(b_.y) };
var N = {x: -P.y, y: P.x };
var len = Math.sqrt((N.x * N.x) + (N.y * N.y));
N.x = N.x / len; N.y = N.y / len;
return { x: Number(b_.x) + Number(radius_) * N.x * Number(dir_), y: Number(b_.y) + Number(radius_) * N.y * Number(dir_) };
}
generatePath = function(){
var this_ = this;
var out = "";
var R = gui.__controllers[0].initialValue;
for(var i = 0; i < this.segments.length; i++){
if(i == 0){
out += "M" + this.points[this.segments[i].start].x + " " + this.points[this.segments[i].start].y + " L" + this.points[this.segments[i].end].x + " " + this.points[this.segments[i].end].y;
}else{
var c0 = this.parseArc(this.points[this.segments[i - 1].start], this.points[this.segments[i - 1].end], this.points[this.segments[i].median], this.points[this.segments[i].end]);
out += this.generateArc(c0.center.x, c0.center.y, R, c0.sigma0, c0.sigma1);
out += "M" + this.points[this.segments[i].median].x + " " + this.points[this.segments[i].median].y + " L" + + this.points[this.segments[i].end].x + " " + this.points[this.segments[i].end].y;
}
}
return out;
}
offsetLine = function(p0_, p1_, offset_){
var dx = Number(p0_.x) - Number(p1_.x);
var dy = Number(p0_.y) - Number(p1_.y);
var dist = Math.sqrt(dx*dx + dy*dy);
dx /= dist;
dy /= dist;
var x1 = Number(p0_.x) + dy * offset_;
var y1 = Number(p0_.y) - dx * offset_;
var x2 = Number(p1_.x) + dy * offset_;
var y2 = Number(p1_.y) - dx * offset_;
return {x1: x1, y1: y1, x2: x2, y2: y2};
}
getMinDistances = function() {
var this_ = this;
var out = [];
var R = gui.__controllers[0].initialValue;
var minDist = Number(gui.__controllers[1].initialValue) / 2.0;
var tmppath = svg.append("path").attr("id", "tmppath");;
for(var i = 0; i < this.segments.length; i++){
if(i == 0){
var hex = "#FFFF00";
var d = "M" + this.points[this.segments[i].start].x + " " + this.points[this.segments[i].start].y + " L" + this.points[this.segments[i].end].x + " " + this.points[this.segments[i].end].y;
tmppath.attr("d", d);
if(tmppath.node().getTotalLength() < minDist * 2) { hex = "#FF0000"; }
var d0 = tmppath.node().getPointAtLength(minDist);
var d1 = tmppath.node().getPointAtLength(tmppath.node().getTotalLength() - minDist);
out.push({hex: hex, line: this.offsetLine(this.points[this.segments[i].start], d0, 8.0)});
out.push({hex: hex, line: this.offsetLine(d1, this.points[this.segments[i].end], 8.0)});
}else{
var hex = "#FFFF00";
var c0 = this.parseArc(this.points[this.segments[i - 1].start], this.points[this.segments[i - 1].end], this.points[this.segments[i].median], this.points[this.segments[i].end]);
var d = "M" + this.points[this.segments[i].median].x + " " + this.points[this.segments[i].median].y + " L" + + this.points[this.segments[i].end].x + " " + this.points[this.segments[i].end].y;
tmppath.attr("d", d);
if(tmppath.node().getTotalLength() < minDist * 2) { hex = "#FF0000"; }
var d0 = tmppath.node().getPointAtLength(minDist);
var d1 = tmppath.node().getPointAtLength(tmppath.node().getTotalLength() - minDist);
out.push({hex: hex, line: this.offsetLine(this.points[this.segments[i].median], d0, 8.0)});
out.push({hex: hex, line: this.offsetLine(d1, this.points[this.segments[i].end], 8.0)});
}
}
svg.select("#tmppath").remove();
return out;
}
getAllSegments = function() {
var this_ = this;
var out = [];
var R = gui.__controllers[0].initialValue;
for(var i = 0; i < this.segments.length; i++){
if(i == 0){
out.push({id: i, d: "M" + this.points[this.segments[i].start].x + " " + this.points[this.segments[i].start].y + " L" + this.points[this.segments[i].end].x + " " + this.points[this.segments[i].end].y, s: this.segments[i].start, e: this.segments[i].end });
}else{
var c0 = this.parseArc(this.points[this.segments[i - 1].start], this.points[this.segments[i - 1].end], this.points[this.segments[i].median], this.points[this.segments[i].end]);
out.push({id: i, d: "M" + this.points[this.segments[i].median].x + " " + this.points[this.segments[i].median].y + " L" + + this.points[this.segments[i].end].x + " " + this.points[this.segments[i].end].y, s: this.segments[i].median, e: this.segments[i].end});
}
}
return out;
}
mergeAllNodes = function(){
var this_ = this;
var out = [];
this.points.forEach(function(point_, i_){ out.push(point_); out[out.length - 1].id = i_; });
out.sort(function(a_, b_){ return a_.t.localeCompare(b_.t); });
return out;
}
lerp2D = function(p0_, p1_, t_){
return { x: Number(p0_.x) + (Number(p1_.x) - Number(p0_.x)) * Number(t_),
y: Number(p0_.y) + (Number(p1_.y) - Number(p0_.y)) * Number(t_)
}
}
dist2D = function(p0_, p1_){ return Math.sqrt(Math.pow(Number(p1_.x) - Number(p0_.x), 2) + Math.pow(Number(p1_.y) - Number(p0_.y), 2)); }
}
var UI = function() {
this.radius = 48.0;
this.minDistance = 64.0;
this.x0y0x1y1 = "32,256;476,256";
this.addline = function() {
var points = gui.__controllers[2].initialValue.split(";");
var p0 = points[0].split(",");
var p1 = points[1].split(",");
paths.push(new Path("line" + paths.length, p0[0], p0[1], p1[0], p1[1]));
};
};
window.onload = function() {
var txt = new UI();
gui = new dat.GUI();
gui.add(txt, "radius", 0, 99, 0.5).onChange(function(v_) {
this.initialValue = v_;
resetPaths();
});
gui.add(txt, "minDistance", 0, 99, 0.5).name("min distance").onChange(function(v_) {
this.initialValue = v_;
resetPaths();
});
gui.add(txt, "x0y0x1y1").name("x0,y0;x1,y1").onChange(function(v_) { this.initialValue = v_; } );
gui.add(txt, "addline").name("add line");
svg = d3.select("#container");
width = document.getElementById("container").clientWidth, height = document.getElementById("container").clientHeight;
};
function resetPaths(){ paths.forEach(function(path_){ path_.reset(); }) }
function radiansToDegrees(radians_) {
return 180 / Math.PI * radians_;
}
function radiansToDegrees2(radians_) {
var out = 0.0;
if(radians_ < -Math.PI / 2.0 ) { out = remapFloat(radians_, -Math.PI, -Math.PI / 2.0, 270.0, 360.0); }
else if(radians_ >= -Math.PI / 2.0 && radians_ < 0) { out = remapFloat(radians_, -Math.PI / 2.0, 0, 0.0, 90.0); }
else if(radians_ >= 0 && radians_ < Math.PI) { out = remapFloat(radians_, 0, Math.PI / 2.0, 90.0, 180.0); }
else if(radians_ >= Math.PI / 2.0) { out = remapFloat(radians_, Math.PI / 2.0, Math.PI, 180.0, 270.0); }
return out;
}
function remapFloat(v_, min0_, max0_, min1_, max1_) {
return min1_ + (v_ - min0_) / (max0_ - min0_) * (max1_ - min1_);
}
d3.selection.prototype.moveToFront = function() {
return this.each(function() {
this.parentNode.appendChild(this);
});
};
d3.selection.prototype.moveToBack = function() {
return this.each(function() {
var firstChild = this.parentNode.firstChild;
if (firstChild) {
this.parentNode.insertBefore(this, firstChild);
}
});
};
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>D3.JS: Connect points with lines and arcs</title>
<meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no" />
<meta name="author" content="Vladimir V. KUCHINOV">
<style>
body { margin: 0; }
#container {
position: absolute;
left: 0; top: 0;
width: 512px;
height: 512px;
background-color: darkgray;
}
.active{
stroke: #000;
stroke-width: 2px;
}
</style>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dat-gui/0.7.6/dat.gui.js"></script>
</head>
<body>
<svg id="container" width="512" height="512">
<defs>
<pattern id="blueMark" width="16" height="16">
<path stroke="#0000FF" d="M0 16 L16 0 M0 0 L16 16"></path>
<circle stroke="#0000FF" fill="none" cx="8" cy="8" r="4"></path>
</pattern>
<pattern id="redMark" width="16" height="16">
<path stroke="#FF0000" d="M0 16 L16 0 M0 0 L16 16"></path>
<circle stroke="#FF0000" fill="none" cx="8" cy="8" r="4"></path>
</pattern>
<pattern id="greenMark" width="16" height="16">
<path stroke="#00FF00" d="M0 16 L16 0 M0 0 L16 16"></path>
<circle stroke="#00FF00" fill="none" cx="8" cy="8" r="4"></path>
</pattern>
<pattern id="dragMark" width="16" height="16">
<path stroke="#FFFFFF" d="M0 16 L16 0 M0 0 L16 16"></path>
<circle stroke="#FFFF00" fill="none" cx="8" cy="8" r="4"></path>
</pattern>
</defs>
</svg>
<script src="app.js"></script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment