import React from 'react'; import { connect } from 'react-redux'; import * as selectors from '../selectors'; import hash from 'object-hash'; import copy from '../js/data/copy.json'; import { formatterWithYear, parseDate } from '../js/utilities'; import TimelineHeader from './presentational/TimelineHeader'; import TimelineAxis from './TimelineAxis.jsx'; import TimelineClip from './presentational/TimelineClip'; import TimelineHandles from './presentational/TimelineHandles.js'; import TimelineZoomControls from './presentational/TimelineZoomControls.js'; import TimelineLabels from './presentational/TimelineLabels.js'; import TimelineMarkers from './presentational/TimelineMarkers.js' import TimelineEvents from './presentational/TimelineEvents.js'; import TimelineCategories from './TimelineCategories.jsx'; class Timeline extends React.Component { constructor(props) { super(props); this.styleDatetime = this.styleDatetime.bind(this) this.getDatetimeX = this.getDatetimeX.bind(this) this.svgRef = React.createRef() this.state = { isFolded: false, dims: { height: 140, width: 0, width_controls: 100, height_controls: 115, margin_left: 120, margin_top: 20, trackHeight: 80 }, scaleX: null, scaleY: null, timerange: [null, null], dragPos0: null, transitionDuration: 300 }; } componentDidMount() { this.computeDims(); this.addEventListeners(); } componentWillReceiveProps(nextProps) { if (hash(nextProps) !== hash(this.props)) { this.setState({ timerange: nextProps.app.timerange, scaleX: this.makeScaleX() }); } if (hash(nextProps.domain.categories) !== hash(this.props.domain.categories)) { this.setState({ scaleY: this.makeScaleY(nextProps.domain.categories) }); } if (hash(nextProps.app.selected) !== hash(this.props.app.selected)) { if (!!nextProps.app.selected && nextProps.app.selected.length > 0) { this.onCenterTime(parseDate(nextProps.app.selected[0].timestamp)); } } } addEventListeners() { window.addEventListener('resize', () => { this.computeDims(); }); let element = document.querySelector('.timeline-wrapper'); element.addEventListener("transitionend", (event) => { this.computeDims(); }, { once: true }); } makeScaleX() { return d3.scaleTime() .domain(this.state.timerange) .range([this.state.dims.margin_left, this.state.dims.width - this.state.dims.width_controls]); } makeScaleY(categories) { const catsYpos = categories.map((g, i) => (i + 1) * this.state.dims.trackHeight / categories.length); return d3.scaleOrdinal() .domain(categories) .range(catsYpos); } componentDidUpdate(prevProps, prevState) { if (prevState.timerange !== this.state.timerange) { this.setState({ scaleX: this.makeScaleX() }); } } /** * Returns the time scale (x) extent in minutes */ getTimeScaleExtent() { const timeDomain = this.state.scaleX.domain(); return (timeDomain[1].getTime() - timeDomain[0].getTime()) / 60000; } onClickArrow() { this.setState((prevState, props) => { return {isFolded: !prevState.isFolded}; }); } computeDims() { const dom = this.props.ui.dom.timeline; if (document.querySelector(`#${dom}`) !== null) { const boundingClient = document.querySelector(`#${dom}`).getBoundingClientRect(); this.setState({ dims: Object.assign({}, this.state.dims, { width: boundingClient.width }) }, () => { this.setState({ scaleX: this.makeScaleX() }) }); } } /** * Shift time range by moving forward or backwards * @param {String} direction: 'forward' / 'backwards' */ onMoveTime(direction) { this.props.methods.onSelect(); const extent = this.getTimeScaleExtent(); const newCentralTime = d3.timeMinute.offset(this.state.scaleX.domain()[0], extent / 2); // if forward let domain0 = newCentralTime; let domainF = d3.timeMinute.offset(newCentralTime, extent); // if backwards if (direction === 'backwards') { domain0 = d3.timeMinute.offset(newCentralTime, -extent); domainF = newCentralTime; } this.setState({ timerange: [domain0, domainF] }, () => { this.props.methods.onUpdateTimerange(this.state.timerange); }); } onCenterTime(newCentralTime) { const extent = this.getTimeScaleExtent(); const domain0 = d3.timeMinute.offset(newCentralTime, -extent/2); const domainF = d3.timeMinute.offset(newCentralTime, +extent/2); this.setState({ timerange: [domain0, domainF] }, () => { this.props.methods.onUpdateTimerange(this.state.timerange); }); } /** * Change display of time range * WITHOUT updating the store, or data shown. * Used for updates in the middle of a transition, for performance purposes */ onSoftTimeRangeUpdate(timerange) { this.setState({ timerange }); } /** * Apply zoom level to timeline * @param {object} zoom: zoom level from zoomLevels */ onApplyZoom(zoom) { const extent = this.getTimeScaleExtent(); const newCentralTime = d3.timeMinute.offset(this.state.scaleX.domain()[0], extent / 2); this.setState({ timerange: [ d3.timeMinute.offset(newCentralTime, -zoom.duration / 2), d3.timeMinute.offset(newCentralTime, zoom.duration / 2) ]}, () => { this.props.methods.onUpdateTimerange(this.state.timerange); }); } toggleTransition(isTransition) { this.setState({ transitionDuration: (isTransition) ? 300 : 0 }); } /* * Setup drag behavior */ onDragStart() { d3.event.sourceEvent.stopPropagation(); this.setState({ dragPos0: d3.event.x }, () => { this.toggleTransition(false); }); } /* * Drag and update */ onDrag() { const drag0 = this.state.scaleX.invert(this.state.dragPos0).getTime(); const dragNow = this.state.scaleX.invert(d3.event.x).getTime(); const timeShift = (drag0 - dragNow) / 1000; const newDomain0 = d3.timeSecond.offset(this.props.app.timerange[0], timeShift); const newDomainF = d3.timeSecond.offset(this.props.app.timerange[1], timeShift); // Updates components without updating timerange this.onSoftTimeRangeUpdate([newDomain0, newDomainF]); } /** * Stop dragging and update data */ onDragEnd() { this.toggleTransition(true); this.props.methods.onUpdateTimerange(this.state.timerange); } getDatetimeX(dt) { return this.state.scaleX(parseDate(dt.timestamp)) } /** * Determines additional styles on the for each timestamp. Note that * timestamp visualisation functions slightly differently from locations, as * a timestamp can be shown as multiple s (one per category of the * events contained therein). Thus the function below has a category as an * argumnent as well, in case timestamps ought to be styled per category. * A datetime consists of an array of events (see selectors). The function * also has full access to the domain and redux state to derive values if * necessary. The function should return an array, where the value at the * first index is a styles object for the SVG at the location, and the value * at the second index is an optional function that renders additional * components in the div. */ styleDatetime(timestamp, category) { return [] } render() { const { isNarrative, app, ui } = this.props let classes = `timeline-wrapper ${(this.state.isFolded) ? ' folded' : ''}`; classes += (app.narrative !== null) ? ' narrative-mode' : ''; const dims = this.state.dims; return (
{ this.onClickArrow(); }} hideInfo={isNarrative} />
{ this.onDragStart() }} onDrag={() => { this.onDrag() }} onDragEnd={() => { this.onDragEnd() }} categories={this.props.domain.categories} /> { this.onMoveTime(dir) }} /> { this.onApplyZoom(zoom); }} />
); } } function mapStateToProps(state) { return { isNarrative: !!state.app.narrative, domain: { datetimes: selectors.selectDatetimes(state), categories: selectors.selectCategories(state), narratives: state.domain.narratives }, app: { timerange: selectors.getTimeRange(state), selected: state.app.selected, language: state.app.language, zoomLevels: state.app.zoomLevels, narrative: state.app.narrative }, ui: { dom: state.ui.dom, } } } export default connect(mapStateToProps)(Timeline);