Skip to content

Instantly share code, notes, and snippets.

@vkuchinov
Last active July 5, 2019 07:13
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/92e3ae201810c18dd24d5a448f5d5d19 to your computer and use it in GitHub Desktop.
Save vkuchinov/92e3ae201810c18dd24d5a448f5d5d19 to your computer and use it in GitHub Desktop.
D3.JS Weekly Ticket Stacks Chart based on two points perspective built with Ф golden ratio

WEEKLY TICKET STACKS Ф [based on two points prespective built with Ф golden ratio]

with UI interface for setting stack height ratio and radius.

golden ratio two point prespective

zl: { x: -11,070, y: 11,993 }
zm: { x: 0.0, y: 14.392 }
zr: { x: 12.749, y: 12.466 }

tp: { x: 0.0, y: 0.0 }
rp: { x: 12.749, y: -5.044 }
bp: { x: 2.887, y: -14.904 }
lp: { x: -11.070, y: -6.277 }

var svg, background, g, defs, div, data, w = 800, h = 600, dist, offset = 64, vratio = 1.0, radius = 18, zAxis, zValues, ratio = 0.3814, stacksData = [], filter = false, filtered = [true, true, true, true, true];
var days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; //could be German or whatever
var states = ["suspended", "pending", "normal", "urgent", "critical"];
var maxStack = Number.NEGATIVE_INFINITY;
var colors = ["#101C32", "#18607D", "#F7EDCC", "#F99613", "#CA391B"];
var scale = 20.0;
var zl = { x: -11.070 * scale, y: -11.993 * scale },
zm = { x: 0.0 * scale, y: -14.392 * scale },
zr = { x: 12.749 * scale, y: -12.466 * scale },
tp = { x: 0.0, y: 0.0 },
rp = { x: 12.749 * scale, y: 5.044 * scale },
bp = { x: 2.887 * scale, y: 14.904 * scale },
lp = { x: -11.070 * scale, y: 6.277 * scale };
var HUD = function() {
this.radius = 18;
this.vratio = 1.0;
};
d3.legend = function(){
function legend(selection_){
selection_.each(function(d_, i_) {
var g = d3.select(this).attr("transform", "translate(" + d_.x + "," + d_.y + ")")
var back = g.append("rect")
.attr("width", d_.w)
.attr("height", d_.h)
.attr("fill", "#DEDEDE")
.attr("opacity", 0.5);
var legendlabels = g.selectAll(".legendlabel")
.data(d_.states)
.enter()
.append("text")
.attr("class", function(d2_, j_){ return "legendText_" + j_; })
.attr("dx", 40)
.attr("dy", function(d2_, j_){ return 134 - j_ * 25; })
.text(function(d_){ return d_; })
var boxes = g.selectAll(".box")
.data(d_.states)
.enter()
.append("rect")
.attr("class", function(d2_, j_){ return "legendRect_" + j_; })
.attr("x", 20)
.attr("y", function(d2_, j_){ return 125 - j_ * 25; })
.attr("width", 12)
.attr("height", 12)
.attr("fill", function(d2_, j_){ return colors[j_]; });
var placeholder = g.selectAll(".box")
.data(d_.states)
.enter()
.append("rect")
.attr("x", 10)
.attr("y", function(d2_, j_){ return 120 - j_ * 25; })
.attr("width", 108)
.attr("height", 22)
.attr("fill", "transparent")
.on("click", function(d2_, j_){
filtered[j_] = !filtered[j_];
if(filtered[j_]){
d3.select(".legendText_" + j_).attr("fill", "#000000");
d3.select(".legendRect_" + j_).attr("fill", colors[j_]);
drawStacks();
}else{
d3.select(".legendText_" + j_).attr("fill", "#808080");
d3.select(".legendRect_" + j_).attr("fill", "#808080");
drawStacks();
}
})
});
}
return legend;
}
d3.diagonalAxis = function(){
function diagonalAxis(selection_){
selection_.each(function(d_, i_) {
var g = d3.select(this);
g.append("line").attr("x1", d_.start.x).attr("y1", d_.start.y).attr("x2", d_.end.x).attr("y2", d_.end.y).attr("stroke", "#000000");
var tline = offsetLine(d_, d_.offset);
var ticksData = setTicks(d_.start, d_.end, tline[0], tline[1], d_.labels.length + 1);
var ticks = g.selectAll(".tick")
.data(ticksData)
.enter()
.append("line")
.attr("x1", function(d_) { return d_.x1; })
.attr("y1", function(d_) { return d_.y1; })
.attr("x2", function(d_) { return d_.x2; })
.attr("y2", function(d_) { return d_.y2; })
.attr("stroke", "#000000")
var tline0 = offsetLine(d_, d_.textOffset);
var tline1 = offsetLine(d_, d_.textRegion);
var labelsData = setLabels(d_, tline0, tline1);
var paths = g.selectAll(".labelpath")
.data(labelsData)
.enter()
.append("path")
.attr("id", function(d2_, i_){ return "labelPath" + d_.id + "_" + i_ })
.attr("d", function(d_) { return d_.path; })
.attr("stroke", "none");
var clickme = g.selectAll(".clickme")
.data(labelsData)
.enter()
.append("circle")
.attr("cx", function(d2_){ return getPathMedian(d2_, d_.placeholders).x; })
.attr("cy", function(d2_){ return getPathMedian(d2_, d_.placeholders).y; })
.attr("r", 16)
.attr("fill", "transparent")
.attr("stroke", "none")
.on("click", function(d_){
filter = true;
d3.selectAll(".cylinder").attr("opacity", 0.0);
d3.selectAll(".day_" + d_.l).attr("opacity", 1.0);
d3.selectAll(".age_" + d_.l).attr("opacity", 1.0);
})
.on("mouseover", function(d_){
if(!filter){
d3.selectAll(".cylinder").attr("opacity", 0.0);
d3.selectAll(".day_" + d_.l).attr("opacity", 1.0);
d3.selectAll(".age_" + d_.l).attr("opacity", 1.0);
}
})
.on("mouseout", function(d_){
if(!filter) { d3.selectAll(".cylinder").attr("opacity", 1.0); }
})
var labels = g.selectAll(".label")
.data(labelsData)
.enter()
.append("text")
.attr("pointer-events", "none")
.append("textPath")
.attr("xlink:href", function(d2_, i_){ return "#labelPath" + d_.id + "_" + i_; })
.text(function(d_){ return d_.l; })
});
}
function getPathMedian(d_, placeholders_){
var coords = d_.path.replace(/M|L/g, "").split(" ");
return {x: lerp1D(coords[0], coords[2], placeholders_.x), y: lerp1D(coords[1], coords[3], placeholders_.y) };
}
function offsetLine(d_, offset_){
var l = Math.sqrt(Math.pow(d_.end.x - d_.start.x, 2) + Math.pow(d_.end.y - d_.start.y, 2));
var delta = 1.0 + offset_ / l;
var p0 = lerp2D(d_.start, d_.startTo, delta);
var p1 = lerp2D(d_.end, d_.endTo, delta);
return [p0, p1];
}
function setTicks(s0_, e0_, s1_, e1_, steps_){
var out = [];
for(var i = 1; i < steps_; i++){
var inc = 1.0 / steps_;
var v0 = lerp2D(s0_, e0_, inc * i);
var v1 = lerp2D(s1_, e1_, inc * i);
out.push({x1: v0.x, y1: v0.y, x2: v1.x, y2: v1.y });
}
return out;
}
function setLabels(d_, v0_, v1_, ){
steps = d_.labels.length + 1;
var out = [];
for(var i = 1; i < steps; i++){
var inc = 1.0 / steps;
if(d_.id == "X"){
var dv0 = lerp2D(v1_[0], v1_[1], inc * i);
var dv1 = lerp2D(v0_[0], v0_[1], inc * i);
}
else{
var dv1 = lerp2D(v1_[0], v1_[1], inc * i);
var dv0 = lerp2D(v0_[0], v0_[1], inc * i);
}
out.push({l: d_.labels[i - 1], path: "M" + dv0.x + " " + dv0.y + " L" + dv1.x + " " + dv1.y });
}
return out;
}
return diagonalAxis;
}
d3.stack = function(){
function stack(selection_){
selection_.each(function(d_, i_) {
var xy = getGridPosition(d_.x, d_.y);
var g = d3.select(this)
.attr("transform", "translate(" + xy.x + "," + xy.y + ")");
var floor = 0;
for(var j = 0; j < d_.stack.length; j++){
if(filtered[j] && d_.stack[j] > 0){
var vertices = generate_cylinder(0, floor, radius * d_.srx * d_.sry, d_.stack[j] * vratio);
var cylinder = g.append("g").attr("class", "cylinder day_" + d_.d + " age_" + d_.a);
cylinder.append("path")
.attr("class", "top")
.attr("id", d_.stack[j] + " " + states[j] + " tickets made on " + d_.d + ", " + d_.a + " days old")
.attr("d", function(d_) { return base_generator(vertices); })
.attr("stroke", "#FFFFFF")
.attr("stroke-opacity", 0.5)
.attr("fill", "url(#gradient_" + j + ")")
.on("mouseover", function(d2_) {
div.transition()
.duration(500)
.style("opacity", .9);
div.html(d3.select(this).attr("id"))
.style("left", (d3.event.pageX) + "px")
.style("top", (d3.event.pageY - 28) + "px");
})
.on("mouseout", function(d2_) {
div.transition()
.duration(500)
.style("opacity", 0);
});
cylinder.append("path")
.attr("class", "top")
.attr("id", d_.stack[j] + " " + states[j] + " tickets made on " + d_.d + ", " + d_.a + " days old")
.attr("d", function(d_) { return cap_generator(vertices); })
.attr("stroke", "#FFFFFF")
.attr("stroke-opacity", 0.5)
.attr("fill", colors[j])
.on("mouseover", function(d2_) {
div.transition()
.duration(500)
.style("opacity", .9);
div.html("value: " + d3.select(this).attr("id"))
.style("left", (d3.event.pageX) + "px")
.style("top", (d3.event.pageY - 28) + "px");
})
.on("mouseout", function(d2_) {
div.transition()
.duration(500)
.style("opacity", 0);
});
floor -= d_.stack[j] * vratio;
}
}
});
}
function getGridPosition(x_, y_){
var gx = d3.select("#gridX_" + x_);
var gy = d3.select("#gridY_" + y_);
return twoLineIntersection([{x: Number(gx.attr("x1")), y: Number(gx.attr("y1"))},
{x: Number(gx.attr("x2")), y: Number(gx.attr("y2"))}],
[{x: Number(gy.attr("x1")), y: Number(gy.attr("y1"))},
{x: Number(gy.attr("x2")), y: Number(gy.attr("y2"))}]);
}
function twoLineIntersection(a_, b_){
ma = (a_[0].y - a_[1].y) / (a_[0].x - a_[1].x);
mb = (b_[0].y - b_[1].y) / (b_[0].x - b_[1].x);
return { x: (ma * a_[0].x - mb * b_[0].x + b_[0].y - a_[0].y) / (ma - mb), y: (ma * mb * (b_[0].x - a_[0].x) + mb * a_[0].y - ma * b_[0].y) / (mb - ma)};
}
function generate_cylinder(x_, y_, width_, height_){
var out;
var base = [[x_, y_ + width_ / 2 * ratio], [x_ + width_ / 2, y_], [x_, y_ - width_ / 2 * ratio], [x_ - width_ / 2, y_]];
var top = [[x_, y_ + width_ / 2 * ratio - height_], [x_ + width_ / 2, y_ - height_], [x_, y_ - width_ / 2 * ratio - height_], [x_ - width_ / 2, y_ - height_]];
return [base, top];
}
function cap_generator(d_) {
var rx = (d_[1][1][0] - d_[1][3][0]) / 2;
var ry = (d_[1][2][1] - d_[1][0][1]) / 2;
str = "M" + d_[1][3].join(" ") + "A" + rx + " " + ry + ", 0, 0 1," + d_[1][1].join(" ");
str += "A" + rx + " " + ry + ", 0, 0 1," + d_[1][3].join(" ")
return str;
};
function base_generator(d_) {
var rx = (d_[0][1][0] - d_[0][3][0]) / 2;
var ry = (d_[0][2][1] - d_[0][0][1]) / 2;
str = "M" + d_[0][1].join(" ") + "A" + rx + " " + ry + ", 0, 0 1," + d_[0][3].join(" ");
str += " L" + d_[1][3].join(" ");
str += " L" + d_[1][1].join(" ");
str += " L" + d_[0][1].join(" ");
return str;
};
return stack;
}
d3.json("mockup.json", function(error_, data_) {
if (error_) throw error_;
data = data_;
parseData(data_);
inits();
});
function inits(){
console.log("%cWeekly Ticket Stacks β demo", "color: #494949; font-size: 18px; font-family: sans-serif;");
console.log("%cby Vladimir V KUCHINOV", "color: #494949; font-size: 12px; font-style: italic;font-family: sans-serif;");
var menu = new HUD();
var gui = new dat.GUI();
gui.add(menu, "radius", 5, 24, 0.5).onChange(function(value_) { radius = value_, drawStacks(); });
gui.add(menu, "vratio", 0.1, 1.0, 0.05).onChange(function(value_) { zValues.domain([0, 350 / value_]); zAxis.call(d3.axisRight(zValues)); vratio = value_, drawStacks() });
svg = d3.select("body").append("svg")
.attr("preserveAspectRatio", "xMinYMin meet")
.attr("viewBox", "0 0 " + w + " " + h)
.classed("svg-content", true);
background = svg.append("rect").attr("width", w).attr("height", h).attr("fill", "transparent").on("click", function(){ filter = false, d3.selectAll(".cylinder").attr("opacity", 1.0); });
defs = svg.append("defs");
div = d3.select("body").append("div")
.attr("class", "tooltip")
.style("opacity", 0);
generateGradients(5); //while current data has 5 stacked values
g = svg.append("g").attr("transform", "translate(" + w/2 + "," + h/2 + ")");
//dist = distance(w/2 - rx, h/2, w/2, h/2 - ry);
//ceilDist = 350;
var xdays = d3.scaleLinear().range([0, dist]).domain([0, 6]);
//x min
g.append("line").attr("x1", lp.x).attr("y1", lp.y).attr("x2", zl.x).attr("y2", zl.y).attr("stroke", "#DEDEDE");
//y min
g.append("line").attr("x1", lp.x).attr("y1", lp.y).attr("x2", tp.x).attr("y2", tp.y).attr("stroke", "#DEDEDE");
drawGrid(tp, rp, bp, lp, days.length + 1, days.length + 1, "#DEDEDE");
drawGrid(zm, zr, rp, tp, 8, 7, "#DEDEDE");
drawGrid(zl, zm, tp, lp, 8, 7, "#DEDEDE");
zValues = d3.scaleLinear().range([0, -(rp.y - zr.y)]).domain([0, 350]);
zAxis = g.append("g")
.attr("transform", "translate(" + (rp.x - 0.5) + "," + rp.y +")")
.call(d3.axisRight(zValues));
var xLabels = g.selectAll(".xLabels")
.data([{
id: "X",
labels: days.reverse(),
start: lp,
startTo: tp,
end: bp,
endTo: rp,
offset: 8,
textOffset: 12,
textRegion: 40,
textRotation : 0,
placeholders : { x: 0.7, y: 0.35 }
}, {
id: "Y",
labels: [1, 2, 3, 4, 5, 6, 7],
start: rp,
startTo: tp,
end: bp,
endTo: lp,
offset: 8,
textOffset: 20,
textRegion: 48,
textRotation : 30,
placeholders: {x: 0.9, y: 1.15}
}])
.enter().append("g")
.attr("id", "xLabels")
.call(d3.diagonalAxis());
drawStacks();
var legend = svg.selectAll(".legendBox")
.data([{states: states, x: 32, y: 32, w: 128, h: 160 }])
.enter().append("g")
.attr("class", "legendBox")
.call(d3.legend());
}
function drawStacks(){
d3.selectAll(".stack").remove();
var stacks = g.selectAll(".stack")
.data(JSON.parse(JSON.stringify(stacksData)))
.enter().append("g")
.attr("class", "stack")
.call(d3.stack());
}
function parseData(data_){
var days = Object.keys(data_);
days.forEach(function(d_, i_){
data_[d_].forEach(function(a_, j_){
stacksData.push({ x: 7 - i_ , y: 7 - j_, d: d_, a: a_.age, stack: a_.stack, vrx: getRatio(7 - i_, 9, zm, zr, tp, rp), vry: getRatio(7 - i_, 9, zl, zm, lp, tp), srx : getRatio(7 - i_, 9, tp, rp, lp, bp), sry : getRatio(7 - i_, 9, lp, tp, bp, rp) })
maxStack = Math.max(maxStack, a_.stack.reduce((a, b) => a + b, 0));
})
});
}
function getRatio(pos_, n_, f0_, f1_, n0_, n1_){
var fd = distance2D(f0_, f1_); //far distance
var nd = distance2D(n0_, n1_); //near distance
var axisRatio = fd / nd / 1.05;
var step = 1.0 / (n_ + 1);
return lerp1D(axisRatio, 1.0, step * pos_);
}
function drawGrid(t_, r_, b_, l_, stepsX_, stepsY_, color_){
for(var i = 0; i < stepsX_; i++){
var inc = 1.0 / stepsX_;
g.append("line")
.attr("id", "gridX_" + i)
.attr("x1", lerp2D(t_, r_, inc * i).x)
.attr("y1", lerp2D(t_, r_, inc * i).y)
.attr("x2", lerp2D(l_, b_, inc * i).x)
.attr("y2", lerp2D(l_, b_, inc * i).y)
.attr("stroke", color_)
}
for(var i = 0; i < stepsY_; i++){
var inc = 1.0 / stepsY_;
g.append("line")
.attr("id", "gridY_" + i)
.attr("x1", lerp2D(l_, t_, inc * i).x)
.attr("y1", lerp2D(l_, t_, inc * i).y)
.attr("x2", lerp2D(b_, r_, inc * i).x)
.attr("y2", lerp2D(b_, r_, inc * i).y)
.attr("stroke", color_);
}
}
function generateGradients(n_){
for(var i = 0; i < n_; i++){
var gradient = defs.append("linearGradient")
.attr("gradientTransform", "rotate(0)")
.attr("id", "gradient_" + i);
gradient.append("stop")
.attr("offset", "0%")
.attr("stop-color", d3.rgb(colors[i]).darker(0.5));
gradient.append("stop")
.attr("offset", "12%")
.attr("stop-color", d3.rgb(colors[i]).darker(1.5));
gradient.append("stop")
.attr("offset", "65%")
.attr("stop-color", d3.rgb(colors[i]));
gradient.append("stop")
.attr("offset", "100%")
.attr("stop-color", d3.rgb(colors[i]).darker(0.25));
}
}
function average1D(v0_, v1_){ return (Number(v0_) + Number(v1_)) / 2.0; }
function remapFloat(v_, min0_, max0_, min1_, max1_) {
return min1_ + (v_ - min0_) / (max0_ - min0_) * (max1_ - min1_);
}
function distance(x0_, y0_, x1_, y1_){
return Math.sqrt(Math.pow(x1_ - x0_, 2) + Math.pow(y1_ - y0_, 2));
}
function distance2D(v0_, v1_){
return Math.sqrt(Math.pow(v1_.x - v0_.x, 2) + Math.pow(v1_.y - v0_.y, 2));
}
function lerp2D(v0_, v1_, t_){
return { x: v0_.x * t_ + (1.0 - t_) * v1_.x, y: v0_.y * t_ + (1.0 - t_) * v1_.y };
}
function lerp1D(f0_, f1_, t_){ return f0_ * t_ + (1.0 - t_) * f1_; }
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Weekly Ticket Stacks φ demo</title>
<script src="http://d3js.org/d3.v4.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dat-gui/0.7.6/dat.gui.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css?family=Karla&display=swap');
body{ margin: 0; font-family: 'Karla', sans-serif; font-size: 12px; }
#d3placeholder {
margin: 0;
width: 100%;
height: 100%;
}
div.tooltip {
position: absolute;
text-align: center;
padding: 8px;
font: 12px sans-serif;
background-color: #DEDEDE;
border: 0px;
border-radius: 8px;
pointer-events: none;
}
</style>
</head>
<body>
<div id="d3placeholder"></div>
<script src="app.js"></script>
</body>
</html>
{
"Mon": [
{
"age": 1,
"stack": [10, 40, 60, 20, 10]
},
{
"age": 2,
"stack": [25, 4, 15, 5, 10]
},
{
"age": 3,
"stack": [0, 8, 52, 40, 2]
},
{
"age": 4,
"stack": [25, 7, 76, 8, 62]
},
{
"age": 5,
"stack": [20, 5, 54, 9, 20]
},
{
"age": 6,
"stack": [2, 25, 34, 49, 36]
},
{
"age": 7,
"stack": [48, 18, 4, 22, 24]
}
],
"Tue": [
{
"age": 1,
"stack": [48, 18, 0, 0, 0]
},
{
"age": 2,
"stack": [42, 36, 28, 9, 35]
},
{
"age": 3,
"stack": [34, 20, 33, 24, 5]
},
{
"age": 4,
"stack": [12, 0, 14, 36, 44]
},
{
"age": 5,
"stack": [41, 39, 27, 23, 34]
},
{
"age": 6,
"stack": [36, 50, 41, 4, 21]
},
{
"age": 7,
"stack": [12, 49, 25, 12, 2]
}
],
"Wed": [
{
"age": 1,
"stack": [18, 13, 7, 19, 13]
},
{
"age": 2,
"stack": [49, 5, 0, 21, 17]
},
{
"age": 3,
"stack": [25, 1, 2, 18, 27]
},
{
"age": 4,
"stack": [35, 30, 4, 0, 47]
},
{
"age": 5,
"stack": [10, 48, 11, 19, 42]
},
{
"age": 6,
"stack": [50, 41, 0, 27, 30]
},
{
"age": 7,
"stack": [13, 22, 6, 50, 20]
}
],
"Thu": [
{
"age": 1,
"stack": [1, 1, 12, 34, 11]
},
{
"age": 2,
"stack": [12, 20, 39, 22, 22]
},
{
"age": 3,
"stack": [16, 8, 1, 10, 24]
},
{
"age": 4,
"stack": [0, 0, 0, 31, 8]
},
{
"age": 5,
"stack": [48, 36, 1, 37, 34]
},
{
"age": 6,
"stack": [21, 19, 2, 43, 3]
},
{
"age": 7,
"stack": [17, 6, 26, 20, 35]
}
],
"Fri": [
{
"age": 1,
"stack": [2, 22, 4, 43, 2]
},
{
"age": 2,
"stack": [28, 20, 1, 28, 14]
},
{
"age": 3,
"stack": [40, 39, 0, 14, 22]
},
{
"age": 4,
"stack": [18, 33, 9, 17, 37]
},
{
"age": 5,
"stack": [38, 17, 6, 33, 2]
},
{
"age": 6,
"stack": [37, 17, 24, 21, 28]
},
{
"age": 7,
"stack": [13, 44, 41, 42, 46]
}
],
"Sat": [
{
"age": 1,
"stack": [35, 35, 24, 46, 23]
},
{
"age": 2,
"stack": [6, 12, 40, 0, 3]
},
{
"age": 3,
"stack": [23, 36, 8, 42, 17]
},
{
"age": 4,
"stack": [26, 0, 50, 41, 27]
},
{
"age": 5,
"stack": [11, 10, 28, 13, 10]
},
{
"age": 6,
"stack": [24, 7, 7, 13, 32]
},
{
"age": 7,
"stack": [3, 8, 27, 43, 39]
}
],
"Sun": [
{
"age": 1,
"stack": [7, 9, 24, 12, 28]
},
{
"age": 2,
"stack": [16, 37, 30, 38, 15]
},
{
"age": 3,
"stack": [10, 8, 28, 8, 10]
},
{
"age": 4,
"stack": [1, 0, 0, 12, 49]
},
{
"age": 5,
"stack": [50, 18, 36, 5, 4]
},
{
"age": 6,
"stack": [7, 47, 26, 17, 7]
},
{
"age": 7,
"stack": [35, 33, 45, 19, 5]
}
]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment