terriajs
Version:
Geospatial data visualization platform.
421 lines (375 loc) • 18.3 kB
JSX
import React from 'react';
import createReactClass from 'create-react-class';
import DatePicker from 'react-datepicker';
import moment from 'moment';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import uniq from 'lodash.uniq';
import defined from 'terriajs-cesium/Source/Core/defined';
import { formatDateTime } from './DateFormats';
import Icon from '../../Icon.jsx';
import ObserveModelMixin from '../../ObserveModelMixin';
import Styles from './timeline.scss';
import combine from 'terriajs-cesium/Source/Core/combine';
function daysInMonth(month, year) {
const n = new Date(year, month, 0).getDate();
return Array.apply(null, { length: n }).map(Number.call, Number);
}
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const DateTimePicker = createReactClass({
displayName: 'DateTimePicker',
mixins: [ObserveModelMixin],
propTypes: {
dates: PropTypes.array, // Array of JS Date objects.
currentDate: PropTypes.object, // JS Date object - must be an element of props.dates, or null/undefined.
onChange: PropTypes.func,
openDirection: PropTypes.string,
},
getInitialState() {
return {
isOpen: false,
century: null,
year: null,
month: null,
day: null,
time: null,
hour: null,
granularity: null
};
},
/* eslint-disable-next-line camelcase */
UNSAFE_componentWillMount() {
const datesObject = objectifyDates(this.props.dates);
let defaultCentury = null;
let defaultYear = null;
let defaultMonth = null;
let defaultDay = null;
let defaultGranularity = 'century';
if (datesObject.indice.length === 1) {
// only one century
const soleCentury = datesObject.indice[0];
const dataFromThisCentury = datesObject[soleCentury];
defaultCentury = soleCentury;
if (dataFromThisCentury.indice.length === 1) {
// only one year, check if this year has only one month
const soleYear = dataFromThisCentury.indice[0];
const dataFromThisYear = dataFromThisCentury[soleYear];
defaultYear = soleYear;
defaultGranularity = 'year';
if (dataFromThisYear.indice === 1) {
// only one month data from this one year, need to check day then
const soleMonth = dataFromThisYear.indice[0];
const dataFromThisMonth = dataFromThisYear[soleMonth];
defaultMonth = soleMonth;
defaultGranularity = 'month';
if (dataFromThisMonth.indice === 1) {
// only one day has data
defaultDay = dataFromThisMonth.indice[0];
}
}
}
}
this.setState({
century: defaultCentury,
year: defaultYear,
month: defaultMonth,
day: defaultDay,
time: currentDate,
granularity: defaultGranularity
});
const currentDate = this.props.currentDate;
window.addEventListener('click', this.closePicker);
},
componentWillUnmount: function () {
window.removeEventListener('click', this.closePicker);
},
closePicker() {
this.setState({ isOpen: false });
},
renderCenturyGrid(datesObject) {
const centuries = datesObject.indice;
if (datesObject.dates && datesObject.dates.length >= 12) {
return (
<div className={Styles.grid}>
<div className={Styles.gridHeading}>Select a century</div>
{centuries.map(c => <button key={c} className={Styles.centuryBtn} onClick={() => this.setState({ century: c })}>{c}00</button>)}
</div>
);
} else {
return this.renderList(datesObject.dates);
}
},
renderYearGrid(datesObject) {
if (datesObject.dates && datesObject.dates.length > 12) {
const years = datesObject.indice;
const monthOfYear = Array.apply(null, { length: 12 }).map(Number.call, Number);
return (
<div className={Styles.grid}>
<div className={Styles.gridHeading}>Select a year</div>
<div className={Styles.gridBody}>{years.map(y => <div className={Styles.gridRow} key={y} onClick={() => this.setState({ year: y, month: null, day: null, time: null })}>
<span className={Styles.gridLabel}>{y}</span>
<span className={Styles.gridRowInner12}>{monthOfYear.map(m => <span className={datesObject[y][m] ? Styles.activeGrid : ''} key={m} ></span>)}</span></div>)}
</div>
</div>
);
} else {
return this.renderList(datesObject.dates);
}
},
renderMonthGrid(datesObject) {
const year = this.state.year;
if (datesObject[year].dates && datesObject[year].dates.length > 12) {
return (
<div className={Styles.grid}>
<div className={Styles.gridHeading}>
<button className={Styles.backbtn} onClick={() => { this.setState({ year: null, month: null, day: null, time: null }); }}>{this.state.year}</button>
</div>
<div className={Styles.gridBody}>{monthNames.map((m, i) => <div className={classNames(Styles.gridRow, { [Styles.inactiveGridRow]: !defined(datesObject[year][i]) })} key={m} onClick={() => defined(datesObject[year][i]) && this.setState({ month: i, day: null, time: null })}>
<span className={Styles.gridLabel}>{m}</span>
<span className={Styles.gridRowInner31}>{daysInMonth(i + 1, year).map(d => <span className={defined(datesObject[year][i]) && defined(datesObject[year][i][d + 1]) ? Styles.activeGrid : ''} key={d} ></span>)}</span></div>)}
</div>
</div>
);
} else {
return this.renderList(datesObject[year].dates);
}
},
renderDayView(datesObject) {
if (datesObject[this.state.year][this.state.month].dates && datesObject[this.state.year][this.state.month].dates.length > 31) {
// Create one date object per day, using an arbitrary time. This does it via Object.keys and moment().
const days = datesObject[this.state.year][this.state.month].indice;
const daysToDisplay = days.map(d => moment().date(d).month(this.state.month).year(this.state.year));
const selected = defined(this.state.day) ? moment().date(this.state.day).month(this.state.month).year(this.state.year) : null;
// Aside: You might think this implementation is clearer - use the first date available on each day.
// However it fails because react-datepicker actually requires a moment() object for selected, not a Date object.
// const monthObject = this.props.datesObject[this.state.year][this.state.month];
// const daysToDisplay = Object.keys(monthObject).map(dayNumber => monthObject[dayNumber][0]);
// const selected = defined(this.state.day) ? this.props.datesObject[this.state.year][this.state.month][this.state.day][0] : null;
return (
<div className={Styles.dayPicker}>
<div>
<button className={Styles.backbtn} onClick={() => { this.setState({ year: null, month: null, day: null, time: null }); }}>{this.state.year}</button>
<button className={Styles.backbtn} onClick={() => { this.setState({ month: null, day: null, time: null }); }}>{monthNames[this.state.month]}</button>
</div>
<DatePicker
inline
onChange={(momentDateObj)=>this.setState({ day: momentDateObj.date()})}
includeDates={daysToDisplay}
selected={selected}
/>
</div>
);
} else {
return this.renderList(datesObject[this.state.year][this.state.month].dates);
}
},
renderList(items) {
if (defined(items)) {
return (
<div className={Styles.grid}>
<div className={Styles.gridHeading}>Select a time</div>
<div className={Styles.gridBody}>{items.map(item => <button key={formatDateTime(item)} className={Styles.dateBtn} onClick={() => { this.setState({ time: item, isOpen: false}); this.props.onChange(item); }}>{formatDateTime(item)}</button>)}</div>
</div>
);
}
},
renderHourView(datesObject) {
const timeOptions = datesObject[this.state.year][this.state.month][this.state.day].dates.map((m) => ({
value: m,
label: formatDateTime(m)
}));
if (timeOptions.length > 24) {
return (
<div className={Styles.grid}>
<div className={Styles.gridHeading}>{`Select an hour on ${this.state.day} ${monthNames[this.state.month + 1]} ${this.state.year}`} </div>
<div className={Styles.gridBody}>{datesObject[this.state.year][this.state.month][this.state.day].indice.map(item => <button key={item} className={Styles.dateBtn} onClick={()=>this.setState({hour: item})}><span>{item} : 00 - {item+1} : 00</span> <span>({datesObject[this.state.year][this.state.month][this.state.day][item].length} options)</span></button>)}</div>
</div>
);
} else {
return this.renderList(datesObject[this.state.year][this.state.month][this.state.day].dates);
}
},
renderMinutesView(datesObject) {
const options = datesObject[this.state.year][this.state.month][this.state.day][this.state.hour];
return this.renderList(options);
},
goBack() {
if (defined(this.state.time)) {
if (!defined(this.state.month)) {
this.setState({
year: null,
month: null,
day: null,
});
}
if(!defined(this.state.hour)) {
this.setState({
day: null,
});
}
if(!defined(this.state.day)) {
this.setState({
month: null,
day: null,
});
}
this.setState({
hour: null,
time: null,
});
} else if (defined(this.state.hour)) {
this.setState({
hour: null,
time: null,
});
}else if (defined(this.state.day)) {
this.setState({
day: null,
time: null,
hour: null,
});
} else if (defined(this.state.month)) {
this.setState({
month: null,
time: null,
day: null,
hour: null,
});
} else if (defined(this.state.year)) {
this.setState({
year: null,
month: null,
time: null,
day: null,
hour: null,
});
} else if (defined(this.state.century)) {
this.setState({
century: null,
year: null,
month: null,
time: null,
day: null,
hour: null,
});
}
},
toggleDatePicker() {
if (!this.state.isOpen) {
// When the date picker is opened, we should update the old state with the new currentDate, but to the same granularity.
// The current date must be one of the available item.dates, or null/undefined.
const currentDate = this.props.currentDate;
if (defined(currentDate)) {
const newState = {
day: defined(this.state.day) ? currentDate.getDate() : null,
month: defined(this.state.month) ? currentDate.getMonth() : null,
year: defined(this.state.year) ? currentDate.getFullYear() : null,
century: defined(this.state.century) ? Math.floor(currentDate.getFullYear() / 100) : null,
time: defined(this.state.time) ? currentDate : null
};
this.setState(newState);
}
}
this.setState({
isOpen: !this.state.isOpen
});
},
render() {
if (this.props.dates) {
const datesObject = objectifyDates(this.props.dates);
return (
<div className={Styles.timelineDatePicker} onClick={(event) => { event.stopPropagation(); }}>
<button className={Styles.togglebutton} onClick={() => { this.toggleDatePicker(); }}><Icon glyph={Icon.GLYPHS.calendar} /></button>
{this.state.isOpen && <div className={classNames(Styles.datePicker, { [Styles.openBelow]: this.props.openDirection === 'down' })}>
<button className={Styles.backbutton} disabled={!this.state[this.state.granularity]} type='button' onClick={() => this.goBack()}><Icon glyph={Icon.GLYPHS.left} /></button>
{!defined(this.state.century) && this.renderCenturyGrid(datesObject)}
{defined(this.state.century) && !defined(this.state.year) && this.renderYearGrid(datesObject[this.state.century])}
{defined(this.state.year) && !defined(this.state.month) && this.renderMonthGrid(datesObject[this.state.century])}
{(defined(this.state.year) && defined(this.state.month) && !defined(this.state.day)) && this.renderDayView(datesObject[this.state.century])}
{(defined(this.state.year) && defined(this.state.month) && defined(this.state.day) && !defined(this.state.hour)) && this.renderHourView(datesObject[this.state.century])}
{(defined(this.state.year) && defined(this.state.month) && defined(this.state.day) && defined(this.state.hour)) && this.renderMinutesView(datesObject[this.state.century])}
</div>}
</div>
);
} else {
return null;
}
}
});
function getOneYear(year, dates) {
// All data from a given year.
return dates.filter(d => d.getFullYear() === year);
}
function getOneMonth(yearData, monthIndex) {
// All data from certain month of that year.
return yearData.filter(y => y.getMonth() === monthIndex);
}
function getOneDay(monthData, dayIndex) {
return monthData.filter(m => m.getDate() === dayIndex);
}
function getMonthForYear(yearData) {
// get available months for a given year
return uniq(yearData.map(d => d.getMonth()));
}
function getDaysForMonth(monthData) {
// Get all available days given a month in a year.
return uniq(monthData.map(m => m.getDate()));
}
function getOneHour(dayData, hourIndex) {
// All data from certain month of that year.
return dayData.filter(y => y.getHours() === hourIndex);
}
function getHoursForDay(dayData) {
return uniq(dayData.map(m => m.getHours()));
}
function getOneCentury(century, dates) {
return dates.filter(d => Math.floor(d.getFullYear() / 100) === century);
}
/**
* Process an array of dates into layered objects of years, months and days.
* @param {Date[]} An array of dates.
* @return {Object} Returns an object whose keys are years, whose values are objects whose keys are months (0=Jan),
* whose values are objects whose keys are days, whose values are arrays of all the datetimes on that day.
*/
function objectifyDates(dates) {
const years = uniq(dates.map(date => date.getFullYear()));
const centuries = uniq(years.map(year => Math.floor(year / 100)));
const result = centuries.reduce((accumulator, currentValue) => combine(accumulator, objectifyCenturyData(currentValue, dates, years)), {});
result.dates = dates;
result.indice = centuries;
return result;
}
function objectifyCenturyData(century, dates, years) {
// century is a number like 18, 19 or 20.
const yearsInThisCentury = years.filter(year => Math.floor(year / 100) === century);
const centuryData = getOneCentury(century, dates);
const centuryDates = { [century]: yearsInThisCentury.reduce((accumulator, currentValue) => combine(accumulator, objectifyYearData(currentValue, dates, years)), {}) };
centuryDates[century].dates = centuryData;
centuryDates[century].indice = yearsInThisCentury;
return centuryDates;
}
function objectifyYearData(year, dates) {
const yearData = getOneYear(year, dates);
const monthInYear = {};
getMonthForYear(yearData).forEach(monthIndex => {
const monthData = getOneMonth(yearData, monthIndex);
const daysInMonth = {};
getDaysForMonth(monthData).forEach(dayIndex => {
daysInMonth.dates = monthData;
daysInMonth.indice = getDaysForMonth(monthData);
const hoursInDay ={};
const dayData = getOneDay(monthData, dayIndex);
getHoursForDay(dayData).forEach(hourIndex =>{
hoursInDay[hourIndex] = getOneHour(dayData, hourIndex);
hoursInDay.dates = dayData;
hoursInDay.indice = getHoursForDay(dayData);
});
daysInMonth[dayIndex] = hoursInDay;
});
monthInYear[monthIndex] = daysInMonth;
monthInYear.indice = getMonthForYear(yearData);
monthInYear.dates = yearData;
});
return { [year]: monthInYear };
}
module.exports = DateTimePicker;