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 (
+
+ );
+ }
+
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
},