|
<!DOCTYPE html> |
|
<title>timeline - approximation thanks to FFT/iFFT</title> |
|
<meta content="Illustrating timeline approximation thanks to FFT/iFFT with d3.js" name="description"> |
|
<meta charset="utf-8"> |
|
<style> |
|
|
|
body { |
|
position: relative; |
|
background-color: #ddd; |
|
margin: auto; |
|
} |
|
|
|
#under-construction { |
|
display: none; |
|
position: absolute; |
|
top: 200px; |
|
left: 300px; |
|
font-size: 40px; |
|
} |
|
|
|
.controls { |
|
position: absolute; |
|
font: 11px arial; |
|
} |
|
#controls1 { |
|
top: 300px; |
|
left: 10px; |
|
} |
|
#controls2 { |
|
top: 300px; |
|
left: 450px; |
|
} |
|
#controls3 { |
|
top: 300px; |
|
right: 10px; |
|
text-align: right; |
|
} |
|
|
|
.viz { |
|
position: absolute; |
|
background-color: white; |
|
border-radius: 10px; |
|
left: 5px; |
|
} |
|
.viz#timelines { |
|
top: 5px; |
|
} |
|
.viz#fft { |
|
top: 355px; |
|
} |
|
|
|
.flow { |
|
position: absolute; |
|
font-size: 30px; |
|
color: darkgrey; |
|
top: 320px; |
|
} |
|
|
|
.flow span { |
|
font-size: initial; |
|
} |
|
|
|
#flow-1 { |
|
left: 400px; |
|
} |
|
|
|
#flow-2 { |
|
right: 120px; |
|
} |
|
|
|
.axis path, |
|
.axis line { |
|
fill: none; |
|
stroke: black; |
|
shape-rendering: crispEdges; |
|
} |
|
.axis text { |
|
font-family: sans-serif; |
|
font-size: 11px; |
|
fill: black; |
|
} |
|
.grid>line, .grid>.intersect { |
|
fill: none; |
|
stroke: #ddd; |
|
shape-rendering: crispEdges; |
|
vector-effect: non-scaling-stroke; |
|
} |
|
.legend { |
|
font-size: 12px; |
|
} |
|
|
|
.dot { |
|
fill: steelblue; |
|
stroke: white; |
|
stroke-width: 3px; |
|
} |
|
.dot.draggable:hover, .dot.dragging { |
|
fill: pink; |
|
cursor: ns-resize; |
|
} |
|
|
|
.timeline { |
|
fill: none; |
|
stroke: lightsteelblue; |
|
stroke-width: 2px; |
|
} |
|
|
|
.timeline.rebuilt { |
|
stroke: pink; |
|
stroke-dasharray: 3 3; |
|
} |
|
|
|
.fft-bar { |
|
fill: grey; |
|
} |
|
|
|
#fft-treshold>line{ |
|
stroke: pink; |
|
stroke-width: 2px; |
|
} |
|
#fft-treshold.draggable:hover, #fft-treshold.dragging { |
|
cursor: ns-resize; |
|
} |
|
#fft-treshold>text { |
|
font-size: 12px; |
|
} |
|
|
|
</style> |
|
<body> |
|
<div id="timelines" class="viz"> |
|
<div id="controls1" class="controls"> |
|
update time serie with a seasonality's length of <a href="#" onclick="updateSeasonalityPeriod(2);">2</a> /<a href="#" onclick="updateSeasonalityPeriod(3);">3</a> / <a href="#" onclick="updateSeasonalityPeriod(4);">4</a> / <a href="#" onclick="updateSeasonalityPeriod(5);">5</a> / <a href="#" onclick="updateSeasonalityPeriod(6);">6</a> / <a href="#" onclick="updateSeasonalityPeriod(7);">7</a> / <a href="#" onclick="updateSeasonalityPeriod(8);">8</a> / <a href="#" onclick="updateSeasonalityPeriod(9);">9</a> / <a href="#" onclick="updateSeasonalityPeriod(10);">10</a> periods |
|
</div> |
|
<div id="controls2" class="controls"> |
|
<a href="#" onclick="increaseSeasonOrderOfMagnitude();">increase</a> / <a href="#" onclick="decreaseSeasonOrderOfMagnitude();">decrease</a> seasonality's order of magnitude |
|
</div> |
|
<div id="controls3" class="controls"> |
|
<a href="#" onclick="increaseTrend();">increase</a> / <a href="#" onclick="decreaseTrend();">decrease</a> timeline's trend |
|
</div> |
|
</div> |
|
<div id="fft" class="viz"></div> |
|
<div id="flow-1" class="flow"><span>FFT</span>↧</div> |
|
<div id="flow-2" class="flow">↥<span>iFFT of retained components (above treshold)</span></div> |
|
<div id="under-construction"> |
|
UNDER CONSTRUCTION |
|
</div> |
|
<script src="https://d3js.org/d3.v4.min.js"></script> |
|
<script src="fft.js"></script> |
|
<script> |
|
var timeserie = []; |
|
var fftComponents = []; |
|
function fftComponentCoef (d) { |
|
// Amplitude-Units-Peak (Apk), from sooeet.com/math/online-fft-calculator.php |
|
return Math.sqrt(Math.pow(d.re,2)+Math.pow(d.im,2)); |
|
// dBV, from sooeet.com/math/online-fft-calculator.php |
|
return 20*Math.log(Math.sqrt(Math.pow(d.re,2)+Math.pow(d.im,2))); |
|
} |
|
var fftTreshold = 0; |
|
var rebuiltTimeserie = []; |
|
var randomness = []; |
|
var currentSeasonLength = 4; |
|
var currentSeasonOrderOfMagnitude = 8; |
|
var currentTrend = 1.4; |
|
var shouldDetrend = false; |
|
var radix = 5; //6 max., see below |
|
var pointNumber = Math.pow(2,radix); //max = Math.pow(2,6) = 64 samples |
|
var maxAmount = {3: 40, 4: 50, 5: 70, 6: 120}[radix]; |
|
|
|
//begin: const. |
|
var WITH_TRANSITION = true; |
|
var WITHOUT_TRANSITION = false; |
|
var duration = 500; |
|
var NEW_RANDOMNESS = true; |
|
var PRESERVE_RANDOMNESS = false; |
|
//end: const. |
|
|
|
|
|
//begin: layout conf. |
|
var timelineVizDimension = {width: 960, height:340}, |
|
fftVizDimension = {width: 960, height:160}, |
|
vizMargin = 5, |
|
flowWidth = 20 |
|
legendHeight = 20, |
|
xAxisLabelHeight = 10, |
|
yAxisLabelWidth = 10, |
|
margin = {top: 20, right: 20, bottom: 20, left: 20}, |
|
timelineSvgWidth = timelineVizDimension.width - 2*vizMargin, |
|
timelineSvgHeight = timelineVizDimension.height - 2*vizMargin - flowWidth/2, |
|
fftSvgWidth = fftVizDimension.width - 2*vizMargin, |
|
fftSvgHeight = fftVizDimension.height - 2*vizMargin - flowWidth/2, |
|
timelineWidth = timelineSvgWidth - margin.left - margin.right - yAxisLabelWidth, |
|
timelineHeight = timelineSvgHeight - margin.top - margin.bottom - xAxisLabelHeight - 1.5*legendHeight, |
|
fftWidth = fftSvgWidth - margin.left - margin.right - yAxisLabelWidth, |
|
fftHeight = fftSvgHeight - margin.top - margin.bottom; |
|
//end: layout conf. |
|
|
|
var drag = d3.drag() |
|
.subject(function(d) { return d; }) |
|
.on("start", dragStarted) |
|
.on("drag", dragged1) |
|
.on("end", dragEnded); |
|
|
|
var tresholdDrag = d3.drag() |
|
//.subject(function(d) { return d; }) |
|
.on("start", dragStarted) |
|
.on("drag", tresholdDragged) |
|
.on("end", dragEnded); |
|
|
|
var x = d3.scaleLinear() |
|
.domain([0, pointNumber]) |
|
.range([0, timelineWidth]); |
|
var y = d3.scaleLinear() |
|
.domain([0, maxAmount]) |
|
.range([0, -timelineHeight]); |
|
|
|
var xFft = d3.scaleLinear() |
|
.domain([0, pointNumber]) |
|
.range([0, fftWidth]); |
|
var yFft = d3.scaleLinear() |
|
.domain([0, 1]) |
|
.range([0, -fftHeight]); |
|
|
|
var xAxisDef = d3.axisBottom() |
|
.scale(x) |
|
.ticks(pointNumber); |
|
var yAxisDef = d3.axisLeft() |
|
.scale(y); |
|
|
|
var xAxisFftDef = d3.axisBottom() |
|
.scale(xFft) |
|
.tickValues(d3.range(0,pointNumber)); |
|
var yAxisFftDef = d3.axisLeft() |
|
.scale(yFft) |
|
.ticks(3) |
|
.tickFormat(""); |
|
|
|
|
|
//being: build layout |
|
var svg = d3.select("#timelines").append("svg") |
|
.attr("width", timelineSvgWidth) |
|
.attr("height", timelineSvgHeight) |
|
.append("g") |
|
.attr("transform", "translate(" + [margin.left, margin.top] + ")"); |
|
|
|
var container = svg.append("g") |
|
.attr("class", "graph") |
|
.attr("transform", "translate(" + [yAxisLabelWidth, timelineHeight] + ")"); |
|
|
|
var grid = container.append("g") |
|
.attr("class", "grid"); |
|
var intersects = []; |
|
d3.range(1, x.invert(timelineWidth)+1, 1).forEach(function(a) { d3.range(5, y.invert(-timelineHeight)+5,5).forEach(function(b) { intersects.push([a,b])})}); |
|
grid.selectAll(".intersect") |
|
.data(intersects) |
|
.enter().append("path") |
|
.classed("intersect", true) |
|
.attr("d", function(d) { return "M"+[x(d[0])-1,y(d[1])]+"h3M"+[x(d[0]),y(d[1])-1]+"v3"}); |
|
|
|
container.append("text") |
|
.attr("transform", "translate(" + [timelineWidth/2, -timelineHeight] + ")") |
|
.attr("text-anchor", "middle") |
|
.text("Timeline"); |
|
|
|
container.append("g") |
|
.attr("class", "axis x") |
|
.call(xAxisDef) |
|
.append("text") |
|
.attr("x", timelineWidth) |
|
.attr("y", -6) |
|
.style("text-anchor", "end") |
|
.text("Time"); |
|
|
|
container.append("g") |
|
.attr("class", "axis y") |
|
.call(yAxisDef) |
|
.append("text") |
|
.attr("transform", "rotate(-90)") |
|
.attr("x", timelineHeight) |
|
.attr("y", 16) |
|
.style("text-anchor", "end") |
|
.text("Amount"); |
|
|
|
var rebuiltTimeline = container.append("path") |
|
.classed("timeline rebuilt", true) |
|
.attr("d", line) |
|
|
|
var timeline = container.append("path") |
|
.classed("timeline", true) |
|
.attr("d", line) |
|
|
|
var dotContainer = container.append("g") |
|
.classed("dots", true); |
|
|
|
svg = d3.select("#fft").append("svg") |
|
.attr("width", fftSvgWidth) |
|
.attr("height", fftSvgHeight) |
|
.append("g") |
|
.attr("transform", "translate(" + [margin.left, margin.top] + ")"); |
|
|
|
container = svg.append("g") |
|
.attr("class", "graph") |
|
.attr("id", "fft") |
|
.attr("transform", "translate(" + [yAxisLabelWidth, fftHeight] + ")"); |
|
|
|
var fftTitle = container.append("text") |
|
.attr("transform", "translate(" + [fftWidth/2, -fftHeight] + ")") |
|
.attr("text-anchor", "middle") |
|
.text("Power spectrum"); |
|
|
|
grid = container.append("g") |
|
.attr("class", "grid"); |
|
intersects = []; |
|
d3.range(1, xFft.invert(fftWidth), 1).forEach(function(a) { d3.range(-1, yFft.invert(-fftHeight)+0.5,0.5).forEach(function(b) { intersects.push([a,b])})}); |
|
grid.selectAll(".intersect") |
|
.data(intersects) |
|
.enter().append("path") |
|
.classed("intersect", true) |
|
.attr("d", function(d) { return "M"+[xFft(d[0])-1,yFft(d[1])]+"h3M"+[xFft(d[0]),yFft(d[1])-1]+"v3"}); |
|
|
|
container.append("g") |
|
.attr("class", "axis x") |
|
.call(xAxisFftDef) |
|
.append("text") |
|
.attr("x", fftWidth) |
|
.attr("y", -6) |
|
.style("text-anchor", "end") |
|
.text("Freqency"); |
|
|
|
container.append("g") |
|
.attr("class", "axis y") |
|
.call(yAxisFftDef) |
|
.append("text") |
|
.attr("transform", "rotate(-90)") |
|
.attr("x", fftHeight) |
|
.attr("y", -10) |
|
.style("text-anchor", "end") |
|
.text("Apk"); |
|
|
|
var barContainer = container.append("g") |
|
.attr("id", "bar-container"); |
|
|
|
var drawnTreshold = container.append("g") |
|
.attr("id", "fft-treshold") |
|
.classed("draggable", true) |
|
.attr("transform", "translate("+[0,yFft(0)]+")") |
|
.call(tresholdDrag); |
|
drawnTreshold.append("line") |
|
.attr("id", "fft-treshold") |
|
.attr("x1", xFft(0)) |
|
.attr("x2", xFft(pointNumber)) |
|
.attr("y1", 0) |
|
.attr("y2", 0) |
|
drawnTreshold.append("text") |
|
.attr("x", fftWidth+margin.right) |
|
.attr("y", 10) |
|
.style("text-anchor", "end") |
|
.text("Treshold"); |
|
//end: build layout |
|
|
|
d3.csv("timeserie.csv", dottype, function(error, dots) { |
|
computeAll(PRESERVE_RANDOMNESS); |
|
redrawAll(WITHOUT_TRANSITION); |
|
}); |
|
|
|
function dottype(d) { |
|
d.x = +d.x; |
|
d.y = +d.y+(+d.random); |
|
if (timeserie.length<pointNumber) { //FFT only on data of length 2-radix |
|
timeserie.push(d); |
|
randomness.push(+d.random); |
|
} |
|
return d; |
|
} |
|
|
|
var line = d3.line() |
|
.x(function(d) { return x(d.x); }) |
|
.y(function(d) { return y(d.y); }); |
|
|
|
function computeAll(withRandom) { |
|
computeTimeserie(withRandom); |
|
computeFftComponents(); |
|
computeRebuiltTimeserie(); |
|
} |
|
|
|
function computeTimeserie(withRandom) { |
|
var trend = shouldDetrend ? 0 : currentTrend; |
|
var intercept = 10; |
|
|
|
var expected; |
|
timeserie.forEach(function(d,i) { |
|
expected = trend*d.x+intercept; |
|
switch (i%currentSeasonLength) { |
|
case 0: expected -= currentSeasonOrderOfMagnitude; break; |
|
case (currentSeasonLength-1): expected += currentSeasonOrderOfMagnitude; break; |
|
} |
|
|
|
if (withRandom) { |
|
randomness[i] = 3*(Math.random()-0.5); |
|
} |
|
d.y = expected + randomness[i]; |
|
}) |
|
} |
|
|
|
function computeFftComponents() { |
|
var complexTimeserie = timeserie.map(function(d) { |
|
return new Complex(d.x, d.y); |
|
}) |
|
fftComponents = cfft(complexTimeserie); |
|
} |
|
|
|
function computeRebuiltTimeserie() { |
|
var fftComponentCopy = fftComponents.map(function(d, i){ |
|
if (fftComponentCoef(d) >= fftTreshold) { |
|
return new Complex(d.re, d.im); |
|
} else { |
|
return new Complex(0, 0); |
|
} |
|
}); |
|
var complexRebuiltTimeserie = icfft(fftComponentCopy); |
|
rebuiltTimeserie = []; |
|
complexRebuiltTimeserie.forEach( function(d, i) { |
|
rebuiltTimeserie.push({ |
|
x: i+1, |
|
y: d.im}); |
|
}) |
|
} |
|
|
|
function redrawAll(withTransition) { |
|
redrawDots(withTransition); |
|
redrawTimeline(withTransition); |
|
redrawTreshold(); |
|
redrawFftComponents(withTransition); |
|
redrawRebuiltTimeline(withTransition); |
|
} |
|
|
|
function redrawDots(withTransition) { |
|
var dots = dotContainer.selectAll(".dot") |
|
.data(timeserie); |
|
dots = dots.enter() |
|
.append("circle") |
|
.classed("dot draggable", true) |
|
.attr("r", 5) |
|
.attr("cx", function(d) { return x(d.x); }) |
|
.call(drag) |
|
.merge(dots); |
|
dots.transition() |
|
.duration(withTransition? duration : 0) |
|
.attr("cy", function(d) { return y(d.y); }) |
|
} |
|
|
|
function redrawTimeline(withTransition) { |
|
timeline.transition() |
|
.duration(withTransition? duration : 0) |
|
.attr("d", line(timeserie)); |
|
} |
|
|
|
function redrawRebuiltTimeline(withTransition) { |
|
rebuiltTimeline.transition() |
|
.duration(withTransition? duration : 0) |
|
.attr("d", line(rebuiltTimeserie)); |
|
} |
|
|
|
function redrawFftComponents(withTransition) { |
|
var barData = [], |
|
maxCoef= -Infinity, |
|
coef; |
|
|
|
fftComponents.forEach( function(d, i) { |
|
coef = fftComponentCoef(d); |
|
maxCoef = Math.max(maxCoef, coef); |
|
barData.push({ |
|
index: i, |
|
coef: coef |
|
}) |
|
}) |
|
|
|
yFft.domain([0, maxCoef]); |
|
|
|
bars = barContainer.selectAll(".fft-bar") |
|
.data(barData); |
|
|
|
bars.enter().append("path") |
|
.classed("fft-bar", true) |
|
.attr("d", function(d) { return "M"+[xFft(d.index)-2,yFft(0)]+"h5V"+yFft(d.coef)+"h-5z" }) |
|
.attr("fill-opacity", function(d){ |
|
return (d.coef >= fftTreshold)? 1 : 0.5;}); |
|
bars.transition() |
|
.duration(withTransition? duration : 0) |
|
.attr("d", function(d) { return "M"+[xFft(d.index)-2,yFft(0)]+"h5V"+yFft(d.coef)+"h-5z" }) |
|
.attr("fill-opacity", function(d){ |
|
return (d.coef >= fftTreshold)? 1 : 0.5;}); |
|
} |
|
|
|
function redrawTreshold() { |
|
drawnTreshold.attr("transform", "translate("+[0,yFft(fftTreshold)+2]+")"); |
|
} |
|
|
|
//begin: handle UI interactions (drag, available controls) |
|
function dragStarted(d) { |
|
d3.select(this).classed("dragging", true); |
|
} |
|
|
|
function dragged1(d) { |
|
d.y += y.invert(d3.event.dy); |
|
|
|
computeFftComponents(); |
|
computeRebuiltTimeserie(); |
|
|
|
redrawAll(WITHOUT_TRANSITION); |
|
} |
|
|
|
function tresholdDragged() { |
|
fftTreshold = yFft.invert(d3.event.y); |
|
computeRebuiltTimeserie(); |
|
|
|
redrawTreshold(); |
|
redrawFftComponents(WITHOUT_TRANSITION); |
|
redrawRebuiltTimeline(WITHOUT_TRANSITION); |
|
} |
|
|
|
function dragEnded(d) { |
|
d3.select(this).classed("dragging", false); |
|
} |
|
|
|
function updateSeasonalityPeriod(newSeasonLength) { |
|
currentSeasonLength = newSeasonLength; |
|
computeAll(NEW_RANDOMNESS); |
|
redrawAll(WITH_TRANSITION); |
|
} |
|
|
|
function increaseTrend() { |
|
currentTrend *= 1.6; |
|
computeAll(PRESERVE_RANDOMNESS); |
|
redrawAll(WITH_TRANSITION); |
|
} |
|
|
|
function decreaseTrend() { |
|
currentTrend *= 0.625; |
|
computeAll(PRESERVE_RANDOMNESS); |
|
redrawAll(WITH_TRANSITION); |
|
} |
|
|
|
function increaseSeasonOrderOfMagnitude() { |
|
currentSeasonOrderOfMagnitude *= 1.6; |
|
computeAll(PRESERVE_RANDOMNESS); |
|
redrawAll(WITH_TRANSITION); |
|
} |
|
|
|
function decreaseSeasonOrderOfMagnitude() { |
|
currentSeasonOrderOfMagnitude *= 0.625; |
|
computeAll(PRESERVE_RANDOMNESS); |
|
redrawAll(WITH_TRANSITION); |
|
} |
|
|
|
function trend() { |
|
shouldDetrend = false; |
|
computeAll(PRESERVE_RANDOMNESS); |
|
redrawAll(WITH_TRANSITION); |
|
} |
|
|
|
function detrend() { |
|
shouldDetrend = true; |
|
computeAll(PRESERVE_RANDOMNESS); |
|
redrawAll(WITH_TRANSITION); |
|
} |
|
|
|
function handleDetrend(cb) { |
|
shouldDetrend = cb.checked; |
|
computeAll(PRESERVE_RANDOMNESS); |
|
redrawAll(WITH_TRANSITION); |
|
} |
|
//end: handle UI interactions (drag, available controls) |
|
</script> |
|
</body> |