Beerbug on Helium
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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