|
/* |
|
|
|
WEEKLY TICKET STACKS |
|
|
|
TODO LIST: |
|
|
|
[x] dynamic gradients for pseudo-3D aesthetics |
|
[x] mockup tooltip, for now, containing only value. |
|
Eventually, could be extended with additional data. |
|
|
|
[x] top left and right grids should match values |
|
[!] isometric view doesn't work well while back stack |
|
visually merge with front ones, so it makes the whole |
|
graph illegible. |
|
|
|
Have to be perspective view. To do this I have to shift |
|
bottom edge. |
|
|
|
Tried that, not so helpful. |
|
Did interactive selector, hover on axes labels (day, age) |
|
|
|
[x] mouse reacting placeholders under labels |
|
|
|
[-] filtering by legend |
|
|
|
[-] D3.JS works great with motion graphics, |
|
so if you need some effect, like growing stack on |
|
page load, let me know. |
|
|
|
[-] planned, [x] done, [!] see comments |
|
|
|
REFERENCES: |
|
http://corpuschristijobsonline.com/wp-content/uploads/2019/01/3d-stacked-bar-chart-plot-in-r-stack-overflow.png |
|
|
|
@author Vladimir V KUCHINOV |
|
@email helloworld@vkuchinov.co.uk |
|
|
|
*/ |
|
var svg, background, g, defs, div, data, w = 800, |
|
h = 600, |
|
dist, offset = 64, |
|
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 rx = 300; |
|
var ry = rx * ratio; |
|
|
|
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, 24, d_.stack[j]); |
|
|
|
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]; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
}); |
|
|
|
} |
|
|
|
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;"); |
|
|
|
|
|
svg = d3.select("#d3placeholder").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(0," + (ry * 1.5) + ")"); |
|
dist = distance(w / 2 - rx, h / 2, w / 2, h / 2 - ry); |
|
ceilDist = 350; |
|
|
|
var xdays = d3.scaleLinear().range([0, dist]).domain([0, 6]); |
|
|
|
var tp = { |
|
x: w / 2, |
|
y: h / 2 - ry |
|
}, |
|
rp = { |
|
x: w / 2 + rx, |
|
y: h / 2 |
|
}, |
|
bp = { |
|
x: w / 2, |
|
y: h / 2 + ry |
|
}, |
|
lp = { |
|
x: w / 2 - rx, |
|
y: h / 2 |
|
}, |
|
zl = { |
|
x: w / 2 - rx, |
|
y: h / 2 - ceilDist |
|
}, |
|
zm = { |
|
x: w / 2, |
|
y: h / 2 - ry - ceilDist |
|
}, |
|
zr = { |
|
x: w / 2 + rx, |
|
y: h / 2 - ceilDist |
|
}; |
|
|
|
//x min |
|
g.append("line").attr("x1", lp.x).attr("y1", lp.y).attr("x2", zl.x).attr("y2", zl.y).attr("stroke", "#DEDEDE"); |
|
|
|
//x max |
|
g.append("line").attr("x1", rp.x).attr("y1", rp.y).attr("x2", tp.x).attr("y2", tp.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"); |
|
|
|
zValues = d3.scaleLinear().range([h / 2, h / 2 - ceilDist]).domain([0, ceilDist]); |
|
|
|
g.append("g") |
|
.attr("transform", "translate(" + (rp.x) + ",0)") |
|
.call(d3.axisRight(zValues)); |
|
|
|
//z back |
|
g.append("line").attr("x1", tp.x).attr("y1", tp.y).attr("x2", zm.x).attr("y2", zm.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"); |
|
|
|
//y: days |
|
g.append("line").attr("x1", w / 2 + rx).attr("y1", h / 2).attr("x2", w / 2).attr("y2", h / 2 + ry).attr("stroke", "#000000"); |
|
|
|
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: 48, |
|
y: 48, |
|
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 |
|
}) |
|
maxStack = Math.max(maxStack, a_.stack.reduce((a, b) => a + b, 0)); |
|
|
|
}) |
|
|
|
}); |
|
|
|
} |
|
|
|
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 distance(x0_, y0_, x1_, y1_) { |
|
|
|
return Math.sqrt(Math.pow(x1_ - x0_, 2) + Math.pow(y1_ - y0_, 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_; |
|
} |