|
<!DOCTYPE html> |
|
<meta charset="utf-8"> |
|
<style> |
|
|
|
#controls{ |
|
position: absolute; |
|
right: 0px; |
|
} |
|
|
|
.grid>line, .grid>.intersect { |
|
fill: none; |
|
stroke: #ddd; |
|
shape-rendering: crispEdges; |
|
vector-effect: non-scaling-stroke; |
|
} |
|
|
|
.axis path, |
|
.axis line { |
|
fill: none; |
|
stroke: black; |
|
shape-rendering: crispEdges; |
|
} |
|
.axis text { |
|
font-family: sans-serif; |
|
font-size: 11px; |
|
} |
|
|
|
.legend { |
|
font-size: 12px; |
|
} |
|
|
|
.dot { |
|
fill: lightsteelblue; |
|
stroke: white; |
|
stroke-width: 3px; |
|
} |
|
.dot.deseasonalized { |
|
fill: lightsteelblue; |
|
stroke: white; |
|
stroke-width: 3px; |
|
opacity: 0.2; |
|
} |
|
.dot.draggable:hover, .dot.dragging { |
|
fill: pink; |
|
cursor: ns-resize; |
|
} |
|
|
|
.timeline { |
|
fill: none; |
|
stroke: steelblue; |
|
stroke-width: 2px; |
|
opacity: 0.2; |
|
} |
|
.timeline.draggable:hover, .timeline.dragging { |
|
stroke: pink; |
|
opacity: 1; |
|
cursor: ns-resize; |
|
} |
|
|
|
.trend { |
|
stroke: steelblue; |
|
} |
|
.trend.deseasonalized { |
|
stroke-opacity: 0.5; |
|
} |
|
|
|
</style> |
|
<body> |
|
<div id="controls"> |
|
<button onclick="invertTrend();">invert trend</button> |
|
<button onclick="invertSeasonality();">invert seasonality</button> |
|
<button onclick="permuteSeasonality();">permute seasonality</button> |
|
</div> |
|
|
|
<script src="https://d3js.org/d3.v3.min.js"></script> |
|
<script> |
|
var timeSerie = []; |
|
var deseasonalizedTimeSerie = []; |
|
var seasonLength = 4; //for a sake of simplicity, this parameter is static, but technics allow to find seasonality from raw time serie (eg. [fast] Fourier transformation) |
|
var seasonCount = 5; //timeSerie.length/seasonLength |
|
|
|
var WITH_TRANSITION = true; |
|
var WITHOUT_TRANSITION = false |
|
var duration = 500; |
|
|
|
var legendHeight = 20; |
|
var xAxisLabelHeight = 20; |
|
var yAxisLabelWidth = 20; |
|
var margin = {top: 20, right: 20, bottom: 20, left: 20}, |
|
width = 960 - margin.left - margin.right - yAxisLabelWidth, |
|
height = 500 - margin.top - margin.bottom - xAxisLabelHeight - legendHeight; |
|
|
|
var drag = d3.behavior.drag() |
|
.origin(function(d) { return d; }) |
|
.on("dragstart", dragStarted) |
|
.on("drag", dragged) |
|
.on("dragend", dragEnded); |
|
|
|
var x = d3.scale.linear() |
|
.domain([0, 20]) |
|
.range([0, width]) |
|
|
|
var y = d3.scale.linear() |
|
.domain([0, 50]) |
|
.range([0, -height]) |
|
|
|
var xAxisDef = d3.svg.axis() |
|
.scale(x) |
|
.ticks(21); |
|
|
|
var yAxisDef = d3.svg.axis() |
|
.scale(y) |
|
.orient("left"); |
|
|
|
var svg = d3.select("body").append("svg") |
|
.attr("width", width + margin.left + margin.right + yAxisLabelWidth) |
|
.attr("height", height + margin.top + margin.bottom + xAxisLabelHeight + legendHeight) |
|
.append("g") |
|
.attr("transform", "translate(" + (margin.left) + "," + (height+margin.top) + ")") |
|
|
|
var container = svg.append("g"); |
|
|
|
var grid = container.append("g") |
|
.attr("class", "grid"); |
|
var intersects = []; |
|
d3.range(1, x.invert(width)).forEach(function(a) { d3.range(5, y.invert(-height),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"}); |
|
grid.selectAll(".x-line") |
|
.data(d3.range(0.5, x.invert(width), 4)) |
|
.enter().append("line") |
|
.classed("x-line", true) |
|
.attr("x1", function(d) { return x(d); }) |
|
.attr("y1", y(0)) |
|
.attr("x2", function(d) { return x(d); }) |
|
.attr("y2", y(49)); |
|
container.selectAll(".season-id") |
|
.data(d3.range(0, 4)) |
|
.enter().append("text") |
|
.attr("x", function(d) { return x(2.5+d*seasonLength); }) |
|
.attr("y", -6) |
|
.style("text-anchor", "middle") |
|
.text(function(d) { return (d!=3)? "season "+ (d+1) : "..."; }); |
|
|
|
container.append("g") |
|
.attr("class", "axis x") |
|
.call(xAxisDef); |
|
container.append("text") |
|
.attr("x", width) |
|
.attr("y", -6) |
|
.style("text-anchor", "end") |
|
.text("Time"); |
|
|
|
container.append("g") |
|
.attr("class", "axis y") |
|
.call(yAxisDef); |
|
container.append("text") |
|
.attr("x", 6) |
|
.attr("y", -height+10) |
|
.style("text-anchor", "start") |
|
.text("Amount"); |
|
|
|
var legend = container.append("g") |
|
.classed("legend", true) |
|
.attr("transform", "translate(" + 100 + "," + (xAxisLabelHeight+legendHeight) + ")"); |
|
var currentLegend = legend.append("g") |
|
.attr("transform", "translate(" + 0 + ",0)"); |
|
currentLegend.append("circle") |
|
.classed("dot", true) |
|
.attr("r", 4) |
|
.attr("cx", -5) |
|
.attr("cy", -5); |
|
currentLegend.append("text") |
|
.attr("dx", 5) |
|
.text(": raw value"); |
|
currentLegend = legend.append("g") |
|
.attr("transform", "translate(" + 130 + ",0)"); |
|
currentLegend.append("circle") |
|
.classed("dot deseasonalized", true) |
|
.attr("r", 8) |
|
.attr("cx", -5) |
|
.attr("cy", -5); |
|
currentLegend.append("text") |
|
.attr("dx", 5) |
|
.text(": deseasonalized value (season's mean)"); |
|
currentLegend = legend.append("g") |
|
.attr("transform", "translate(" + 400 + ",0)"); |
|
currentLegend.append("line") |
|
.classed("trend", true) |
|
.attr("x1", -20) |
|
.attr("y1", -5) |
|
.attr("x2", -5) |
|
.attr("y2", -5); |
|
currentLegend.append("text") |
|
.attr("dx", 5) |
|
.text(": trend of raw time serie"); |
|
currentLegend = legend.append("g") |
|
.attr("transform", "translate(" + 600 + ",0)"); |
|
currentLegend.append("line") |
|
.classed("trend deseasonalized", true) |
|
.attr("x1", -20) |
|
.attr("y1", -5) |
|
.attr("x2", -5) |
|
.attr("y2", -5); |
|
currentLegend.append("text") |
|
.attr("dx", 5) |
|
.text(": trend of deseasonalized time serie"); |
|
|
|
var timeline = container.append("path") |
|
.classed("timeline", true) |
|
.attr("d", line); |
|
|
|
var dotContainer = container.append("g") |
|
.classed("dots", true); |
|
var deseasonalizedDotContainer = container.append("g") |
|
.classed("dots deseasonalized", true); |
|
|
|
var trendLine = container.append("line") |
|
.attr("class", "trend") |
|
.attr("x1", x(0)) |
|
.attr("y1", y(0)) |
|
.attr("x2", x(20)) |
|
.attr("y2", y(0)); |
|
var deseasonalizedTrendLine = container.append("line") |
|
.attr("class", "trend deseasonalized") |
|
.attr("x1", x(0)) |
|
.attr("y1", y(0)) |
|
.attr("x2", x(20)) |
|
.attr("y2", y(0)); |
|
|
|
d3.csv("timeserie.csv", dottype, function(error, dots) { |
|
updateTimeline(WITHOUT_TRANSITION); |
|
updateDots(WITHOUT_TRANSITION); |
|
updateTrend(WITHOUT_TRANSITION); |
|
|
|
updateDeseasonalizedTimeSerie(); |
|
updateDeseasonalizedDots(WITHOUT_TRANSITION); |
|
updateDeseasonalizedTrend(WITHOUT_TRANSITION); |
|
}); |
|
|
|
function dottype(d) { |
|
d.x = +d.x; |
|
d.y = +d.y; |
|
timeSerie.push(d); |
|
return d; |
|
} |
|
|
|
var line = d3.svg.line() |
|
.x(function(d) { return x(d.x); }) |
|
.y(function(d) { return y(d.y); }); |
|
|
|
function updateDots(withTransition) { |
|
dots = dotContainer.selectAll(".dot") |
|
.data(timeSerie); |
|
|
|
dots.enter() |
|
.append("circle") |
|
.classed("dot draggable", true) |
|
.attr("r", 5) |
|
.call(drag); |
|
|
|
dots.transition() |
|
.duration(withTransition? duration : 0) |
|
.attr("cx", function(d) { return x(d.x); }) |
|
.attr("cy", function(d) { return y(d.y); }) |
|
} |
|
|
|
function updateTimeline(withTransition) { |
|
timeline.data(timeSerie).transition() |
|
.duration(withTransition? duration : 0) |
|
.attr("d", line(timeSerie)); |
|
} |
|
|
|
function updateDeseasonalizedDots(withTransition){ |
|
var deseasonalizeDots = deseasonalizedDotContainer.selectAll(".dot.deseasonalized") |
|
.data(deseasonalizedTimeSerie); |
|
|
|
deseasonalizeDots.enter() |
|
.append("circle") |
|
.classed("dot deseasonalized", true) |
|
.attr("r", 8) |
|
.attr("cx", function(d) { return x(d.x); }); |
|
|
|
deseasonalizeDots.transition() |
|
.duration(withTransition? duration : 0) |
|
.attr("cy", function(d) { return y(d.y); }); |
|
} |
|
|
|
function invertTrend() { |
|
var serieLength = timeSerie.length; |
|
var countSum = 0; |
|
var mean = 0; |
|
|
|
timeSerie.forEach(function (d) { |
|
countSum += d.y |
|
}); |
|
mean = countSum/serieLength; |
|
timeSerie.forEach(function (d) { |
|
d.y = (mean-d.y)+mean; |
|
}); |
|
|
|
updateDeseasonalizedTimeSerie(); |
|
updateTimeline(WITH_TRANSITION); |
|
updateDots(WITH_TRANSITION); |
|
updateDeseasonalizedDots(WITH_TRANSITION); |
|
updateTrend(WITH_TRANSITION); |
|
updateDeseasonalizedTrend(WITH_TRANSITION); |
|
} |
|
|
|
function invertSeasonality() { |
|
//objective: for each season, make the inverse with regards to the season's mean |
|
|
|
var i = 0, j = 0; |
|
var seasonMean = 0; |
|
while (i<seasonCount) { |
|
seasonMean = deseasonalizedTimeSerie[i].y; |
|
j = 0; |
|
while (j<seasonLength) { |
|
timeSerie[i*seasonLength+j].y = (seasonMean - timeSerie[i*seasonLength+j].y) + seasonMean; |
|
j++; |
|
} |
|
i++; |
|
} |
|
|
|
updateTimeline(WITH_TRANSITION); |
|
updateDots(WITH_TRANSITION); |
|
updateDeseasonalizedDots(WITH_TRANSITION); |
|
updateTrend(WITH_TRANSITION); |
|
updateDeseasonalizedTrend(WITH_TRANSITION); |
|
} |
|
|
|
function permuteSeasonality() { |
|
//objective: in each season, n-th value becomes the (n-1)-th value, and the first one becomes the last one |
|
|
|
d3.transition() |
|
.duration(duration/2) |
|
.each("start", function() { |
|
line.defined(function(d, i) { return (i%4)!=0; }); |
|
updateTimeline(WITHOUT_TRANSITION); |
|
}) |
|
.transition() |
|
.duration(duration) |
|
.each("start", function() { |
|
var i = 0; |
|
while (i<timeSerie.length) { |
|
if (timeSerie[i].x%seasonLength === 1) { |
|
timeSerie[i].x += seasonLength-1; |
|
} else { |
|
timeSerie[i].x -= 1 |
|
} |
|
i++; |
|
} |
|
|
|
updateTimeline(WITH_TRANSITION); |
|
updateDots(WITH_TRANSITION); |
|
updateDeseasonalizedDots(WITH_TRANSITION); |
|
updateTrend(WITH_TRANSITION); |
|
updateDeseasonalizedTrend(WITH_TRANSITION); |
|
}) |
|
.transition() |
|
.duration(duration/2) |
|
.each("end", function() { |
|
timeSerie.sort(function(d0, d1){ return d0.x-d1.x; }); |
|
line.defined(function(d) { return true; }); |
|
updateTimeline(WITHOUT_TRANSITION); |
|
updateDots(WITHOUT_TRANSITION); |
|
}); |
|
|
|
} |
|
|
|
function updateDeseasonalizedTimeSerie() { |
|
//for each season, the mean is computed |
|
deseasonalizedTimeSerie = []; |
|
var seasonLength = 4; //for a sake of simplicity, this parameter is static, but technics allow to find seasonality from raw time serie (eg. [fast] Fourier transformation) |
|
var seasonCount = timeSerie.length/seasonLength; |
|
|
|
var i = 0, j = 0; |
|
var seasonCountSum = 0; |
|
while (i<seasonCount) { |
|
seasonCountSum = 0; |
|
j = 0; |
|
while (j<seasonLength) { |
|
seasonCountSum += timeSerie[i*seasonLength+j].y; |
|
j++; |
|
} |
|
deseasonalizedTimeSerie.push({x: (i+0.5)*seasonLength+0.5, y:seasonCountSum/seasonLength}); |
|
i++; |
|
} |
|
} |
|
|
|
function updateTrend(withTransition) { |
|
// The objective is to draw a line that is the closest line from each point |
|
// (cf. https://en.wikipedia.org/wiki/Linear_regression) |
|
// A simple regression line is of the form y=ax+b, where a is the trend of the time serie |
|
// below code computes 'a' and 'b' |
|
|
|
var serieLength = timeSerie.length; |
|
var timeInterval = 1 |
|
var countSum = 0; |
|
var orderCountSum = 0; |
|
|
|
timeSerie.forEach(function(d){ |
|
countSum += d.y; |
|
orderCountSum += (d.x)*(d.y); |
|
}); |
|
|
|
var a = (12*orderCountSum - 6*(serieLength+1)*countSum)/(timeInterval*serieLength*(serieLength-1)*(serieLength+1)); |
|
var b = (2*(2*serieLength+1)*countSum - 6*orderCountSum)/(serieLength*(serieLength-1)); |
|
|
|
trendLine |
|
.transition() |
|
.duration(withTransition? duration : 0) |
|
.attr("y1", y(b)) |
|
.attr("y2", y(a*serieLength+b)); |
|
} |
|
|
|
function updateDeseasonalizedTrend(withTransition) { |
|
var serieLength = timeSerie.length; |
|
var timeInterval = 1 |
|
var countSum = 0; |
|
var orderCountSum = 0; |
|
|
|
deseasonalizedTimeSerie.forEach(function(d){ |
|
countSum += d.y; |
|
orderCountSum += (d.x)*(d.y); |
|
}); |
|
|
|
var a = (12*orderCountSum - 6*(serieLength+1)*countSum)/(timeInterval*serieLength*(serieLength-1)*(serieLength+1))*seasonLength; |
|
var b = (2*(2*serieLength+1)*countSum - 6*orderCountSum)/(serieLength*(serieLength-1))*seasonLength; |
|
|
|
deseasonalizedTrendLine |
|
.transition() |
|
.duration(withTransition? duration : 0) |
|
.attr("y1", y(b)) |
|
.attr("y2", y(a*serieLength+b)); |
|
} |
|
|
|
function dragStarted(d) { |
|
d3.select(this).classed("dragging", true); |
|
} |
|
|
|
function dragged(d) { |
|
d.y += y.invert(d3.event.dy) |
|
updateTimeline(WITHOUT_TRANSITION); |
|
updateDots(WITHOUT_TRANSITION); |
|
updateTrend(WITHOUT_TRANSITION); |
|
updateDeseasonalizedTimeSerie(); |
|
updateDeseasonalizedDots(WITHOUT_TRANSITION); |
|
updateDeseasonalizedTrend(WITHOUT_TRANSITION); |
|
} |
|
|
|
function dragEnded(d) { |
|
d3.select(this).classed("dragging", false); |
|
} |
|
|
|
</script> |