Skip to content

Instantly share code, notes, and snippets.

@feyderm
Last active April 26, 2019 06:25
Show Gist options
  • Save feyderm/200af08a2e8a04d47ac5fd49ba4fa5fa to your computer and use it in GitHub Desktop.
Save feyderm/200af08a2e8a04d47ac5fd49ba4fa5fa to your computer and use it in GitHub Desktop.
Concurrent temporal data (pan and zoom)
.eslintrc.js

A prototype to visualize concurrent temporal data, including:

  • Time intervals
  • Timestamped events
  • Infrequent, timestamped measurements
  • Frequent, timestamped measurements

Pan (click and drag) or zoom (scroll) to transform view.

<!DOCTYPE html>
<meta charset="utf-8">
<head>
<link rel="stylesheet" type="text/css" href="style.css">
</head>
<body>
<!--container for viz-->
<div id="container"></div>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="main.js"></script>
</body>
/*
PLOT TYPES:
1) time interval
2) timestamp of event
3) timestamp w/ infrequent measurement
4) timestamp w/ frequent measurement
*/
addAxisTime();
addViz('interval');
addViz('event');
addViz('infreq');
addViz('freq');
//---------------------
// TIME AXIS
//---------------------
function addAxisTime () {
const axis = d3.axisTop(getScaleTime());
addSVG('axis').call(axis);
}
//---------------------
// PLOTS (INITIAL)
//---------------------
function addViz (plot_type) {
const svg = addSVG(plot_type);
const data = getData()[plot_type];
switch (plot_type) {
case 'interval':
plotIntervals(svg, data);
break;
case 'event':
plotEvents(svg, data);
break;
case 'infreq':
plotInfreq(svg, data);
break;
case 'freq':
plotFreq(svg, data);
break;
default:
alert('incorrect plot type');
}
}
function plotIntervals (selection, data) {
selection.selectAll('rect')
.data(data)
.enter()
.append('rect')
.attr('class', 'interval')
.attr('x', d => getScaleTime()(d.on))
.attr('y', 0)
.attr('height', getHeight('interval'))
.attr('width', d => getScaleTime()(d.off) - getScaleTime()(d.on));
}
function plotEvents (selection, data) {
selection.selectAll('line')
.data(data)
.enter()
.append('line')
.attr('class', 'event')
.attr('x1', d => getScaleTime()(d))
.attr('x2', d => getScaleTime()(d))
.attr('y1', 0)
.attr('y2', getHeight('event'));
}
function plotInfreq (selection, data) {
const params = {
data,
scale_y: getScaleY(data, 'infreq'),
plot_type: 'infreq'
};
addAxisY(selection, params);
plotLine(selection, params);
plotCircles(selection, params);
}
function plotFreq (selection, data) {
const params = {
data,
scale_y: getScaleY(data, 'freq'),
plot_type: 'freq'
};
addAxisY(selection, params);
plotLine(selection, params);
}
function addAxisY (selection, params) {
const width = getWidth();
const axis_y = d3.axisLeft(params.scale_y)
.ticks(5)
.tickSizeInner(width)
.tickSizeOuter(0);
// extend ticks across plot to aid in discerning data values, ref[0]
selection.append('g')
.attr('class', 'axis')
.attr('transform', `translate(${width}, 0)`)
.call(axis_y);
// remove axis domain to minimize chart junk
selection.select('.domain')
.remove();
}
function plotCircles (selection, params) {
selection.append('g')
.selectAll('circle')
.data(params.data)
.enter()
.append('circle')
.attr('class', `circle_${params.plot_type}`)
.attr('cx', d => getScaleTime()(d.date_time))
.attr('cy', d => params.scale_y(d.ob))
.attr('r', '5');
}
function plotLine (selection, params) {
const line = d3.line()
.x(d => getScaleTime()(d.date_time))
.y(d => params.scale_y(d.ob));
selection.append('g')
.append('path')
.attr('class', `line_${params.plot_type}`)
.datum(params.data)
.attr('d', line);
}
//---------------------
// PLOTS (ZOOM)
//---------------------
function plotIntervalsZoom (zoom_scale) {
d3.selectAll('.interval')
.attr('x', d => zoom_scale(d.on))
.attr('width', d => zoom_scale(d.off) - zoom_scale(d.on));
}
function plotEventsZoom (zoom_scale) {
d3.selectAll('.event')
.attr('x1', d => zoom_scale(d))
.attr('x2', d => zoom_scale(d));
}
function plotInfreqZoom (zoom_scale) {
plotLineZoom(zoom_scale, 'infreq');
plotCirclesZoom(zoom_scale, 'infreq');
}
function plotFreqZoom (zoom_scale) {
plotLineZoom(zoom_scale, 'freq');
}
function plotCirclesZoom (zoom_scale, plot_type) {
d3.selectAll(`.circle_${plot_type}`)
.attr('cx', d => zoom_scale(d.date_time));
}
function plotLineZoom (zoom_scale, plot_type) {
const data = d3.select(`.line_${plot_type}`).datum();
const scale_y = getScaleY(data, `${plot_type}`);
const line_zoom = d3.line()
.x(d => zoom_scale(d.date_time))
.y(d => scale_y(d.ob));
d3.select(`.line_${plot_type}`)
.attr('d', line_zoom);
}
//---------------------
// SVG + DIMENSIONS
//---------------------
function addSVG (plot_type) {
const height = getHeight(plot_type);
const width = getWidth();
const margin = getMargins(plot_type);
const svg = d3.select('#container')
.append('svg')
.attr('id', `svg_${plot_type}`);
// clipping mask so that viz does not extend into y-axis during zoom, ref[1]
if (plot_type !== 'axis') {
svg.append('defs')
.append('clipPath')
.attr('id', `clip_${plot_type}`)
.append('rect')
.attr('height', height + margin.verticle)
.attr('width', width)
.attr('transform', `translate(0, -${margin.top})`);
}
return (
svg.attr('height', height + margin.verticle)
.attr('width', width + margin.horizontal)
.call(
d3.zoom()
.scaleExtent([1, 10])
.on('zoom', zoomed)
)
.append('g')
.attr('transform', `translate(${margin.left}, ${margin.top})`)
.attr('class', `g_${plot_type}`)
);
}
function getHeight (svg_type) {
let height;
// svg height excluding margins
switch (svg_type) {
case 'axis':
height = 10;
break;
case 'interval':
height = 5;
break;
case 'event':
height = 20;
break;
case 'infreq':
height = 75;
break;
case 'freq':
height = 75;
break;
default:
height = 20;
}
return height;
}
function getWidth () {
return 550;
}
// margin convention, ref[2]
function getMargins (svg_type) {
return {
top: svg_type === 'axis' ? 20 : 10,
bottom: 10,
right: 20,
left: 100,
get verticle () { return this.top + this.bottom; },
get horizontal () { return this.left + this.right; }
};
}
//---------------------
// ZOOM
//---------------------
function zoomed () {
const transform_svg = d3.zoomTransform(this);
// re-scale time axis, ref[3]
const zoom_scale = transform_svg.rescaleX(getScaleTime());
// transition axis
d3.select('.g_axis')
.transition()
.duration(50)
.call(
d3.axisTop(getScaleTime())
.scale(zoom_scale)
);
// update viz w/ re-scaled axis;
plotIntervalsZoom(zoom_scale);
plotEventsZoom(zoom_scale);
plotInfreqZoom(zoom_scale);
plotFreqZoom(zoom_scale);
// apply zoom state to all SVGs
// (states stored on the element to which the zoom is applied rather
// than globally to allow for independent zoom of elements - ref[4]; however
// coordinated states are needed here, without which jumping occurs when
// zooming in on one plot and out on another)
d3.selectAll('svg')
.call(
d3.zoom().transform,
transform_svg
);
}
//---------------------
// SCALES
//---------------------
function getScaleTime () {
const date_extent = [
getDateExtent().start,
getDateExtent().end
];
return (
d3.scaleTime()
.domain(date_extent)
.range([0, getWidth()])
);
}
function getScaleY (data, plot_type) {
const height = getHeight(plot_type);
return (
d3.scaleLinear()
.domain(d3.extent(data, d => d.ob))
.range([height, 0])
);
}
//---------------------
// MOCK DATA
//---------------------
// start/end dates
function getDateExtent () {
return {
start: new Date(2017, 4, 1),
end: new Date(2017, 4, 15)
};
}
// date-time interval
function getDataInterval () {
const days = d3.range(1, 16);
const data = days.map(day => {
return {
on: new Date(2017, 4, day, 7),
off: new Date(2017, 4, day, 22)
};
})
.filter(date_time => {
return date_time.on <= getDateExtent().end &&
date_time.off <= getDateExtent().end;
});
return data;
}
// date-time of event
function getDataEvent () {
const days = d3.range(1, 16);
const hours = d3.range(0, 24, 8);
const data = d3.cross(days, hours, (day, hour) => {
return new Date(2017, 4, day, hour);
})
.filter(date_time => {
return date_time <= getDateExtent().end;
});
return data;
}
// date-time of infrequent observations
function getDataInfreq () {
const obs = [0.5, 0.75, 3, 4, 10, 11, 15, 17];
const data = obs.map((ob, i) => {
return {
date_time: new Date(2017, 4, (i + 1) * 2 , 9),
ob: ob
};
})
.filter(obs => {
return obs.date_time <= getDateExtent().end;
});
return data;
}
// date-time of frequent observations
function getDataFreq () {
const data = d3.timeMinute
.every(60)
.range(
getDateExtent().start,
getDateExtent().end
)
.map((date_time, i) => {
return {
date_time: date_time,
ob: -Math.log(Math.random() * (i + 1))
};
});
return data;
}
function getData () {
return {
interval: getDataInterval(),
event: getDataEvent(),
infreq: getDataInfreq(),
freq: getDataFreq()
};
}
/*
REFERENCES
[0] https://bl.ocks.org/mbostock/3371592
[1] https://bl.ocks.org/mbostock/431a331294d2b5ddd33f947cf4c81319
[2] https://bl.ocks.org/mbostock/3019563
[3] https://bl.ocks.org/mbostock/db6b4335bf1662b413e7968910104f0f
[4] https://github.com/d3/d3-zoom/blob/master/README.md#zoom-transforms
*/
#container {
transform: translate(0, 50px);
}
svg {
/* remove spacing between svgs, ref[0] */
margin-top: -4px;
}
.interval {
fill: #ff4dff;
stroke: #b300b3;
opacity: 0.7;
clip-path: url(#clip_interval);
}
.event {
stroke-width: 2px;
stroke: #0066ff;
clip-path: url(#clip_event);
}
.axis .tick line {
opacity: 0.1;
stroke-width: 2px;
}
.line_infreq {
fill: none;
stroke: #000000;
stroke-width: 2px;
opacity: 0.3;
clip-path: url(#clip_infreq);
}
.circle_infreq {
fill: #ffffff;
stroke: #29a329;
stroke-width: 2px;
clip-path: url(#clip_infreq);
}
.line_freq {
fill: none;
stroke: #000000;
clip-path: url(#clip_freq);
}
/*
REFERENCES
[0] https://css-tricks.com/fighting-the-space-between-inline-block-elements/
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment