Skip to content

Instantly share code, notes, and snippets.

@belst
Last active February 6, 2019 16:30
Show Gist options
  • Save belst/67ef05a4cb4b0efadb3c3cf7c1c895a6 to your computer and use it in GitHub Desktop.
Save belst/67ef05a4cb4b0efadb3c3cf7c1c895a6 to your computer and use it in GitHub Desktop.
Visualization of env data
<!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