Commit bd8510c1 authored by alain's avatar alain 🐙
Browse files

support multiple datastreams for sources

parent aed1f90e
......@@ -8,7 +8,7 @@ export const texts = {
loading: "Bezig met laden...",
loadingError: "Laden mislukt...",
loadingRetry: "Probeer opnieuw",
nodata: "Geen data...",
nodata: "geen data...",
lastMean: "laatste uurgemiddelde",
lastPeak: "piekwaarde laatste uur",
mean: "gemiddelde",
......
......@@ -2,7 +2,7 @@ import { CompositeLayer } from '@deck.gl/core'
import { ColumnLayer } from '@deck.gl/layers'
import { scaleLinear, scaleLog } from "d3-scale"
import { color, getColorArray } from "../util/color.js"
import { getColor, getColorArray } from "../util/color.js"
import { setMetersOffset } from "../util/plot"
......@@ -59,39 +59,30 @@ class StationLayer extends CompositeLayer {
}
return [
// // max
// new ColumnLayer({
// ...this.props,
// id: `${id}-column-layer-max`,
// data,
// radius
// diskResolution: sides,
// opacity: 1,
// lightSettings,
// getPosition: d => centroid(d.coordinates, d.offset),
// getFillColor: d => getColorArray(lightenBy(color(d.max, legend), 0.25)),
// getElevation: d => elevation(d.max, zoom),
// updateTriggers: {
// getElevation: [zoom],
// getPosition: [radius]
// }
// }),
//mean
new ColumnLayer({
...this.props,
id: `${id}-column-layer-mean`,
data,
radius,
diskResolution: sides,
opacity: 1,
opacity: 0.95,
lightSettings,
getElevation: d => getHeight(d.mean, zoom),
getPosition: d => this.getCoordinates(d, count, radius),
getFillColor: d => {
if(d.mean === null) {
return getColorArray(mapItemSettings.colorOffline)
let color = getColorArray(mapItemSettings.colorOffline)
color[3] = color[3] * 0.5
return color
} else {
return getColorArray(color(d.mean, legend, (scale === 'log')))
let color = getColorArray(getColor(d.mean, legend, (scale === 'log')))
//if(d.dataAge > 3) color = changeColorLuminance(color, -0.015 * d.dataAge)
//if(d.dataAge > 4)
color[3] = color[3] * (1 - 0.1 * d.dataAge)
return color
}
},
updateTriggers: {
......
......@@ -3,27 +3,30 @@ import React from 'react'
import { texts } from "../../../config/texts"
import { roundBy } from "../util/math.js"
import { moment } from "../util/time.js"
const SensorTooltip = props => {
const { d, label, unit } = props
//const dataAgeText = d.dataAge < 1 ? texts.lastHour : `${roundBy(d.dataAge, 0)} ${texts.hoursAgo}`
const dataAgeText = `${ moment(d.timestamp).format("HH") }-${ moment(d.timestamp).add(1, 'hours').format("HH") }${texts.hourShort}`
return (
<div>
<h4>{d.name}</h4>
<em>{label}</em>
<table className="data"><tbody>
{ d.mean !== null &&
<tr>
<td>{ texts.lastMean }:</td>
<td><strong>{ roundBy(d.mean, 1) }{ unit }</strong></td>
</tr>
<tr>
<td>{`${texts.lastMean} (${dataAgeText}):`}</td>
<td><strong>{ roundBy(d.mean, 1) }{ unit }</strong></td>
</tr>
}
{ !!d.max &&
<tr>
<td>{ texts.lastPeak }:</td>
<td><strong>{ roundBy(d.max, 1) }{ unit }</strong></td>
</tr>
{ d.mean === null &&
<tr>
<td>{ texts.nodata }</td>
</tr>
}
</tbody></table>
</div>
......
......@@ -8,7 +8,7 @@ class MapAttributions extends React.Component {
<div className="mapboxgl-ctrl-bottom-right">
<div className="mapboxgl-ctrl mapboxgl-ctrl-attrib">
<div className="mapboxgl-ctrl-attrib-inner">
<span>v. 18/11/2019</span>
<span>v. 28/11/2019</span>
<span><a href="https://waag.org" target="_blank" rel="noopener noreferrer">waag</a></span>
<span>© <a href="http://www.openstreetmap.org/about/" target="_blank" rel="noopener noreferrer">OpenStreetMap</a></span>
</div>
......
......@@ -7,29 +7,49 @@ import { moment } from "../util/time.js"
const ChartTooltip = (props) => {
const { active, payload, label, unit } = props
const { active, payload, label, unit, dataStreamsState } = props
if (active) {
let avg, max
const title = <h4>{ moment(label).format("D MMM") } { moment(label).format("HH:mm") } - { moment(label).add(1, 'hours').format("HH:mm") } { texts.hour }</h4>
let body
if(payload && payload.length > 1) {
avg = payload[1].value
max = payload[0].value[1]
} else if(payload && payload.length > 0) {
avg = payload[0].value
if(!payload) {
body = <p>{ texts.nodata }</p>
} else {
avg = "geen data"
const dataStreams = payload[0] ? payload[0].payload : {}
let rows = []
Object.keys(dataStreamsState).forEach(i => {
const headerRow = <tr key={i}><th>{ texts.dataStreams[i]}:</th><th></th></tr>
let subRows = []
Object.keys(dataStreamsState[i]).forEach(j => {
const s = dataStreamsState[i][j]
if(s.active) {
const value = dataStreams[s.key]
let text
text = Array.isArray(value) ? `${ roundBy(value[0], 1) }-${ roundBy(value[1], 1) } ${unit}` : `${roundBy(value, 1)} ${unit}`
if(!value) text = texts.nodata
subRows.push(<tr key={s.key}><td>{ texts.dataStreams[j] }:</td><td><strong>{ text }</strong></td></tr>)
}
})
if(subRows.length > 0 && Object.keys(dataStreamsState).length > 1) rows.push(headerRow)
rows = rows.concat(subRows)
})
body = <table><tbody>{ rows }</tbody></table>
}
return (
<div className="tooltip-data">
<h4>{ moment(label).format("D MMM") } { moment(label).subtract(1, 'hours').format("HH:mm") } - { moment(label).format("HH:mm") }</h4>
<table><tbody>
<tr><td>{ texts.mean }:</td><td>{ roundBy(avg, 1)}{unit}</td></tr>
{ !!max && <tr><td>{ texts.peak }:</td><td>{ roundBy(max, 1)}{unit}</td></tr> }
</tbody></table>
</div>
{ title }
{ body }
</div>
)
}
return null
......
......@@ -15,11 +15,11 @@ import IconArrowDown from "../Icons/IconArrowDown"
import { texts } from "../../../config/texts"
import { appSettings } from "../../../config/app"
import { moment, getNowISO, getDaysAgoHourISO } from "../util/time.js"
import { moment, getNowHourISO, getDaysAgoHourISO } from "../util/time.js"
const start = getDaysAgoHourISO(90)
const end = getNowISO()
const end = getNowHourISO()
class StationInfo extends React.Component {
......@@ -36,7 +36,7 @@ class StationInfo extends React.Component {
downloadStart: moment(start).format("YYYY-MM-DD"),
downloadEnd: moment(end).format("YYYY-MM-DD"),
data: null,
stationMeta: null,
stationMeta: null
}
this.updateChartHeight = this.updateChartHeight.bind(this)
......@@ -57,7 +57,7 @@ class StationInfo extends React.Component {
const daysToFetch = source.daysToFetch
const start = getDaysAgoHourISO(daysToFetch)
const end = getNowISO()
const end = getNowHourISO()
const granularity = this.state.granularity
......@@ -65,16 +65,14 @@ class StationInfo extends React.Component {
this.setState({ stationMeta })
source.stationData(station, parameter.id, start, end, granularity).then(response => {
source.stationData(station, parameter.id, start, end, granularity).then(({ status, data }) => {
if(this.mounted) {
if(response === 'error') {
if(status === 'error') {
this.setState({ error: true })
} else {
let data = response
const unit = (parameter.units ? parameter.units.find(x => x.id === this.props.unitStatus[parameter.id]) : null)
if(unit.conversion) {
data = response.map(d => {
data = data.map(d => {
d.value = unit.conversion(d.value)
if(d.minmax) {
d.minmax[0] = unit.conversion(d.minmax[0])
......@@ -84,7 +82,9 @@ class StationInfo extends React.Component {
})
}
this.setState({ data, unit })
this.setPlotSettings(source.dataStreams[parameter.id])
this.setState({ data, unit})
if(data.length > 0) {
const startIndex = (data.length - 7*24 > 0 ? data.length - 7*24 : 0)
......@@ -128,28 +128,91 @@ class StationInfo extends React.Component {
}
setPlotSettings(dataStreams) {
let last = null
let brushData = null
Object.keys(dataStreams).forEach(i => {
Object.keys(dataStreams[i]).forEach(j => {
if(dataStreams[i][j].type === "line") brushData = dataStreams[i][j].key
})
})
Object.keys(dataStreams).forEach(i => {
Object.keys(dataStreams[i]).forEach(j => {
const s = dataStreams[i][j]
let attrs = {}
if(s.active && s.type === "area") {
attrs.className = (s.color === false ? "area secondary" : "area primary")
}
if(s.active && s.type === "line") {
attrs.className = (last === "area" ? "line-on-area" : "line")
attrs.className += (s.color === false ? " secondary" : " primary")
}
if(s.active) last = s.type
dataStreams[i][j].attrs = attrs
})
})
this.setState({ dataStreams, brushData })
}
toggleDataStream(i,j) {
const dataStreams = this.state.dataStreams
dataStreams[i][j].active = !dataStreams[i][j].active
this.setPlotSettings(dataStreams)
}
render() {
moment.locale(appSettings.language)
const { error, stationMeta, data, chartHeight, downloadForm, unit, tickFormat, ticks } = this.state
const { id, source } = this.props.clickedObject
const { error, stationMeta, data, dataStreams, brushData, chartHeight, downloadForm, unit, tickFormat, ticks } = this.state
const { id } = this.props.clickedObject
const parameter = this.props.activeLayer
const source = parameter.sources.find(o => {
return o.name === this.props.clickedObject.source
})
let yScale = scaleLinear()
if(unit) {
yScale = (unit.scale === 'log' ? scaleLog().domain(unit.range) : scaleLinear().domain(unit.range))
}
const lineStyle = (data && data.length > 0 && data[0].minmax ? { stroke:"#000000" } : { stroke:"url(#yaxis)" } )
const lineClass = (data && data.length > 0 && data[0].minmax ? "dashed" : "solid" )
var gradients
let plots = []
const sourceObject = this.props.activeLayer.sources.find(o => {
return o.name === source
})
var downloadJSX
if(sourceObject.download) {
if(data) {
const stops = Object.keys(unit.legend).sort((a,b) => b-a).map(s => <stop key={s} offset={`${100 - (yScale(s) * 100)}%`} stopColor={unit.legend[s].color} />)
gradients = (
<defs>
<linearGradient id="yaxis" x1="0" y1="0" x2="0" y2={chartHeight - 80} gradientUnits="userSpaceOnUse">{ stops }</linearGradient>
<linearGradient id="legend" x1="0" y1="0" x2="0" y2="12" gradientUnits="userSpaceOnUse">{ stops }</linearGradient>
</defs>
)
Object.keys(dataStreams).forEach(i => {
Object.keys(dataStreams[i]).forEach(j => {
const s = dataStreams[i][j]
if(s.active && s.type === "area") plots.push(<Area key={s.key} dataKey={s.key} { ...s.attrs } type="natural" animationDuration={1} />)
if(s.active && s.type === "line") plots.push(<Line key={s.key} dataKey={s.key} { ...s.attrs } type="natural" animationDuration={1} dot={false} activeDot={{ r: 3 }} />)
})
})
}
let downloadJSX
if(source.download) {
const startValue = moment(start).format("YYYY-MM-DD")
const minValue = moment(sourceObject.dataStart).format("YYYY-MM-DD")
const minValue = moment(source.dataStart).format("YYYY-MM-DD")
const maxValue = moment(end).format("YYYY-MM-DD")
downloadJSX =
......@@ -161,7 +224,7 @@ class StationInfo extends React.Component {
<div>
{ texts.endDate }: <input className="input-text" type="date" id="end" name="end" defaultValue={ maxValue } min={ minValue } max={ maxValue } onChange={(e)=> { this.setState({ downloadEnd: e.target.value }) } } />
</div>
<button className="button-text" onClick={ e => { window.location.href = sourceObject.download(id, parameter.id, this.state.downloadStart, this.state.downloadEnd) } }>
<button className="button-text" onClick={ e => { window.location.href = source.download(id, parameter.id, this.state.downloadStart, this.state.downloadEnd) } }>
{ texts.downloadCsv }
</button>
</div>
......@@ -186,43 +249,65 @@ class StationInfo extends React.Component {
{ (data && data.length > 0) &&
<div>
<div id="chart-header">
<header id="chart-header">
<h3>{parameter.label} {unit.label} { texts.chartHeaderAddition }</h3>
{ sourceObject.download && <span className={`toggle-link ${(downloadForm ? "active" : "")}`} onClick={ ()=> { this.setState({ downloadForm: !downloadForm }) } }>
{ source.download && <span className={`toggle-link ${(downloadForm ? "active" : "")}`} onClick={ ()=> { this.setState({ downloadForm: !downloadForm }) } }>
{ texts.downloadData } <IconArrowDown />
</span> }
</div>
</header>
{ downloadJSX }
<div id="chart-body">
<ResponsiveContainer width="100%" height={chartHeight}>
<ComposedChart data={data} margin={{ top: 0, right: 30, left: 0, bottom: 10 }}>
{ gradients }
<XAxis type="number" scale="time" domain={['auto', 'auto']} dataKey="timestamp" height={20} padding={{ left: 6 }} tickFormatter={()=>"###"} tickSize={4} ticks={ticks[0]} tick={<ChartTick tickFormat={tickFormat[0]} />} />
<XAxis xAxisId="day" type="number" scale="time" domain={['auto', 'auto']} dataKey="timestamp" height={20} padding={{ left: 6 }} axisLine={false} tickFormatter={()=>"####"} tickSize={0} ticks={ticks[1]} tick={<ChartTick tickFormat={tickFormat[1]} />} />
<YAxis type="number" strokeWidth="6" stroke="url(#yaxis)" width={30} ticks={(unit.ticks ? unit.ticks : Object.keys(unit.legend))} height={chartHeight} domain={unit.range} tickSize={2} tickLine={{ strokeWidth: 1 }} allowDataOverflow={true} scale={yScale} />
<Tooltip content={ ChartTooltip } animationDuration={0} unit={unit.label} dataStreamsState={dataStreams} />
{ plots }
<Brush dataKey="timestamp" height={40} fill="#eee" stroke="none" travellerWidth={4}
onChange={ indexes => { this.handleRangeChange(indexes) } }
startIndex={ (data.length - 7*24 > 0 ? data.length - 7*24 : 0) }
tickFormatter={this.xAxisTickFormatter} >
<LineChart>
<Line type="natural" dataKey={ brushData } stroke="#aaa" dot={false} />
<YAxis domain={unit.range} tick={false} width={0} scale={yScale} />
</LineChart>
</Brush>
</ComposedChart>
</ResponsiveContainer>
</div>
<ResponsiveContainer width="100%" height={chartHeight}>
<ComposedChart data={data} margin={{ top: 0, right: 30, left: 0, bottom: 10 }}>
<defs>
<linearGradient id="yaxis" x1="0" y1="0" x2="0" y2={chartHeight - 80} gradientUnits="userSpaceOnUse">
{ Object.keys(unit.legend).sort((a,b) => b-a).map(s => <stop key={s} offset={`${100 - (yScale(s) * 100)}%`} stopColor={unit.legend[s].color} />) }
</linearGradient>
</defs>
<XAxis type="number" scale="time" domain={['auto', 'auto']} dataKey="timestamp" height={20} padding={{ left: 6 }} tickFormatter={()=>"###"} tickSize={4} ticks={ticks[0]} tick={<ChartTick tickFormat={tickFormat[0]} />} />
<XAxis xAxisId="day" type="number" scale="time" domain={['auto', 'auto']} dataKey="timestamp" height={20} padding={{ left: 6 }} axisLine={false} tickFormatter={()=>"####"} tickSize={0} ticks={ticks[1]} tick={<ChartTick tickFormat={tickFormat[1]} />} />
<YAxis type="number" strokeWidth="6" stroke="url(#yaxis)" width={30} ticks={(unit.ticks ? unit.ticks : Object.keys(unit.legend))} height={chartHeight} domain={unit.range} tickSize={2} tickLine={{ strokeWidth: 1 }} allowDataOverflow={true} scale={yScale} />
<Tooltip content={ ChartTooltip } animationDuration={0} unit={unit.label} />
<Area type="natural" dataKey="minmax" stroke="none" fill="url(#yaxis)" fillOpacity={0.5} />
<Line className={lineClass} type="natural" dataKey="value" { ...lineStyle } dot={false} strokeWidth={1} activeDot={{ r: 3 }} />
<Brush dataKey="timestamp" height={40} fill="#eee" stroke="none" travellerWidth={4}
onChange={ indexes => { this.handleRangeChange(indexes) } }
startIndex={ (data.length - 7*24 > 0 ? data.length - 7*24 : 0) }
tickFormatter={this.xAxisTickFormatter} >
<LineChart>
<Line type="natural" dataKey="value" stroke="#aaa" dot={false} />
<YAxis domain={unit.range} tick={false} width={0} scale={yScale} />
</LineChart>
</Brush>
</ComposedChart>
</ResponsiveContainer>
<footer id="chart-footer">
<span className="hint-time-selection">{ texts.timeSelectionHint }</span>
{ Object.keys(dataStreams).length > 1 &&
<ul id="chart-data-selection">
{ Object.keys(dataStreams).map(i =>
<li key={i} className="data-stream">
<h4>{ texts.dataStreams[i] }</h4>
<ul>
{ Object.keys(dataStreams[i]).map(j => <li key={j} className={dataStreams[i][j].active ? "option active" : "option"} onClick={e => { this.toggleDataStream(i,j) } }>
<span className="switch"></span>
{ texts.dataStreams[j] }
{ dataStreams[i][j].active &&
<svg width="16" height="16">
{ dataStreams[i][j].type === "area" && <path d="M0 16v-4c5 0 6-7 8-7s3 7 8 7v4c-3 0-5-3-8-3s-5 3-8 3z" { ...dataStreams[i][j].attrs } /> }
{ dataStreams[i][j].type === "line" && <path d="M0 13.5c3 0 4-5 8-5s5 5 8 5" fill="none" stroke="#000" { ...dataStreams[i][j].attrs }/> }
</svg>
}
</li>) }
</ul>
</li>
) }
</ul>
}
</footer>
</div>
}
</div>
......
......@@ -13,6 +13,36 @@
}
}
#chart-footer {
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
padding: 0 30px;
margin-top: 0;
@media (max-width: 600px) {
flex-direction: column;
.hint-time-selection {
margin-bottom: 1rem;
text-align: right;
}
}
@media (max-width: 600px) {
padding: 0;
}
ul {
margin: 0;
padding-left: 0;
}
li {
list-style: none;
}
}
#download-data {
margin: -1em 0 1em 0;
max-height: 0;
......@@ -39,14 +69,59 @@
}
input {
//margin-bottom: 0.25em;
font-size: 0.9rem;
}
.button-text {
margin-right: 0;
margin-bottom: 0;
}
}
#download-data {
a.more {
margin-right: 0;
&:hover {
margin-right: -0.3em;
}
}
}
#chart-data-selection {
display: flex;
flex-wrap: wrap;
.data-stream {
margin: 0.5rem 1rem 0.5rem 0;
}
.option {
position: relative;
height: 16px;
margin-top: 0.25rem;
padding-right: 20px;
cursor: pointer;
svg {
position: absolute;
top: -2px;
right: 0;
}
}
}
.recharts-wrapper {
user-select: none;
}
#chart-body {
@media (max-width: 400px) {
margin: 0 -20px;
}
}
.xAxis,
.yAxis {
text {
......@@ -94,10 +169,52 @@ rect.recharts-brush-slide {
}
}
.recharts-line.dashed path {
stroke-dasharray: 3px;
@mixin line-on-area { stroke-width: 1px; stroke-dasharray: 2px; }
@mixin line-on-area-primary {
@include line-on-area;
stroke: #000;
}
@mixin line-on-area-secondary {
@include line-on-area;
stroke: #bbb;
}
.recharts-line.solid path {
stroke-width: 1.5px;
}
\ No newline at end of file
@mixin line { stroke-width: 1.5px; }
@mixin line-primary {
@include line;
stroke: url(#yaxis);
}
@mixin line-secondary {
@include line;
stroke: #ddd;