UNPKG

terriajs

Version:

Geospatial data visualization platform.

421 lines (375 loc) 18.3 kB
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;