diff --git a/src/components/Map.jsx b/src/components/Map.jsx index 6f93158..7ced231 100644 --- a/src/components/Map.jsx +++ b/src/components/Map.jsx @@ -112,7 +112,6 @@ class Map extends React.Component { } renderSVG() { - if (this.map === null) return ''; const pane = this.map.getPanes().overlayPane; const { width, height } = this.getClientDims(); @@ -203,7 +202,7 @@ class Map extends React.Component { return (
- {this.renderSVG()} + {(this.map !== null) ? this.renderSVG() : ''} {(this.map !== null) ? this.renderMarkers() : ''} {(this.map !== null) ? this.renderSites() : ''} {(this.map !== null) ? this.renderEvents() : ''} diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx index 28aba21..e68db1d 100644 --- a/src/components/Timeline.jsx +++ b/src/components/Timeline.jsx @@ -4,27 +4,105 @@ import * as selectors from '../selectors'; import hash from 'object-hash'; import copy from '../js/data/copy.json'; -import { formatterWithYear, isNotNullNorUndefined } from '../js/utilities'; +import { formatterWithYear, parseDate } from '../js/utilities'; import TimelineHeader from './presentational/TimelineHeader'; -import TimelineLogic from '../js/timeline/timeline.js'; +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.svgRef = React.createRef() this.state = { - isFolded: false + 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.timeline = new TimelineLogic(this.props.app, this.props.ui, this.props.methods); - this.timeline.update(this.props.domain, this.props.app); + this.computeDims(); + this.addEventListeners(); } componentWillReceiveProps(nextProps) { if (hash(nextProps) !== hash(this.props)) { - this.timeline.update(nextProps.domain, nextProps.app); + 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) + }); + } + } + + addEventListeners() { + window.addEventListener('resize', () => { this.computeDims(); }); + } + + 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() }); + } + } + + /** + * Get x position of eventPoint, considering the time scale + * @param {object} eventPoint: regular eventPoint data + */ + getEventX(eventPoint) { + return this.state.scaleX(parseDate(eventPoint.timestamp)); + } + + /** + * Get y height of eventPoint, considering the ordinal Y scale + * @param {object} eventPoint: regular eventPoint data + */ + getEventY(eventPoint) { + return this.state.scaleY(eventPoint.category); + } + + /** + * Returns the time scale (x) extent in minutes + */ + getTimeScaleExtent() { + const timeDomain = this.state.scaleX.domain(); + return (timeDomain[1].getTime() - timeDomain[0].getTime()) / 60000; } onClickArrow() { @@ -33,6 +111,162 @@ class Timeline extends React.Component { }); } + 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 }) + }); + } + } + + /** + * 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); + }); + } + + /** + * 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); + } + + renderSVG() { + const dims = this.state.dims; + + return ( + + + + { this.onDragStart() }} + onDrag={() => { this.onDrag() }} + onDragEnd={() => { this.onDragEnd() }} + categories={this.props.domain.categories} + /> + { this.onMoveTime(dir) }} + /> + { this.onApplyZoom(zoom); }} + /> + + this.getEventX(e)} + getEventY={(e) => this.getEventY(e)} + transitionDuration={this.state.transitionDuration} + /> + this.getEventX(e)} + getEventY={(e) => this.getEventY(e)} + getCategoryColor={this.props.methods.getCategoryColor} + transitionDuration={this.state.transitionDuration} + onSelect={this.props.methods.onSelect} + /> + + ); + } + render() { const { isNarrative, app, ui } = this.props let classes = `timeline-wrapper ${(this.state.isFolded) ? ' folded' : ''}`; @@ -40,14 +274,16 @@ class Timeline extends React.Component { return (
{ this.onClickArrow(); }} hideInfo={isNarrative} />
-
+
+ {this.renderSVG()} +
); diff --git a/src/components/TimelineAxis.jsx b/src/components/TimelineAxis.jsx new file mode 100644 index 0000000..0daafe4 --- /dev/null +++ b/src/components/TimelineAxis.jsx @@ -0,0 +1,69 @@ +import React from 'react'; + +class TimelineAxis extends React.Component { + + constructor() { + super(); + this.xAxis0Ref = React.createRef(); + this.xAxis1Ref = React.createRef(); + this.state = { + isInitialized: false, + } + } + + + componentDidUpdate() { + if (this.props.scaleX) { + this.x0 = + d3.axisBottom(this.props.scaleX) + .ticks(10) + .tickPadding(5) + .tickSize(this.props.dims.trackHeight) + .tickFormat(d3.timeFormat('%d %b')); + + this.x1 = + d3.axisBottom(this.props.scaleX) + .ticks(10) + .tickPadding(this.props.dims.margin_top) + .tickSize(0) + .tickFormat(d3.timeFormat('%H:%M')); + + if (!this.state.isInitialized) this.setState({ isInitialized: true }); + } + + if (this.state.isInitialized) { + d3.select(this.xAxis0Ref.current) + .transition() + .duration(this.props.transitionDuration) + .call(this.x0); + + d3.select(this.xAxis1Ref.current) + .transition() + .duration(this.props.transitionDuration) + .call(this.x1); + } + } + + render () { + return ( + + + + + + + ); + } +} + +export default TimelineAxis; \ No newline at end of file diff --git a/src/components/TimelineCategories.jsx b/src/components/TimelineCategories.jsx new file mode 100644 index 0000000..d1cd15f --- /dev/null +++ b/src/components/TimelineCategories.jsx @@ -0,0 +1,62 @@ +import React from 'react'; + +class TimelineCategories extends React.Component { + + constructor() { + super(); + this.grabRef = React.createRef() + this.state = { + isInitialized: false + } + } + + componentDidUpdate() { + if (!this.state.isInitialized) { + const drag = d3.drag() + .on('start', this.props.onDragStart) + .on('drag', this.props.onDrag) + .on('end', this.props.onDragEnd); + + d3.select(this.grabRef.current) + .call(drag); + + this.setState({ isInitialized: true }); + } + } + + getY(idx) { + return (idx + 1) * this.props.dims.trackHeight / this.props.categories.length + } + + renderCategory(category, idx) { + const dims = this.props.dims; + return ( + + + {category.category} + + ) + } + + render () { + const dims = this.props.dims; + + return ( + + {this.props.categories.map((cat, idx) => this.renderCategory(cat, idx))} + + + ); + } +} + +export default TimelineCategories; \ No newline at end of file diff --git a/src/components/presentational/TimelineClip.js b/src/components/presentational/TimelineClip.js new file mode 100644 index 0000000..29491da --- /dev/null +++ b/src/components/presentational/TimelineClip.js @@ -0,0 +1,15 @@ +import React from 'react'; + +const TimelineClip = ({ dims }) => ( + + + + +); + +export default TimelineClip; diff --git a/src/components/presentational/TimelineEvents.js b/src/components/presentational/TimelineEvents.js new file mode 100644 index 0000000..2ce4086 --- /dev/null +++ b/src/components/presentational/TimelineEvents.js @@ -0,0 +1,55 @@ +import React from 'react'; + +const TimelineEvents = ({ events, narrative, getEventX, getEventY, + getCategoryColor, onSelect, transitionDuration }) => { + + function getAllEventsAtOnce(eventPoint) { + const timestamp = eventPoint.timestamp; + const category = eventPoint.category; + return events + .filter(event => (event.timestamp === timestamp && category === event.category)) + } + + function renderEvent(event) { + let styleProps = ({ + fill: getCategoryColor(event.category), + fillOpacity: 0.8, + transform: `translate(${getEventX(event)}px, ${getEventY(event)}px)`, + transition: `transform ${transitionDuration / 1000}s ease` + }); + + if (narrative) { + const { steps } = narrative + const isInNarrative = steps.map(s => s.id).includes(event.id) + + if (!isInNarrative) { + styleProps = { + ...styleProps, + fillOpacity: 0.1 + } + } + } + + return ( + {onSelect(getAllEventsAtOnce(event))}} + > + + ) + } + + return ( + + {events.map(event => renderEvent(event))} + + ); +} + +export default TimelineEvents; \ No newline at end of file diff --git a/src/components/presentational/TimelineHandles.js b/src/components/presentational/TimelineHandles.js new file mode 100644 index 0000000..3e65f9b --- /dev/null +++ b/src/components/presentational/TimelineHandles.js @@ -0,0 +1,26 @@ +import React from 'react'; + +const TimelineHandles = ({ dims, onMoveTime }) => { + + return ( + + onMoveTime('backwards')} + > + + + + onMoveTime('forward')} + > + + + + + ) + +} + +export default TimelineHandles; \ No newline at end of file diff --git a/src/components/presentational/TimelineLabels.js b/src/components/presentational/TimelineLabels.js new file mode 100644 index 0000000..3f165aa --- /dev/null +++ b/src/components/presentational/TimelineLabels.js @@ -0,0 +1,44 @@ +import React from 'react'; + +import { formatterWithYear } from '../../js/utilities.js'; + +const TimelineLabels = ({ dims, timelabels }) => { + + return ( + + + + + + + {formatterWithYear(timelabels[0])} + + + {formatterWithYear(timelabels[1])} + + + ) +} + +export default TimelineLabels; \ No newline at end of file diff --git a/src/components/presentational/TimelineMarkers.js b/src/components/presentational/TimelineMarkers.js new file mode 100644 index 0000000..7126015 --- /dev/null +++ b/src/components/presentational/TimelineMarkers.js @@ -0,0 +1,31 @@ +import React from 'react'; + +const TimelineMarkers = ({ getEventX, getEventY, transitionDuration, selected }) => { + function renderMarker(event) { + return ( + + + ) + } + + return ( + + {selected.map(event => renderMarker(event))} + + ); +} + +export default TimelineMarkers; \ No newline at end of file diff --git a/src/components/presentational/TimelineZoomControls.js b/src/components/presentational/TimelineZoomControls.js new file mode 100644 index 0000000..9b4f6c2 --- /dev/null +++ b/src/components/presentational/TimelineZoomControls.js @@ -0,0 +1,25 @@ +import React from 'react'; + +const TimelineZoomControls = ({ zoomLevels, dims, onApplyZoom }) => { + + function renderZoom(zoom, idx) { + return ( + onApplyZoom(zoom)} + > + {zoom.label} + + ) + } + + return ( + + {zoomLevels.map((z, idx) => renderZoom(z, idx))} + + ); +} + +export default TimelineZoomControls; \ No newline at end of file diff --git a/src/js/timeline/timeline.js b/src/js/timeline/timeline.js deleted file mode 100644 index 042ea21..0000000 --- a/src/js/timeline/timeline.js +++ /dev/null @@ -1,594 +0,0 @@ -/* - TIMELINE - Displays events over the course of the night - Allows brushing and selecting periods of time in it - TODO: is it possible to express this idiomatically as React? -*/ -import { - areEqual, - parseDate, - formatterWithYear -} from '../utilities'; -import hash from 'object-hash'; -import esLocale from '../data/es-MX.json'; -import copy from '../data/copy.json'; - -export default function(newApp, ui, methods) { - d3.timeFormatDefaultLocale(esLocale); - - const domain = { - events: [], - categories: [], - narratives: [] - } - const app = { - timerange: newApp.timerange, - selected: [], - language: newApp.language, - zoomLevels: newApp.zoomLevels - } - - // Dimension of the client - const WIDTH_CONTROLS = 100; - const HEIGHT = 140; - const boundingClient = d3.select(`#${ui.dom.timeline}`).node().getBoundingClientRect(); - let WIDTH = boundingClient.width - WIDTH_CONTROLS; - - // Highlight events with a larger white ring marker - const markerRadius = 15; - - // NB: is it possible to do this with SCSS? - // A: Maybe, although we are using it programmatically here for now - const margin = { left: 120 }; - - // Drag behavior - let dragPos0; - let transitionDuration = 500; - - /** - * Create scales - */ - const scale = {}; - scale.x = d3.scaleTime() - .domain(app.timerange) - .range([margin.left, WIDTH]); - - scale.y = d3.scaleOrdinal() - - - /** - * Initilize SVG elements and groups - */ - const dom = {}; - - dom.svg = - d3.select(`#${ui.dom.timeline}`) - .append('svg') - .attr('width', WIDTH) - .attr('height', HEIGHT); - - dom.clip = dom.svg.append("svg:clipPath") - .attr("id", "clip") - .append("svg:rect") - .attr("x", margin.left) - .attr("y", 0) - .attr("width", WIDTH - margin.left) - .attr("height", HEIGHT - 25); - - dom.controls = - d3.select(`#${ui.dom.timeline}`) - .append('svg') - .attr('class', 'time-controls') - .attr('width', WIDTH_CONTROLS) - .attr('height', HEIGHT); - - - /* - * Axis group elements - */ - dom.axis = {}; - - dom.axis.x0 = dom.svg.append('g') - .attr('transform', `translate(0, 25)`) - .attr('class', 'axis xAxis'); - - dom.axis.x1 = dom.svg.append('g') - .attr('transform', `translate(0, 105)`) - .attr('class', 'axis axisHourText'); - - dom.axis.y = dom.svg.append('g') - .attr('transform', `translate(${WIDTH}, 0)`) - .attr('class', 'yAxis'); - - dom.axis.boundaries = dom.svg.selectAll('.axisBoundaries') - .data([0, 1]) - .enter().append('line') - .attr('class', 'axisBoundaries'); - - dom.axis.label0 = dom.svg.append('text') - .attr('class', 'timeLabel0 timeLabel'); - - dom.axis.label1 = dom.svg.append('text') - .attr('class', 'timelabelF timeLabel'); - - - /* - * Plottable elements - */ - - dom.body = dom.svg.append("g").attr("clip-path", "url(#clip)"); - dom.events = dom.body.append('g'); - dom.markers = dom.body.append('g'); - - - /* - * Time Controls - */ - dom.forward = dom.svg.append('g').attr('class', 'time-controls-inline'); - dom.forward.append('circle'); - dom.forward.append('path'); - - dom.backwards = dom.svg.append('g').attr('class', 'time-controls-inline'); - dom.backwards.append('circle'); - dom.backwards.append('path'); - - dom.zooms = dom.controls.append('g'); - - dom.zooms.selectAll('.zoom-level-button') - .data(app.zoomLevels) - .enter().append('text') - .attr('class', 'zoom-level-button'); - - - /* - * Initialize axis function and element group - */ - const axis = {}; - - axis.x0 = - d3.axisBottom(scale.x) - .ticks(10) - .tickPadding(5) - .tickSize(80) - .tickFormat(d3.timeFormat('%d %b')); - - axis.x1 = - d3.axisBottom(scale.x) - .ticks(10) - .tickPadding(20) - .tickSize(0) - .tickFormat(d3.timeFormat('%H:%M')); - - axis.y = - d3.axisLeft(scale.y) - .tickValues([]); - - updateAxis(); - - - /** - * Adapt dimensions when resizing - */ - function getCurrentWidth() { - return d3.select(`#${ui.dom.timeline}`).node() - .getBoundingClientRect().width; - } - - - /** - * Resize timeline one window resice - */ - function addResizeListener() { - window.addEventListener('resize', () => { - if (d3.select(`#${ui.dom.timeline}`).node() !== null) { - WIDTH = getCurrentWidth() - WIDTH_CONTROLS; - - dom.svg.attr('width', WIDTH); - scale.x.range([margin.left, WIDTH]); - axis.y.tickSize(WIDTH - margin.left); - dom.axis.y.attr('transform', `translate(${WIDTH}, 0)`) - render(null); - } - }); - } - addResizeListener(); - - - /** - * Return which color event circle should be based on incident type - * @param {object} eventPoint data object - */ - function getEventPointFillColor(eventPoint) { - return methods.getCategoryColor(eventPoint.category); - } - - - /** - * Given an event, get all the filtered events that happen simultaneously - * @param {object} eventPoint: regular eventPoint data - */ - function getAllEventsAtOnce(eventPoint) { - const timestamp = eventPoint.timestamp; - const category = eventPoint.category; - return domain.events - .filter(event => (event.timestamp === timestamp && category === event.category)) - } - - - /* - * Get y height of eventPoint, considering the ordinal Y scale - * @param {object} eventPoint: regular eventPoint data - */ - function getEventY(eventPoint) { - const yGroup = eventPoint.category; - return scale.y(yGroup); - } - - - /* - * Get x position of eventPoint, considering the time scale - * @param {object} eventPoint: regular eventPoint data - */ - function getEventX(eventPoint) { - return scale.x(parseDate(eventPoint.timestamp)); - } - - function getTimeScaleExtent() { - return (scale.x.domain()[1].getTime() - scale.x.domain()[0].getTime()) / 60000; - } - - - /* - * Given a number of minutes, calculate the width based on current scale.x - * @param {number} minutes: number of minutes - */ - function getWidthOfTime(minutes) { - const allMins = getTimeScaleExtent(); - return (minutes * WIDTH) / allMins; - } - - - /* - * TODO: Highlight zoom level based on time range selected - */ - function highlightZoomLevel(zoom) { - app.zoomLevels.forEach((level) => { - if (level.label === zoom.label) { - level.active = true; - } else { - level.active = false; - } - }); - - dom.zooms.selectAll('text') - .classed('active', level => level.active); - } - - - /** - * Apply zoom level to timeline - * @param {object} zoom: zoom level from zoomLevels - */ - function applyZoom(zoom) { - highlightZoomLevel(zoom); - - const extent = getTimeScaleExtent(); - const newCentralTime = d3.timeMinute.offset(scale.x.domain()[0], extent / 2); - - const domain0 = d3.timeMinute.offset(newCentralTime, -zoom.duration / 2); - const domainF = d3.timeMinute.offset(newCentralTime, zoom.duration / 2); - - scale.x.domain([domain0, domainF]); - methods.onUpdateTimerange(scale.x.domain()); - } - - - /** - * Shift time range by moving forward or backwards - * @param {String} direction: 'forward' / 'backwards' - */ - function moveTime(direction) { - methods.onSelect(); - const extent = getTimeScaleExtent(); - const newCentralTime = d3.timeMinute.offset(scale.x.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; - } - - scale.x.domain([domain0, domainF]); - methods.onUpdateTimerange(scale.x.domain()); - } - - function toggleTransition(isTransition) { - transitionDuration = (isTransition) ? 500 : 0; - } - - - /* - * Setup drag behavior - */ - const drag = d3.drag() - .on('start', () => { - d3.event.sourceEvent.stopPropagation(); - dragPos0 = d3.event.x; - toggleTransition(false); - }) - .on('drag', () => { - const drag0 = scale.x.invert(dragPos0).getTime(); - const dragNow = scale.x.invert(d3.event.x).getTime(); - const timeShift = (drag0 - dragNow) / 1000; - - const newDomain0 = d3.timeSecond.offset(app.timerange[0], timeShift); - const newDomainF = d3.timeSecond.offset(app.timerange[1], timeShift); - - scale.x.domain([newDomain0, newDomainF]); - render(); - }) - .on('end', () => { - toggleTransition(true); - app.timerange = scale.x.domain(); - methods.onUpdateTimerange(scale.x.domain()); - }); - - - /** - * Highlight event circle on hover - */ - function handleMouseOver() { - d3.select(this) - .attr('r', 7) - .classed('mouseover', true); - } - - - /** - * Unhighlight event when mouse out - */ - function handleMouseOut() { - d3.select(this) - .attr('r', 5) - .classed('mouseover', false); - } - - - /** - * It automatically sets brush timeline to a domain set by the params - */ - function updateTimeRange() { - scale.x.domain(app.timerange); - axis.x0.scale(scale.x); - axis.x1.scale(scale.x); - } - - - /** - * Display the current time range in the time label above the timeline - */ - function renderTimeLabels() { - dom.axis.label0 - .attr('x', 5) - .attr('y', 15) - .text(formatterWithYear(app.timerange[0])); - - dom.axis.label1 - .attr('x', WIDTH - 5) - .attr('y', 15) - .text(formatterWithYear(app.timerange[1])) - .style('text-anchor', 'end'); - } - - - /** - * Makes a circular ring mark in all selected events - * @param {object} eventPoint: object with eventPoint data (time, loc, tags) - */ - function renderHighlight() { - const markers = dom.markers - .selectAll('circle') - .data(app.selected); - - markers - .enter() - .append('circle') - .attr('class', 'timeline-marker') - .merge(markers) - .attr('cy', eventPoint => getEventY(eventPoint)) - .attr('cx', eventPoint => getEventX(eventPoint)) - .attr('r', 10) - .style('opacity', .9); - - markers.exit().remove(); - } - - - /** - * Return event circles of different groups - */ - function renderEvents() { - const eventsDom = dom.events - .selectAll('.event') - .data(domain.events, d => d.id); - - eventsDom - .exit() - .remove(); - - eventsDom - .transition() - .duration(transitionDuration) - .attr('cx', eventPoint => getEventX(eventPoint)); - - eventsDom - .enter() - .append('circle') - .attr('class', 'event') - .attr('cx', eventPoint => getEventX(eventPoint)) - .attr('cy', eventPoint => getEventY(eventPoint)) - .style('fill', eventPoint => getEventPointFillColor(eventPoint)) - .on('click', eventPoint => { - return methods.onSelect(getAllEventsAtOnce(eventPoint)) - }) - .on('mouseover', handleMouseOver) - .on('mouseout', handleMouseOut) - .transition() - .delay(300) - .duration(200) - .attr('r', 5); - } - - - /** - * Render axis on timeline and viewbox boundaries - */ - function renderAxis() { - dom.axis.x0 - .call(drag); - - dom.axis.x1 - .call(drag); - - dom.axis.x0 - .transition() - .duration(transitionDuration) - .call(axis.x0); - - dom.axis.x1 - .transition() - .duration(transitionDuration) - .call(axis.x1); - - axis.y.tickSize(WIDTH - margin.left); - - dom.axis.y - .call(axis.y) - .call(drag); - - dom.axis.boundaries - .attr('x1', (d, i) => scale.x.range()[i]) - .attr('x2', (d, i) => scale.x.range()[i]) - .attr('y1', 10) - .attr('y2', 20); - - dom.axis.label1 - .attr('x', scale.x.range()[1] - 5); - } - - - /** - * Render left and right time shifting controls - */ - function renderTimeControls() { - const zoomLabels = copy[app.language].timeline.zooms; - app.zoomLevels.forEach((level, i) => { - level.label = zoomLabels[i]; - }); - - // These controls on timeline svg - dom.backwards.select('circle') - .attr('transform', `translate(${scale.x.range()[0] + 20}, 62)`) - .attr('r', 15); - - dom.backwards.select('path') - .attr('d', d3.symbol().type(d3.symbolTriangle).size(80)) - .attr('transform', `translate(${scale.x.range()[0] + 20}, 62)rotate(270)`); - - dom.forward.select('circle') - .attr('transform', `translate(${scale.x.range()[1] - 20}, 62)`) - .attr('r', 15); - - dom.forward.select('path') - .attr('d', d3.symbol().type(d3.symbolTriangle).size(80)) - .attr('transform', `translate(${scale.x.range()[1] - 20}, 62)rotate(90)`); - - dom.zooms.selectAll('text') - .text(d => d.label) - .attr('x', 60) - .attr('y', (d, i) => (i * 15) + 20) - .classed('active', level => level.active); - - dom.forward - .on('click', () => moveTime('forward')); - - dom.backwards - .on('click', () => moveTime('backwards')); - - dom.zooms.selectAll('text') - .on('click', zoom => applyZoom(zoom)); - } - - - /** - * Updates data displayed by this timeline, but only render if necessary - * @param {Object} domain: Redux state domain subtree - * @param {Object} app: Redux state app subtree - */ - function updateAxis() { - const groupStep = (106 - 30) / domain.categories.length; - let groupYs = Array.apply(null, Array(domain.categories.length)); - groupYs = groupYs.map((g, i) => { - return 30 + i * groupStep; - }); - - scale.y = d3.scaleOrdinal() - .domain(domain.categories) - .range(groupYs); - - axis.y = - d3.axisLeft(scale.y) - .tickValues(domain.categories.map(c => c.category)); - } - - - /** - * Updates displayable data on the timeline: events, selected and - * potentially adjusts time range - * @param {Object} newDomain: object of arrays of events and categories - * @param {Object} newApp: object of time range and selected events - */ - function update(newDomain, newApp) { - const isNewDomain = (hash(domain) !== hash(newDomain)); - const isNewAppProps = (hash(app) !== hash(newApp)); - - if (isNewDomain) { - domain.categories = newDomain.categories; - domain.events = newDomain.events; - domain.narratives = newDomain.narratives; - } - - if (isNewAppProps) { - app.timerange = newApp.timerange; - app.selected = newApp.selected.slice(0); - } - - if (isNewDomain || isNewAppProps) renderContent(); - if (isNewAppProps) renderContext(); - } - - function renderContext() { - renderTimeControls(); - renderTimeLabels(); - } - - function renderContent() { - updateAxis(); - renderAxis(); - renderEvents(); - renderHighlight(); - } - - function render() { - renderContext(); - renderContent(); - } - - return { - update, - }; -} diff --git a/src/scss/timeline.scss b/src/scss/timeline.scss index 24666a4..8e495b3 100644 --- a/src/scss/timeline.scss +++ b/src/scss/timeline.scss @@ -171,10 +171,24 @@ .yAxis { .tick line { + stroke: $midwhite; stroke-width: 15px; cursor: -webkit-grab; cursor: -moz-grab; } + + .tick text { + font-size: 10px; + font-family: 'Lato'; + text-anchor: end; + } + } + + .drag-grabber { + cursor: -webkit-grab; + cursor: -moz-grab; + fill: $offwhite; + opacity: 0.05; } .axisBoundaries { @@ -184,7 +198,6 @@ } .event { - fill: $event_default; cursor: pointer; opacity: .7; diff --git a/src/store/initial.js b/src/store/initial.js index c18baa0..8db1642 100644 --- a/src/store/initial.js +++ b/src/store/initial.js @@ -55,27 +55,27 @@ const initial = { language: 'en-US', mapAnchor: process.env.MAP_ANCHOR, zoomLevels: [{ - label: '3 años', + label: '3 years', duration: 1576800, active: false }, { - label: '3 meses', + label: '3 months', duration: 129600, active: false }, { - label: '3 días', + label: '3 days', duration: 4320, active: false }, { - label: '12 horas', + label: '12 hours', duration: 720, active: false }, { - label: '2 horas', + label: '2 hours', duration: 120, active: false },