Last active
February 6, 2019 16:30
-
-
Save belst/67ef05a4cb4b0efadb3c3cf7c1c895a6 to your computer and use it in GitHub Desktop.
Visualization of env data
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 lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<meta http-equiv="X-UA-Compatible" content="ie=edge"> | |
<title>Weather API</title> | |
<style> | |
body { | |
margin: 0 auto; | |
} | |
svg { | |
margin: 20px auto; | |
display: block; | |
} | |
path { | |
clip-path: url(#clip); | |
} | |
rect { | |
pointer-events: all; | |
} | |
.valbox { | |
margin-left: 5px; | |
padding: 5px; | |
background-color: #efefef; | |
opacity: 0.7; | |
border-radius: 4px; | |
border: 1px solid white; | |
} | |
.valbox ul { | |
margin: 0; | |
padding: 0; | |
} | |
.valbox li { | |
list-style-type: none; | |
} | |
.symbol { | |
clip-path: none; | |
} | |
</style> | |
</head> | |
<body> | |
<svg width="960" height="500"> | |
<foreignObject style="pointer-events: none"> | |
<div class="valbox" style="display: inline-block; z-index: -5; pointer-events: none;"> | |
<ul> | |
<li id="temp"></li> | |
<li id="press"></li> | |
<li id="humid"></li> | |
</ul> | |
<div class="clearfix" ></div> | |
</div> | |
</foreignObject> | |
</svg> | |
<script src="https://d3js.org/d3.v5.min.js"></script> | |
<script> | |
Number.prototype.clamp = function(min, max) { | |
return Math.min(Math.max(this, min), max); | |
}; | |
const binarySearch = (value, array, accessor) => { | |
let l = 0; | |
let u = array.length - 1; | |
let c = accessor; | |
if (!c) { | |
c = i => i; | |
} | |
while (l <= u) { | |
const m = Math.floor((l + u) / 2); | |
if (c(array[m]) < c(value)) { | |
l = m + 1; | |
} else if (c(array[m]) > c(value)) { | |
u = m - 1; | |
} else { | |
return m | |
} | |
} | |
l = l.clamp(0, array.length - 1); | |
u = u.clamp(0, array.length - 1); | |
if (Math.abs(c(array[u]) - c(value)) < Math.abs(c(array[l]) - c(value))) { | |
return u; | |
} else { | |
return l; | |
} | |
} | |
(async () => { | |
const svg = d3.select('svg'), | |
margin = {top: 40, right: 20, bottom: 110, left: 135}, | |
margin2 = {top: 430, right: 20, bottom: 30, left: 135}, | |
width = +svg.attr('width') - margin.left - margin.right, | |
height = +svg.attr('height') - margin.top - margin.bottom, | |
height2 = +svg.attr('height') - margin2.top - margin2.bottom; | |
svg.insert('defs', 'foreignObject').append('clipPath') | |
.attr('id', 'clip') | |
.append('rect') | |
.attr('width', width) | |
.attr('height', height); | |
const yScales = { | |
humid: d3.scaleLinear().range([height, 0]), | |
temp: d3.scaleLinear().range([height, 0]), | |
press: d3.scaleLinear().range([height, 0]), | |
}; | |
const y2Scales = { | |
humid: d3.scaleLinear().range([height2, 0]), | |
temp: d3.scaleLinear().range([height2, 0]), | |
press: d3.scaleLinear().range([height2, 0]), | |
}; | |
const x = d3.scaleTime().range([0, width]), | |
x2 = x.copy(); | |
const z = d3.scaleOrdinal().range(d3.schemeCategory10) | |
.domain(['humid', 'temp', 'press']); | |
const shape = d3.scaleOrdinal().range(d3.symbols) | |
.domain(['humid', 'temp', 'press']); | |
const symbol = d3.symbol(); | |
const tempAxis = d3.axisLeft(yScales.temp).ticks(10, 's'), | |
humidAxis = d3.axisLeft(yScales.humid).ticks(10, 's'), | |
pressAxis = d3.axisLeft(yScales.press).ticks(10, 's'); | |
const brushed = () => { | |
const s = d3.event.selection || x2.range(); | |
data = overview; | |
x.domain(s.map(x2.invert, x2)); | |
focus.selectAll('.line').remove(); | |
let focusl = focus.selectAll('.line') | |
.data(data) | |
.enter().append('g') | |
.attr('class', 'line'); | |
//focusl = focusl.merge(focus.selectAll('.line').data(data)); | |
focusl.append('path') | |
.attr('class', 'line') | |
.attr('d', d => area(x, yScales[d.id])(d.values)) | |
.style('fill', d => z(d.id)) | |
.style('opacity', 0.3); | |
focusl.append('path') | |
.attr('class', 'line') | |
.attr('d', d => line(x, yScales[d.id])(d.values)) | |
.style('stroke', d => z(d.id)) | |
.style('fill', 'none'); | |
focus.select('.axis--x').call(d3.axisBottom(x)); | |
} | |
const endfn = async () => { | |
const s = d3.event.selection || x2.range(); | |
[start, end] = s.map(x2.invert, x2); | |
url.searchParams.set('start', start.toISOString()); | |
url.searchParams.set('end', end.toISOString()); | |
data = mapdata(await d3.json(url)); | |
x.domain([start, end]); | |
focus.selectAll('.line').remove(); | |
let focusl = focus.selectAll('.line') | |
.data(data) | |
.enter().append('g') | |
.attr('class', 'line'); | |
focusl.append('path') | |
.attr('d', d => area(x, yScales[d.id])(d.values)) | |
.style('fill', d => z(d.id)) | |
.style('stroke', 'none') | |
.style('opacity', 0.3); | |
focusl.append('path') | |
.attr('d', d => line(x, yScales[d.id])(d.values)) | |
.style('stroke', d => z(d.id)) | |
.style('fill', 'none'); | |
focusl.append('path') | |
.attr('class', 'symbol') | |
.attr('opacity', '0') | |
.style('fill', d => z(d.id)) | |
.attr('d', d => symbol.type(shape(d.id))()); | |
focus.select('.axis--x').call(d3.axisBottom(x)); | |
} | |
const brush = d3.brushX() | |
.extent([[0,0], [width, height2]]) | |
.on('brush', brushed) | |
.on('end', endfn); | |
const line = (x, y) => d3.line() | |
.curve(d3.curveLinear) | |
.x(d => x(d.time)) | |
.y(d => y(d.value)); | |
const area = (x, y) => d3.area() | |
.curve(d3.curveLinear) | |
.x(d => x(d.time)) | |
.y0(d => y(d.value + d.stddev)) | |
.y1(d => y(d.value - d.stddev)); | |
textoffset = (x) => { | |
const l = hovertext.node().getComputedTextLength(); | |
return Math.min(Math.max(x - l / 2, 0), width - l); | |
}, | |
boxoffset = (x, y) => { | |
let yoffset = hoverbox.node().clientHeight; | |
yoffset = Math.min(Math.max(y - yoffset / 2, 0), height - yoffset); | |
let vx = x; | |
if (vx + hoverbox.node().offsetWidth > width) { | |
vx = vx - hoverbox.node().offsetWidth - 10; // hardcoded 10 = margin * 2 | |
} | |
return [vx, yoffset]; | |
} | |
focusenter = () => { | |
hoverline.attr('opacity', '1'); | |
hovertext.attr('opacity', '1'); | |
svg.select('foreignObject').attr('opacity', '1'); | |
focus.selectAll('.symbol').attr('opacity', '1'); | |
}, | |
focusleave = () => { | |
hoverline.attr('opacity', '0'); | |
hovertext.attr('opacity', '0'); | |
svg.select('foreignObject').attr('opacity', '0'); | |
focus.selectAll('.symbol').attr('opacity', '0'); | |
}, | |
focusmove = () => { | |
let [xc, yc] = d3.mouse(hoverrect.node()); | |
let i = binarySearch({time: x.invert(xc) }, data[0].values, v => v.time); | |
xc = x(data[0].values[i].time); | |
hoverline.attr('x1', xc); | |
hoverline.attr('x2', xc); | |
hovertext.text(data[0].values[i].time); | |
hovertext.attr('x', textoffset(xc)); | |
const tmpv = data[0].values[i]; | |
const tmph = data[1].values[i]; | |
const tmpp = data[2].values[i]; | |
hoverbox.select('#temp') | |
.style('color', z('temp')) | |
.text(`Temperature: ${tmpv.value.toFixed(2)} °C (σ: ${tmpv.stddev.toFixed(2)})`); | |
hoverbox.select('#humid') | |
.style('color', z('humid')) | |
.text(`Humidity: ${tmph.value.toFixed(2)} % (σ: ${tmph.stddev.toFixed(2)})`); | |
hoverbox.select('#press') | |
.style('color', z('press')) | |
.text(`Pressure: ${tmpp.value.toFixed(2)} hPa (σ: ${tmpp.stddev.toFixed(2)})`); | |
const [bx, by] = boxoffset(xc, yc); | |
const tmp = svg.select('foreignObject'); | |
tmp.attr('x', bx); | |
tmp.attr('y', by); | |
d3.selectAll('.symbol') | |
.attr('transform', d => `translate(${xc}, ${yScales[d.id](d.values[i].value)})`) | |
}; | |
const focus = svg.insert('g', 'foreignObject') | |
.attr('class', 'focus') | |
.attr('transform', `translate(${margin.left}, ${margin.top})`); | |
const context = svg.insert('g', 'foreignObject') | |
.attr('class', 'context') | |
.attr('transform', `translate(${margin2.left}, ${margin2.top})`); | |
const hoverrect = svg.insert('rect', 'foreignObject') | |
.attr('transform', `translate(${margin.left}, ${margin.top})`) | |
.attr('width', width) | |
.attr('height', height) | |
.attr('fill', 'none') | |
.attr('opactiy', '0') | |
.on('mouseenter', focusenter) | |
.on('mouseleave', focusleave) | |
.on('mousemove', focusmove); | |
const hoverline = focus.append('line') | |
.attr('y1', '0') | |
.attr('y2', height) | |
.attr('opacity', '0') | |
.style('stroke-width', '1px') | |
.style('stroke', 'black'); | |
const hovertext = focus.append('text') | |
.attr('dy', '-0.1em') | |
.attr('opacity', '0'); | |
const hoverbox = svg.select('foreignObject') | |
.attr('width', width) | |
.attr('height', height) | |
.attr('transform', `translate(${margin.left}, ${margin.top})`) | |
.select('.valbox'); | |
// const hoverbox = svg.select('.valbox') | |
// .attr('transform', `translate(${margin.left}, ${margin.top})`); | |
let start = new Date(0); | |
let end = new Date(); | |
let url = new URL('https://weather.totally.rip/api/v2/timespan.php'); | |
url.searchParams.append('start', start.toISOString()); | |
url.searchParams.append('end', end.toISOString()); | |
url.searchParams.append('limit', width); | |
const mapdata = d => { | |
const ret = [ | |
{ id: 'temp', values: [] }, | |
{ id: 'humid', values: [] }, | |
{ id: 'press', values: [] } | |
]; | |
for (v of d.data) { | |
ret[0].values.push({ | |
id: 'temp', | |
time: new Date(v.time), | |
value: +v.temp_avg, | |
stddev: +v.temp_stddev | |
}); | |
ret[1].values.push({ | |
id: 'humid', | |
time: new Date(v.time), | |
value: +v.humidity_avg, | |
stddev: +v.humidity_stddev | |
}); | |
ret[2].values.push({ | |
id: 'press', | |
time: new Date(v.time), | |
value: +v.pressure_avg, | |
stddev: +v.pressure_stddev | |
}); | |
} | |
return ret; | |
}; | |
tempaxis = g => g.attr('transform', `translate(${margin.left},${margin.top})`) | |
.call(tempAxis) | |
.append('text') | |
.attr('dy', '-1em') | |
.attr('x', '-45px') | |
.style('fill', z('temp')) | |
.style('text-anchor', 'start') | |
.text('Temp (°C)'); | |
humidaxis = g => g.attr('transform', `translate(${90},${margin.top})`) | |
.call(humidAxis) | |
.append('text') | |
.attr('dy', '-2em') | |
.attr('y', 0) | |
.attr('x', '-45px') | |
.style('fill', z('humid')) | |
.style('text-anchor', 'start') | |
.text('Humidity %'); | |
pressaxis = g => g.attr('transform', `translate(${45},${margin.top})`) | |
.call(pressAxis) | |
.append('text') | |
.attr('dy', '-1em') | |
.attr('x', '-45px') | |
.style('fill', z('press')) | |
.style('text-anchor', 'start') | |
.text('Pressure (hPa)'); | |
let data = mapdata(await d3.json(url)); | |
const overview = data; | |
x.domain([d3.min(data[0].values, d => d.time), new Date()]); | |
x2.domain(x.domain()); | |
yScales.temp.domain([d3.min(data[0].values, d => d.value - d.stddev), d3.max(data[0].values, d => d.value + d.stddev)]).nice(); | |
yScales.humid.domain([d3.min(data[1].values, d => d.value - d.stddev), d3.max(data[1].values, d => d.value + d.stddev)]).nice(); | |
yScales.press.domain([d3.min(data[2].values, d => d.value - d.stddev), d3.max(data[2].values, d => d.value + d.stddev)]).nice(); | |
y2Scales.temp.domain(yScales.temp.domain()); | |
y2Scales.humid.domain(yScales.humid.domain()); | |
y2Scales.press.domain(yScales.press.domain()); | |
tempaxis(svg.append('g')); | |
humidaxis(svg.append('g')); | |
pressaxis(svg.append('g')); | |
context.append('g') | |
.attr('class', 'axis axis--x') | |
.attr('transform', `translate(0, ${height2})`) | |
.call(d3.axisBottom(x2)); | |
focus.append('g') | |
.attr('class', 'axis axis--x') | |
.attr('transform', `translate(0, ${height})`) | |
.call(d3.axisBottom(x)); | |
const contextl = context.selectAll('.line') | |
.data(overview) | |
.enter().append('g') | |
.attr('class', 'line'); | |
contextl.append('path') | |
.attr('d', d => line(x2, y2Scales[d.id])(d.values)) | |
.style('stroke', d => z(d.id)) | |
.style('fill', 'none'); | |
context.append('g') | |
.attr('class', 'brush') | |
.call(brush) | |
.call(brush.move, x.range()); | |
})(); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment