Skip to content

Instantly share code, notes, and snippets.

@beerriot
Created January 29, 2017 23:05
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save beerriot/307a659870c5d66c7b20650830a5a6a4 to your computer and use it in GitHub Desktop.
Save beerriot/307a659870c5d66c7b20650830a5a6a4 to your computer and use it in GitHub Desktop.
Beerbug on Helium
body {
font: 10px sans-serif;
}
.axis path,
.axis line {
fill: none;
stroke: #000;
shape-rendering: crispEdges;
}
.x.axis path {
display: none;
}
.area {
fill: steelblue;
opacity: 0.25;
}
.line {
fill: none;
stroke: steelblue;
stroke-width: 1.5px;
}
svg text {
cursor: default;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
svg text::selection {
background: none;
}
<!DOCTYPE html>
<html>
<meta charset="utf-8">
<link rel="stylesheet" type="text/css" href="graph.css" />
<body>
<p>shift = zoom in; alt = zoom out</p>
<script src="https://d3js.org/d3.v3.min.js"></script>
<!-- private.js defines sensorID and authKey for graph.js -->
<script src="private.js"></script>
<script src="graph.js"></script>
</body>
</html>
var margin = {top: 20, right: 20, bottom: 30, left: 50},
width = 960 - margin.left - margin.right,
height = 220 - margin.top - margin.bottom;
var parseDate = d3.time.format.utc("%Y-%m-%dT%H:%M:%SZ").parse;
var x = d3.time.scale()
.range([0, width]);
var y = d3.scale.linear()
.range([height, 0]);
var xAxis = d3.svg.axis()
.scale(x)
.orient("bottom");
var yAxis = d3.svg.axis()
.scale(y)
.orient("left");
var area = d3.svg.area()
.x(function(d) { return x(d.date); })
.y0(function(d) { return y(d.low); })
.y1(function(d) { return y(d.high); });
var line = d3.svg.line()
.x(function(d) { return x(d.date); })
.y(function(d) { return y(d.mean); });
var mouse_down_point = null;
function make_graph(label) {
var graph = {data: {}};
graph.svg = d3.select("body").append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom);
graph.ygroup = graph.svg.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
graph.yaxis = graph.ygroup.append("g")
.attr("class", "y axis");
graph.yaxis
.append("text")
.attr("transform", "rotate(-90)")
.attr("y", 6)
.attr("dy", ".71em")
.style("text-anchor", "end")
.text(label);
graph.xgroup = graph.svg.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
graph.xaxis = graph.xgroup.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + height + ")")
graph.ggroup = graph.svg.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
graph.rangePlot = graph.ggroup.append("path")
.attr("class", "area");
graph.meanPlot = graph.ggroup.append("path")
.attr("class", "line")
graph.svg.on("mousedown", function(e) {
mouse_down_point = d3.mouse(this);
mouse_down_shiftalt = d3.event.shiftKey || d3.event.altKey;
if (mouse_down_shiftalt) {
insert_zoom_rectangle();
}
}).on("mouseup", function() {
if (mouse_down_point != null) {
p = d3.mouse(this);
if (mouse_down_shiftalt) {
finish_zoom();
} else {
lastDelta += p[0]-mouse_down_point[0];
replot();
}
mouse_down_point = null;
}
}).on("mousemove", function() {
if (mouse_down_point != null) {
p = d3.mouse(this);
if (mouse_down_shiftalt) {
resize_zoom_rectangle(p);
} else {
move_graphs(p[0]-mouse_down_point[0]);
}
}
});
//todo: need mouseout or mouseleave to unset mouse_down_point too?
return graph;
}
var graphNames = [
{port: "t", label: "Temperature (ºC)"},
{port: "sg", label: "Specific Gravity"},
{port: "b", label: "Battery (mV)"}];
var graphs = {}
for (i in graphNames) {
graphs["agg("+graphNames[i].port+")"] = make_graph(graphNames[i].label);
}
var lastDelta = 0;
function move_graphs(deltax) {
var newX = margin.left+deltax+lastDelta;
for (i in graphs) {
graphs[i].xgroup.attr("transform", "translate("+newX+","+margin.top+")");
graphs[i].ggroup.attr("transform", "translate("+newX+","+margin.top+")");
}
}
function plot(graph) {
// TODO: y should be per-graph
y.domain([d3.min(graph.data[currentZoomLevel].points,
function(d) { return d.low; }),
d3.max(graph.data[currentZoomLevel].points,
function(d) { return d.high; })]);
graph.rangePlot.datum(graph.data[currentZoomLevel].points)
.attr("d", area);
graph.meanPlot.datum(graph.data[currentZoomLevel].points)
.attr("d", line);
graph.xaxis.call(xAxis);
graph.yaxis.call(yAxis);
}
var zoomLevels = [
{name: "1m", relative: 1},
{name: "2m", relative: 2},
{name: "5m", relative: 5},
{name: "10m", relative: 10},
{name: "30m", relative: 30},
{name: "1h", relative: 60},
{name: "6h", relative: 6*60},
{name: "12h", relative: 12*60},
{name: "1d", relative: 24*60}];
var currentZoomLevel = 3; //10m
var currentPixelsPerPoint;
function insert_zoom_rectangle() {
for (i in graphs) {
graphs[i].zoom = graphs[i].svg.append("rect")
.attr("stroke", "#cccccc")
.attr("stroke-width", 3)
.attr("fill", "#cccccc")
.attr("fill-opacity", 0.25)
.attr("x", mouse_down_point[0])
.attr("y", -3)
.attr("width", 0)
.attr("height", height+margin.top+margin.bottom+6);
}
}
function resize_zoom_rectangle(p) {
var newX, newWidth;
if (p[0] < mouse_down_point[0]) {
newX = p[0];
newWidth = mouse_down_point[0]-p[0];
} else {
newX = mouse_down_point[0];
newWidth = p[0]-mouse_down_point[0];
}
for (i in graphs) {
graphs[i].zoom.attr("x", newX).attr("width", newWidth);
}
}
function finish_zoom() {
var zoomWidth, zoomX;
for (i in graphs) {
zoomWidth = graphs[i].zoom.attr("width");
zoomX = graphs[i].zoom.attr("x")-margin.left;
//loop over first key name
break;
}
var ratio = d3.event.shiftKey ?
width/zoomWidth : //zoom-in
zoomWidth/width; //zoom-out
var oldrange = x.range();
x.range([oldrange[0]*ratio, oldrange[1]*ratio]);
lastDelta = d3.event.shiftKey ?
(lastDelta-zoomX)*ratio : //left side of zoom-in to left side of graph
lastDelta*ratio+zoomX; //left size of graph to left side of zoom-out
currentPixelsPerPoint = currentPixelsPerPoint * ratio;
for (i in graphs) {
graphs[i].zoom.remove();
delete graphs[i].zoom;
plot(graphs[i]);
}
move_graphs(0);
var changeZoom = currentPixelsPerPoint < desiredPixelsPerPoint ? 1 : -1;
var newZoomLevel = currentZoomLevel;
var diff = Math.pow(desiredPixelsPerPoint - currentPixelsPerPoint, 2);
while ((newZoomLevel > 0 && changeZoom < 0) ||
(newZoomLevel < zoomLevels.length-1 && changeZoom > 0)) {
var tryZoomLevel = newZoomLevel+changeZoom;
var tryPixelsPerPoint = currentPixelsPerPoint *
(zoomLevels[tryZoomLevel].relative /
zoomLevels[currentZoomLevel].relative);
var trydiff = Math.pow(desiredPixelsPerPoint - tryPixelsPerPoint, 2);
if (trydiff < diff) {
diff = trydiff;
newZoomLevel = tryZoomLevel;
} else {
break;
}
}
if (newZoomLevel != currentZoomLevel) {
console.log("Moving to zoom level "+newZoomLevel);
currentPixelsPerPoint = currentPixelsPerPoint *
(zoomLevels[newZoomLevel].relative /
zoomLevels[currentZoomLevel].relative);
currentZoomLevel = newZoomLevel;
var startTime = x.invert(-lastDelta).toISOString()
var endTime = x.invert(width-lastDelta).toISOString()
var zoomRangeInfo = rangeInfo[currentZoomLevel];
if (startTime >= zoomRangeInfo.oldestLoaded &&
endTime <= zoomRangeInfo.newestLoaded) {
//we already have this data
replot(true);
} else if (startTime >= zoomRangeInfo.oldestLoaded &&
startTime <= zoomRangeInfo.newestLoaded &&
zoomRangeInfo.next != null) {
//range starts at or before newest end - follow link
requestData(zoomRangeInfo.next, null, currentZoomLevel);
} else if (endTime <= zoomRangeInfo.newestLoaded &&
endTime >= zoomRangeInfo.oldestLoaded &&
zoomRangeInfo.prev != null) {
//range endss at or after oldest end - follow link
requestData(zoomRangeInfo.prev, null, currentZoomLevel);
} else {
//range is outside link field - fresh query
rangeInfo[currentZoomLevel] = emptyRangeInfo();
//totalPointsNeeded was calculated for desiredPixelsPerPoint,
//so scale it to currentPixelsPerPoint
var pointsNeeded = Math.round(
d3.max([totalPointsNeeded,
totalPointsNeeded *
(currentPixelsPerPoint / desiredPixelsPerPoint)]));
var props = helium_timeseries_properties(
null, //don't use a startTime, or you won't get a prev link
endTime,
pointsNeeded,
portNames,
stats,
zoomLevels[currentZoomLevel].name);
requestData(sensorID, props, currentZoomLevel, true);
}
} else {
//this will trigger loading of missing data
replot();
}
}
function replot(force = false) {
var earlier = x.invert(-lastDelta);
var later = x.invert(width-lastDelta);
var zoomRangeInfo = rangeInfo[currentZoomLevel];
if (earlier >= zoomRangeInfo.oldestLoaded &&
later <= zoomRangeInfo.newestLoaded) {
// we already have this data
if (force) {
for (i in graphs) {
plot(graph[i]);
}
}
return;
}
if (earlier < zoomRangeInfo.oldestLoaded &&
zoomRangeInfo.prev != null) {
console.log("following prev link");
requestData(zoomRangeInfo.prev, null, currentZoomLevel);
zoomRangeInfo.prev = null;
return;
}
if (later > zoomRangeInfo.newestLoaded &&
zoomRangeInfo.next != null) {
console.log("following next link");
requestData(zoomRangeInfo.next, null, currentZoomLevel);
delete zoomRangeInfo.next;
return;
}
console.log("No links to follow - need to pick dates: zri:("+
zoomRangeInfo.oldestLoaded+" - "+
zoomRangeInfo.newestLoaded+") dates:("+
earlier+" - "+later+")");
//TODO: request with endTime == later, but need a way to prevent
//this when we've already loaded the latest data
}
function emptyRangeInfo() {
return {prev: null, next: null,
newestLoaded: null, oldestLoaded: null};
}
var rangeInfo = zoomLevels.map(emptyRangeInfo);
function consume_helium_response(zoomLevel, HeResp) {
var data = HeResp.data;
var newestDate = data.length > 0 ?
parseDate(data[0].attributes.timestamp) : null;
var oldestDate = data.length > 0 ?
parseDate(data[data.length-1].attributes.timestamp) : null;
var zoomRangeInfo = rangeInfo[zoomLevel];
if (zoomRangeInfo.newestLoaded == null ||
// all dates are > null
zoomRangeInfo.newestLoaded < newestDate) {
zoomRangeInfo.newestLoaded = newestDate;
zoomRangeInfo.next = HeResp.links.next;
}
if (zoomRangeInfo.oldestLoaded == null ||
(oldestDate != null && oldestDate < zoomRangeInfo.oldestLoaded)) {
zoomRangeInfo.oldestLoaded = oldestDate;
zoomRangeInfo.prev = HeResp.links.prev;
}
data.forEach(function(d) {
var pd = {};
pd.date = parseDate(d.attributes.timestamp);
var value = d.attributes.value;
pd.low = value.min;
pd.high = value.max;
pd.mean = value.avg;
var g = graphs[d.attributes.port];
if (g != null) {
var d = g.data[currentZoomLevel];
if (d == null) {
g.data[currentZoomLevel] = {points: [pd]};
// WARNING: edits to d will not make it to g.data
} else if (pd.date > d.points[0].date) {
d.points.unshift(pd);
} else if (pd.date < d.points[d.points.length-1].date) {
d.points.push(pd);
} else {
console.log("data in middle");
}
} else {
console.log("unknown graph "+d.attributes.port);
}
});
}
function helium_sensor_timeseries_url(sensorID, properties) {
var url = "https://api.helium.com/v1/sensor/"+sensorID+"/timeseries"
var first = true;
for (k in properties) {
var v = properties[k];
url += (first ? "?" : "&") +
encodeURIComponent(k) + "=" + encodeURIComponent(v);
first = false;
}
return url;
}
function helium_timeseries_properties(start, end, pageSize, ports, aggTypes, aggSize) {
var p = {}
if (start != null) {
p["filter[start]"] = start;
}
if (end != null) {
p["filter[end]"] = end;
}
if (pageSize != null) {
p["page[size]"] = pageSize;
}
if (ports != null) {
p["filter[port]"] = ports.join(",");
}
if (aggTypes != null) {
p["agg[type]"] = aggTypes.join(",");
}
if (aggSize != null) {
p["agg[size]"] = aggSize;
}
return p;
}
function requestData(sensorIDorURL, properties, zoomLevel, firstInitialization=false) {
var url = properties == null ? sensorIDorURL :
helium_sensor_timeseries_url(sensorIDorURL, properties);
d3.json(url)
.header("Authorization", authKey)
.get(function(error, HeResp) {
if (error) throw error;
consume_helium_response(zoomLevel, HeResp);
var zoomRangeInfo = rangeInfo[zoomLevel];
if (firstInitialization) {
lastDelta = 0;
move_graphs(0);
x.range([0, width]);
x.domain([zoomRangeInfo.oldestLoaded,
zoomRangeInfo.newestLoaded]);
for (i in graphs) {
currentPixelsPerPoint = width / graphs[i].data[zoomLevel].points.length;
break;
}
} else {
x.range([x(zoomRangeInfo.oldestLoaded),
x(zoomRangeInfo.newestLoaded)]);
x.domain([zoomRangeInfo.oldestLoaded,
zoomRangeInfo.newestLoaded]);
}
for (i in graphs) {
plot(graphs[i]);
}
replot();
});
}
var keydownthis, keyupthis;
d3.select("body").on("keydown", chooseCursor);
d3.select("body").on("keyup", chooseCursor);
function chooseCursor() {
var cursor = "default";
if (d3.event.altKey) {
cursor = "zoom-out";
} else if (d3.event.shiftKey) {
cursor = "zoom-in";
}
d3.select("body").style({"cursor": cursor});
}
var desiredPixelsPerPoint = 3;
var lowPixelsPerPoint = desiredPixelsPerPoint/2;
var highPixelsPerPoint = desiredPixelsPerPoint*2;
var initialPointsInWindow = width/desiredPixelsPerPoint;
var totalPointsNeeded = initialPointsInWindow * graphNames.length;
var portNames = graphNames.map(function(g) { return g.port; });
var stats = ["min", "max", "avg"];
var initialProperties = helium_timeseries_properties(
null, null, // start at latest, with default window
totalPointsNeeded, portNames, stats, zoomLevels[currentZoomLevel].name);
requestData(sensorID, initialProperties, currentZoomLevel, true);
var sensorID = "put-your-uuid-here"
var authKey = "put-your-authorization-key-here"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment