Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate TimeSlider component to React #2231

Merged
merged 6 commits into from Mar 3, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 4 additions & 4 deletions web/src/components/countryhistorycarbongraph.js
Expand Up @@ -6,8 +6,8 @@ import { CARBON_GRAPH_LAYER_KEY } from '../helpers/constants';
import { getCo2Scale } from '../helpers/scales';
import {
getSelectedZoneHistory,
getZoneHistoryGraphStartTime,
getZoneHistoryGraphEndTime,
getZoneHistoryStartTime,
getZoneHistoryEndTime,
createSingleLayerGraphBackgroundMouseMoveHandler,
createSingleLayerGraphBackgroundMouseOutHandler,
createGraphLayerMouseMoveHandler,
Expand Down Expand Up @@ -36,8 +36,8 @@ const prepareGraphData = (historyData, colorBlindModeEnabled, electricityMixMode
const mapStateToProps = state => ({
colorBlindModeEnabled: state.application.colorBlindModeEnabled,
electricityMixMode: state.application.electricityMixMode,
startTime: getZoneHistoryGraphStartTime(state),
endTime: getZoneHistoryGraphEndTime(state),
startTime: getZoneHistoryStartTime(state),
endTime: getZoneHistoryEndTime(state),
historyData: getSelectedZoneHistory(state),
isMobile: state.application.isMobile,
selectedTimeIndex: state.application.selectedZoneTimeIndex,
Expand Down
8 changes: 4 additions & 4 deletions web/src/components/countryhistorymixgraph.js
Expand Up @@ -10,8 +10,8 @@ import { modeOrder, modeColor } from '../helpers/constants';
import {
getExchangeKeys,
getSelectedZoneHistory,
getZoneHistoryGraphStartTime,
getZoneHistoryGraphEndTime,
getZoneHistoryStartTime,
getZoneHistoryEndTime,
createGraphBackgroundMouseMoveHandler,
createGraphBackgroundMouseOutHandler,
createGraphLayerMouseMoveHandler,
Expand Down Expand Up @@ -106,8 +106,8 @@ const mapStateToProps = state => ({
colorBlindModeEnabled: state.application.colorBlindModeEnabled,
displayByEmissions: state.application.tableDisplayEmissions,
electricityMixMode: state.application.electricityMixMode,
startTime: getZoneHistoryGraphStartTime(state),
endTime: getZoneHistoryGraphEndTime(state),
startTime: getZoneHistoryStartTime(state),
endTime: getZoneHistoryEndTime(state),
historyData: getSelectedZoneHistory(state),
isMobile: state.application.isMobile,
selectedTimeIndex: state.application.selectedZoneTimeIndex,
Expand Down
8 changes: 4 additions & 4 deletions web/src/components/countryhistorypricesgraph.js
Expand Up @@ -9,8 +9,8 @@ import { first } from 'lodash';
import { PRICES_GRAPH_LAYER_KEY } from '../helpers/constants';
import {
getSelectedZoneHistory,
getZoneHistoryGraphStartTime,
getZoneHistoryGraphEndTime,
getZoneHistoryStartTime,
getZoneHistoryEndTime,
createSingleLayerGraphBackgroundMouseMoveHandler,
createSingleLayerGraphBackgroundMouseOutHandler,
createGraphLayerMouseMoveHandler,
Expand Down Expand Up @@ -55,8 +55,8 @@ const prepareGraphData = (historyData, colorBlindModeEnabled, electricityMixMode
const mapStateToProps = state => ({
colorBlindModeEnabled: state.application.colorBlindModeEnabled,
electricityMixMode: state.application.electricityMixMode,
startTime: getZoneHistoryGraphStartTime(state),
endTime: getZoneHistoryGraphEndTime(state),
startTime: getZoneHistoryStartTime(state),
endTime: getZoneHistoryEndTime(state),
historyData: getSelectedZoneHistory(state),
isMobile: state.application.isMobile,
selectedTimeIndex: state.application.selectedZoneTimeIndex,
Expand Down
5 changes: 3 additions & 2 deletions web/src/components/graph/areagraph.js
Expand Up @@ -159,10 +159,11 @@ const AreaGraph = React.memo(({
if (isEmpty(layers)) return null;

return (
<svg height={height} ref={ref}>
<svg height={height} ref={ref} style={{ overflow: 'visible' }}>
<TimeAxis
scale={timeScale}
height={container.height}
transform={`translate(-1 ${container.height - 1})`}
className="x axis"
/>
<ValueAxis
scale={valueScale}
Expand Down
37 changes: 32 additions & 5 deletions web/src/components/graph/timeaxis.js
@@ -1,23 +1,50 @@
import React from 'react';
import moment from 'moment';
import { range } from 'lodash';

const TimeAxis = React.memo(({ scale, height }) => {
import { __ } from '../../helpers/translation';

// If the tick represents a timestamp not more than 15 minutes in the past,
// render it as "Now", otherwise render as localized time, i.e. "8:30 PM".
const renderTickValue = v => (
moment().diff(moment(v), 'minutes') <= 15
? __('country-panel.now')
: moment(v).format('LT')
);

const roundUp = (number, base) => Math.ceil(number / base) * base;

// Return `count` timestamp values uniformly distributed within the scale
// domain, including both ends, rounded up to 15 minutes precision.
const getTicksValuesFromTimeScale = (scale, count) => {
const startTime = scale.domain()[0].valueOf();
const endTime = scale.domain()[1].valueOf();

const precision = moment.duration(15, 'minutes').valueOf();
const step = (endTime - startTime) / (count - 1);

return range(count).map(ind => (
moment(ind === count - 1 ? endTime : roundUp(startTime + ind * step, precision)).toDate()
));
};

const TimeAxis = React.memo(({ className, scale, transform }) => {
const [x1, x2] = scale.range();
return (
<g
className="x axis"
transform={`translate(-1 ${height - 1})`}
className={className}
transform={transform}
fill="none"
fontSize="10"
fontFamily="sans-serif"
textAnchor="middle"
style={{ pointerEvents: 'none' }}
>
<path className="domain" stroke="currentColor" d={`M${x1 + 0.5},6V0.5H${x2 + 0.5}V6`} />
{scale.ticks(5).map(v => (
{getTicksValuesFromTimeScale(scale, 5).map(v => (
<g key={`tick-${v}`} className="tick" opacity={1} transform={`translate(${scale(v)},0)`}>
<line stroke="currentColor" y2="6" />
<text fill="currentColor" y="9" dy="0.71em">{moment(v).format('LT')}</text>
<text fill="currentColor" y="9" x="5" dy="0.71em">{renderTickValue(v)}</text>
</g>
))}
</g>
Expand Down
203 changes: 96 additions & 107 deletions web/src/components/timeslider.js
@@ -1,122 +1,111 @@
const d3 = Object.assign(
{},
require('d3-selection'),
require('d3-scale'),
require('d3-axis'),
);
const moment = require('moment');
const translation = require('../helpers/translation');
import React, {
useRef,
useMemo,
useState,
useEffect,
} from 'react';
import {
first,
last,
sortedIndex,
range,
isNumber,
} from 'lodash';
import { scaleTime } from 'd3-scale';
import moment from 'moment';

import { __ } from '../helpers/translation';
import TimeAxis from './graph/timeaxis';

const TIME_FORMAT = 'LT'; // Localized time, e.g. "8:30 PM"
const NUMBER_OF_TICKS = 5;
const AXIS_MARGIN_LEFT = 5;
const AXIS_HORIZONTAL_MARGINS = 12;

export default class TimeSlider {
constructor(selector, dateAccessor) {
this.rootElement = d3.select(selector);
this.dateAccessor = dateAccessor;
this._setup();
}

_setup() {
this.slider = this.rootElement.append('input')
.attr('type', 'range')
.attr('class', 'time-slider-input');
this.axisContainer = this.rootElement.append('svg')
.attr('class', 'time-slider-axis-container');
this.axis = this.axisContainer.append('g')
.attr('class', 'time-slider-axis')
.attr('transform', `translate(${AXIS_MARGIN_LEFT}, 0)`);
const getTimeScale = (containerWidth, datetimes, startTime, endTime) => scaleTime()
.domain([
startTime ? moment(startTime).toDate() : first(datetimes),
endTime ? moment(endTime).toDate() : last(datetimes),
])
.range([0, containerWidth]);

const onChangeAndInput = () => {
const selectedIndex = parseInt(this.slider.property('value'), 10);
if (this._onChange) {
this._onChange(selectedIndex);
}
};
this.slider.on('input', onChangeAndInput);
this.slider.on('change', onChangeAndInput);
const createChangeAndInputHandler = (datetimes, onChange, setAnchoredTimeIndex) => (ev) => {
const value = parseInt(ev.target.value, 10);
let index = sortedIndex(datetimes.map(t => t.valueOf()), value);
// If the slider is past the last datetime, we set index to null in order to use the scale end time.
if (index >= datetimes.length) {
index = null;
}

render() {
if (this._data && this._data.length) {
const width = this.axisContainer.node().getBoundingClientRect().width - AXIS_MARGIN_LEFT;
this.timeScale.range([0, width]);
this._renderXAxis();
this._updateSliderValue();
}
return this;
setAnchoredTimeIndex(index);
if (onChange) {
onChange(index);
}
};

_renderXAxis() {
const xAxis = d3.axisBottom(this.timeScale)
.ticks(NUMBER_OF_TICKS)
.tickSize(0)
.tickValues(this.tickValues)
.tickFormat((d, i) => {
if (i === NUMBER_OF_TICKS - 1) {
return translation.translate('country-panel.now');
}
return moment(d).format(TIME_FORMAT);
});
this.axis.call(xAxis);
this.axis.selectAll('.tick text').attr('fill', '#000000');
}
const TimeSlider = ({
className,
onChange,
selectedTimeIndex,
datetimes,
startTime,
endTime,
}) => {
const ref = useRef(null);
const [containerWidth, setContainerWidth] = useState(0);
const [anchoredTimeIndex, setAnchoredTimeIndex] = useState(null);

_updateSliderValue() {
if (this._selectedIndex) {
this.slider.property('value', this._selectedIndex);
} else {
this.slider.property('value', this._data && this._data.length ? this._data.length : 0);
// Container resize hook
useEffect(() => {
const updateContainerWidth = () => {
if (ref.current) {
setContainerWidth(ref.current.getBoundingClientRect().width - 2 * AXIS_HORIZONTAL_MARGINS);
}
};
// Initialize width if it's not set yet
if (!containerWidth) {
updateContainerWidth();
}
}

data(data) {
if (!arguments.length) return this._data;
this._data = data.map(this.dateAccessor);
this._setupSliderRange();
this._setupSliderTimeScale();
this._sampleTickValues();
// Update container width on every resize
window.addEventListener('resize', updateContainerWidth);
return () => {
window.removeEventListener('resize', updateContainerWidth);
};
});

return this;
}
const timeScale = useMemo(
() => getTimeScale(containerWidth, datetimes, startTime, endTime),
[containerWidth, datetimes, startTime, endTime]
);

_setupSliderRange() {
if (this._data && this.data.length) {
this.slider.attr('min', 0);
this.slider.attr('max', this._data.length - 1);
}
}
const handleChangeAndInput = useMemo(
() => createChangeAndInputHandler(datetimes, onChange, setAnchoredTimeIndex),
[datetimes, onChange, setAnchoredTimeIndex]
);

_setupSliderTimeScale() {
if (this._data && this.data.length) {
this.timeScale = d3.scaleTime();
const firstDate = moment(this._data[0]).toDate();
const lastDate = moment(this._data[this._data.length - 1]).toDate();
this.timeScale.domain([firstDate, lastDate]);
}
}
if (!datetimes || datetimes.length === 0) return null;

_sampleTickValues() {
this.tickValues = [];
if (this._data.length >= NUMBER_OF_TICKS) {
for (let i = 0; i < NUMBER_OF_TICKS; i += 1) {
const sampleIndex = Math.floor(((this._data.length - 1) / (NUMBER_OF_TICKS - 1)) * (i));
const sampledTimeStamp = this._data[sampleIndex];
this.tickValues.push(moment(sampledTimeStamp).toDate());
}
}
}
const selectedTimeValue = isNumber(selectedTimeIndex) ? datetimes[selectedTimeIndex].valueOf() : null;
const anchoredTimeValue = isNumber(anchoredTimeIndex) ? datetimes[anchoredTimeIndex].valueOf() : null;
const startTimeValue = timeScale.domain()[0].valueOf();
const endTimeValue = timeScale.domain()[1].valueOf();

selectedIndex(index, previousIndex) {
this._selectedIndex = index || previousIndex;
this.render();
return this;
}
return (
<div className={className}>
<input
type="range"
className="time-slider-input"
onChange={handleChangeAndInput}
onInput={handleChangeAndInput}
value={selectedTimeValue || anchoredTimeValue || endTimeValue}
min={startTimeValue}
max={endTimeValue}
/>
<svg className="time-slider-axis-container" ref={ref}>
<TimeAxis
scale={timeScale}
transform={`translate(${AXIS_HORIZONTAL_MARGINS}, 0)`}
className="time-slider-axis"
/>
</svg>
</div>
);
};

onChange(onChangeHandler) {
this._onChange = onChangeHandler;
return this;
}
}
export default TimeSlider;
10 changes: 7 additions & 3 deletions web/src/helpers/history.js
Expand Up @@ -13,13 +13,17 @@ export function getExchangeKeys(zoneHistory) {
}

export function getSelectedZoneHistory(state) {
return state.data.histories[state.application.selectedZoneName];
return state.data.histories[state.application.selectedZoneName] || [];
}

export function getSelectedZoneHistoryDatetimes(state) {
return getSelectedZoneHistory(state).map(d => moment(d.stateDatetime).toDate());
}

// Use current time as the end time of the graph time scale explicitly
// as we want to make sure we account for the missing data at the end of
// the graph (when not inferable from historyData timestamps).
export function getZoneHistoryGraphEndTime(state) {
export function getZoneHistoryEndTime(state) {
return moment(state.application.customDate || (state.data.grid || {}).datetime).format();
}

Expand All @@ -28,7 +32,7 @@ export function getZoneHistoryGraphEndTime(state) {
// the graph, but right now that would create UI inconsistency with the
// other neighbouring graphs showing data over a bit longer time scale
// (see https://github.com/tmrowco/electricitymap-contrib/issues/2250).
export function getZoneHistoryGraphStartTime(state) {
export function getZoneHistoryStartTime(state) {
return null;
}

Expand Down