Hehe
- Scroll or mouse over table to navigate list
- Scroll or touch over chart to zoom in or out
Revising this one
Using Visvalingam line simplification algorithm, hackily adapted from
Hehe
Revising this one
Using Visvalingam line simplification algorithm, hackily adapted from
<!DOCTYPE html> | |
<meta charset="utf-8"> | |
<style> | |
* { | |
box-sizing: border-box; | |
} | |
html, body { | |
width: 100%; | |
height: 100%; | |
margin: 0; | |
overflow: hidden; | |
font-family: helvetica, sans-serif; | |
} | |
.table-wrapper { | |
position: relative; | |
height: 100%; | |
padding-top: 20px; | |
width: 8em; | |
margin: 0 0 0 auto; | |
} | |
.table-scroll { | |
height: 100%; | |
overflow: scroll; | |
} | |
table { | |
margin-right: 0; | |
margin-left: auto; | |
} | |
thead { | |
background: white; | |
} | |
th > div { | |
position: absolute; | |
width: 4em; | |
text-align: left; | |
background: white; | |
padding: 3px; | |
top: 0; | |
} | |
td { | |
padding: 3px; | |
width: 4em; | |
} | |
td:last-child, | |
th:last-child > div { | |
text-align: right; | |
} | |
svg { | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: calc(100% - 8.5em); | |
height: 100%; | |
} | |
path { | |
fill: none; | |
stroke-width: 1; | |
stroke: black; | |
} | |
rect { | |
pointer-events: all; | |
fill: none; | |
} | |
</style> | |
<body> | |
<div class="table-wrapper"> | |
<div class="table-scroll"> | |
<table> | |
<thead> | |
<th><div>Name</div></th> | |
<th><div>Value</div></th> | |
</thead> | |
<tbody></tbody> | |
</table> | |
</div> | |
</div> | |
<svg></svg> | |
</body> | |
<script src="https://d3js.org/d3.v4.min.js"></script> | |
<script src="visvalingam.js"></script> | |
<script> | |
var format = d3.format("$.0f"), | |
marginLeft = 40, | |
indexThreshold = 4; | |
var data = d3.range(100).map(getRandomSeries) | |
.sort((a,b) => b.data[b.data.length-1][1] - a.data[a.data.length-1][1]) | |
var x = d3.scaleTime() | |
.domain(d3.extent(d3.merge(data.map(d => d.data)).map(d => d[0]))) | |
.range([marginLeft, d3.select("svg").node().getBoundingClientRect().width]) | |
var y = d3.scaleLinear() | |
.domain(d3.extent(d3.merge(data.map(d => d.data)).map(d => d[1]))) | |
.range([innerHeight, 0]) | |
// add a third entry to each array element with triangle area for simplify algo | |
data.forEach(d => { | |
simplify(d.data) | |
}) | |
var line = d3.line() | |
.x(d => x(d[0])) | |
.y(d => y(d[1])) | |
var row = d3.select("tbody") | |
.selectAll("tr") | |
.data(data) | |
.enter() | |
.append("tr") | |
.on("mouseenter", function(d,i) { | |
render(i); | |
}) | |
row.append("td").text(d => d.name) | |
row.append("td").text(d => format(d.data[d.data.length-1][1] - 1)) | |
var yAxis = d3.axisLeft(y).tickFormat(format) | |
var yAxisG = d3.select("svg").append("g") | |
.attr("class", "axis axis--y") | |
.attr("transform", "translate(" + marginLeft + ",0)") | |
.call(yAxis) | |
var path = d3.select("svg") | |
.selectAll("path.line") | |
.data(data) | |
.enter() | |
.append("path") | |
.classed("line", true) | |
.attr("d", d => line(d.data)) | |
var scrollScale = d3.scaleLinear() | |
.domain([0, d3.select(".table-scroll").node().scrollHeight - (innerHeight - 20)]) | |
.range([0, data.length-1]) | |
var opacityScale = d3.scaleLinear() | |
.domain([0, indexThreshold]) | |
.range([1, 0]) | |
.clamp(true) | |
var zoom = d3.zoom() | |
.scaleExtent([1, data.length]) | |
.on("zoom", zoomed); | |
d3.select("svg") | |
.append("rect") | |
.attr("width", innerWidth) | |
.attr("height", innerHeight) | |
.call(zoom) | |
function zoomed() { | |
indexThreshold = d3.event.transform.k | |
opacityScale.domain([0, indexThreshold]) | |
render(lastIndex) | |
} | |
d3.select(".table-scroll").on("scroll", function() { | |
render(Math.round(scrollScale(this.scrollTop))); | |
}) | |
var simplifyAreaScale = d3.scaleLinear() | |
.domain([3, data.length]) | |
.range(d3.extent(d3.merge(data.map(d => d.data)).map(d => d[2]))) | |
.clamp(true) | |
var lastIndex = 0; | |
function render(index) { | |
lastIndex = index | |
row | |
.style("opacity", (d,i) => opacityScale(Math.abs(index - i))) | |
.style("font-weight", (d,i) => i == index ? 'bold' : 'normal') | |
path | |
.style("opacity", (d,i) => opacityScale(Math.abs(index - i))) | |
.style("stroke-width", (d,i) => i == index ? 3 : 1) | |
.each(d => d.simplified = d.data.filter((d,i,arr) => i == 0 || i == arr.length-1 || d[2] >= simplifyAreaScale(indexThreshold))) | |
// .attr("d", d => line(d.simplified)) | |
y.domain( | |
padExtent( | |
d3.extent( | |
d3.merge( | |
data | |
.filter((d,i) => Math.abs(index - i) < indexThreshold) | |
.map(d => d.data) | |
).map(d => d[1]) | |
) | |
) | |
) | |
// var t = d3.transition() | |
// .duration(250) | |
// .ease(d3.easeLinear) | |
path | |
// .transition(t) | |
.attr("d", d => line(d.simplified)) | |
yAxisG | |
// .transition(t) | |
.call(yAxis) | |
} | |
function getRandomSeries() { | |
return { | |
name: getRandomTicker(), | |
data: getRandomTimeSeries(100) | |
} | |
} | |
function getRandomTicker() { | |
var length = Math.ceil(Math.random()*4); | |
var chars = 'abcdefghijklmnopqrstuvwxyz'; | |
return d3.range(length).map(() => chars[Math.floor(Math.random()*chars.length)].toUpperCase()).join(''); | |
} | |
function getRandomTimeSeries(numPoints) { | |
var data = d3.range(numPoints).map(d => [ | |
d3.interpolateDate(new Date("2000/01/01"), new Date("2016/10/01"))(d/numPoints), | |
undefined | |
]) | |
data.forEach(function(d,i,arr) { | |
if(i==0) { | |
d[1] = d3.randomNormal(75, 30)() | |
} else { | |
d[1] = arr[i-1][1] * d3.randomNormal(1, .02)() | |
} | |
}) | |
return data | |
} | |
function padExtent(extent) { | |
var d = extent[1] - extent[0] | |
return [ | |
extent[0] - d * .25, | |
extent[1] + d * .25 | |
] | |
} | |
</script> |
(function() { | |
window.simplify = function(points) { | |
var heap = minHeap(), | |
maxArea = 0, | |
triangle; | |
var triangles = []; | |
if (points.some(function(p) { return p == null; })) return null; | |
for (var i = 1, n = points.length - 1; i < n; ++i) { | |
triangle = points.slice(i - 1, i + 2); | |
if (triangle[1][2] = area(triangle)) { | |
triangles.push(triangle); | |
heap.push(triangle); | |
} | |
} | |
for (var i = 0, n = triangles.length; i < n; ++i) { | |
triangle = triangles[i]; | |
triangle.previous = triangles[i - 1]; | |
triangle.next = triangles[i + 1]; | |
} | |
while (triangle = heap.pop()) { | |
// If the area of the current point is less than that of the previous point | |
// to be eliminated, use the latter’s area instead. This ensures that the | |
// current point cannot be eliminated without eliminating previously- | |
// eliminated points. | |
if (triangle[1][2] < maxArea) triangle[1][2] = maxArea; | |
else maxArea = triangle[1][2]; | |
if (triangle.previous) { | |
triangle.previous.next = triangle.next; | |
triangle.previous[2] = triangle[2]; | |
update(triangle.previous); | |
} else { | |
triangle[0][2] = triangle[1][2]; | |
} | |
if (triangle.next) { | |
triangle.next.previous = triangle.previous; | |
triangle.next[0] = triangle[0]; | |
update(triangle.next); | |
} else { | |
triangle[2][2] = triangle[1][2]; | |
} | |
} | |
function update(triangle) { | |
heap.remove(triangle); | |
triangle[1][2] = area(triangle); | |
heap.push(triangle); | |
} | |
return points; | |
} | |
function compare(a, b) { | |
return a[1][2] - b[1][2]; | |
} | |
function area(t) { | |
return Math.abs((t[0][0] - t[2][0]) * (t[1][1] - t[0][1]) - (t[0][0] - t[1][0]) * (t[2][1] - t[0][1])); | |
} | |
function minHeap() { | |
var heap = {}, | |
array = []; | |
heap.push = function() { | |
for (var i = 0, n = arguments.length; i < n; ++i) { | |
var object = arguments[i]; | |
up(object.index = array.push(object) - 1); | |
} | |
return array.length; | |
}; | |
heap.pop = function() { | |
var removed = array[0], | |
object = array.pop(); | |
if (array.length) { | |
array[object.index = 0] = object; | |
down(0); | |
} | |
return removed; | |
}; | |
heap.remove = function(removed) { | |
var i = removed.index, | |
object = array.pop(); | |
if (i !== array.length) { | |
array[object.index = i] = object; | |
(compare(object, removed) < 0 ? up : down)(i); | |
} | |
return i; | |
}; | |
function up(i) { | |
var object = array[i]; | |
while (i > 0) { | |
var up = ((i + 1) >> 1) - 1, | |
parent = array[up]; | |
if (compare(object, parent) >= 0) break; | |
array[parent.index = i] = parent; | |
array[object.index = i = up] = object; | |
} | |
} | |
function down(i) { | |
var object = array[i]; | |
while (true) { | |
var right = (i + 1) << 1, | |
left = right - 1, | |
down = i, | |
child = array[down]; | |
if (left < array.length && compare(array[left], child) < 0) child = array[down = left]; | |
if (right < array.length && compare(array[right], child) < 0) child = array[down = right]; | |
if (down === i) break; | |
array[child.index = i] = child; | |
array[object.index = i = down] = object; | |
} | |
} | |
return heap; | |
} | |
})(); |