Work in progress...
Last active
July 17, 2022 09:22
-
-
Save vkuchinov/fd8dd71fa35873ea4d59a342c063b5df to your computer and use it in GitHub Desktop.
D3.JS Curves v.δ
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
@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); | |
} | |
}); | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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