diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx
index 273fc98..e2139a5 100644
--- a/src/components/Timeline.jsx
+++ b/src/components/Timeline.jsx
@@ -4,6 +4,7 @@ import * as selectors from '../selectors';
import copy from '../js/data/copy.json';
import { formatterWithYear } from '../js/utilities';
+import TimelineHeader from './presentational/TimelineHeader';
import TimelineLogic from '../js/timeline/timeline.js';
class Timeline extends React.Component {
@@ -15,18 +16,12 @@ class Timeline extends React.Component {
}
componentDidMount() {
- const ui = {
- dom: this.props.dom
- }
-
- this.timeline = new TimelineLogic(this.props.app, ui, this.props.methods);
+ this.timeline = new TimelineLogic(this.props.app, this.props.ui, this.props.methods);
this.timeline.update(this.props.domain, this.props.app);
- this.timeline.render(this.props.domain);
}
componentWillReceiveProps(nextProps) {
this.timeline.update(nextProps.domain, nextProps.app);
- this.timeline.render(nextProps.domain);
}
onClickArrow() {
@@ -35,34 +30,19 @@ class Timeline extends React.Component {
});
}
- renderLabels() {
- const labels = copy[this.props.language].timeline.labels;
- return this.props.categories.map((label) => {
- const groupLen = this.props.categories.length
- return (
{label}
);
- });
- }
-
render() {
- const labels_title_lang = copy[this.props.app.language].timeline.labels_title;
- const info_lang = copy[this.props.app.language].timeline.info;
let classes = `timeline-wrapper ${(this.state.isFolded) ? ' folded' : ''}`;
- const date0 = formatterWithYear(this.props.app.timerange[0]);
- const date1 = formatterWithYear(this.props.app.timerange[1]);
return (
-
-
this.onClickArrow()}>
-
-
-
-
{info_lang}
-
{date0} - {date1}
-
-
+
{ this.onClickArrow(); }}
+ />
);
@@ -82,7 +62,9 @@ function mapStateToProps(state) {
language: state.app.language,
zoomLevels: state.app.zoomLevels
},
- dom: state.ui.dom,
+ ui: {
+ dom: state.ui.dom,
+ }
}
}
diff --git a/src/components/presentational/TimelineHeader.js b/src/components/presentational/TimelineHeader.js
new file mode 100644
index 0000000..d75a605
--- /dev/null
+++ b/src/components/presentational/TimelineHeader.js
@@ -0,0 +1,15 @@
+import React from 'react';
+
+const TimelineHeader = ({ title, date0, date1, onClick }) => (
+
+
+
+
{title}
+
{date0} - {date1}
+
+
+);
+
+export default TimelineHeader;
diff --git a/src/js/map/map.js b/src/js/map/map.js
index 3e30e93..e16b149 100644
--- a/src/js/map/map.js
+++ b/src/js/map/map.js
@@ -122,40 +122,49 @@ Stop and start the development process in terminal after you have added your tok
}
function getSVGBoundaries() {
+ const mapNode = d3.select('.leaflet-map-pane').node();
+
+ // We'll get the transform of the leaflet container,
+ // which will let us offset the SVG by the same quantity
+ const transform = window
+ .getComputedStyle(mapNode)
+ .getPropertyValue('transform');
+
+ // However getComputedStyle returns an awkward string of the format
+ // matrix(0, 0, 1, 0, 0.56523, 123123), hence this awkwardness
return {
- topLeft: projectPoint(maxBoundaries[0]),
- bottomRight: projectPoint(maxBoundaries[1])
+ transformX: +transform.split(',')[4],
+ transformY: +transform.split(',')[5].substring(0, transform.split(',')[5].length - 2)
}
}
function updateSVG() {
- const boundaries = getSVGBoundaries();
- const {
- topLeft,
- bottomRight
- } = boundaries;
- svg.attr('width', bottomRight.x - topLeft.x + 200)
- .attr('height', bottomRight.y - topLeft.y + 200)
- .style('left', `${topLeft.x - 100}px`)
- .style('top', `${topLeft.y - 100}px`);
+ const boundingClient = d3.select(`#${ui.dom.map}`).node().getBoundingClientRect();
+ let WIDTH = boundingClient.width;
+ let HEIGHT = boundingClient.height;
- g.attr('transform', `translate(${-(topLeft.x - 100)},${-(topLeft.y - 100)})`);
+ // Offset with leaflet map transform boundaries
+ const { transformX, transformY } = getSVGBoundaries();
+
+ svg.attr('width', WIDTH)
+ .attr('height', HEIGHT)
+ .style('left', -transformX)
+ .style('top', -transformY)
g.selectAll('.location').attr('transform', (d) => {
const newPoint = projectPoint([+d.latitude, +d.longitude]);
- return `translate(${newPoint.x},${newPoint.y})`;
+ return `translate(${newPoint.x + transformX},${newPoint.y + transformY})`;
});
+ const sequenceLine = d3.line()
+ .x(d => getCoords(d).x + transformX)
+ .y(d => getCoords(d).y + transformY);
+
g.selectAll('.narrative')
.attr('d', sequenceLine);
-
- const busLine = d3.line()
- .x(d => lMap.latLngToLayerPoint(d).x)
- .y(d => lMap.latLngToLayerPoint(d).y)
- .curve(d3.curveMonotoneX);
}
- lMap.on("zoom viewreset move", updateSVG);
+ lMap.on("zoomend viewreset moveend", updateSVG);
/**
* Returns latitud / longitude
@@ -224,9 +233,7 @@ Stop and start the development process in terminal after you have added your tok
function getLocationEventsDistribution(location) {
const eventCount = {};
const categories = domain.categories;
- // categories.sort((a, b) => {
- // return (+a.slice(-2) > +b.slice(-2));
- // });
+
categories.forEach(cat => {
eventCount[cat.category] = 0
});
diff --git a/src/js/timeline/timeline.js b/src/js/timeline/timeline.js
index 8a009e9..3598c94 100644
--- a/src/js/timeline/timeline.js
+++ b/src/js/timeline/timeline.js
@@ -9,54 +9,49 @@ import {
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(app, ui, methods) {
+export default function(newApp, ui, methods) {
d3.timeFormatDefaultLocale(esLocale);
- const zoomLevels = app.zoomLevels;
- let events = [];
- let categories = [];
- let selected = [];
- let timerange = app.timerange;
+ const domain = {
+ events: [],
+ categories: [],
+ }
+ const app = {
+ selected: [],
+ highlighted: null,
+ zoomLevels: newApp.zoomLevels,
+ timerange: newApp.timerange,
+ language: newApp.language
+ }
+
+ // 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;
- // Dimension of the client
- const WIDTH_CONTROLS = 100;
- const boundingClient = d3.select(`#${ui.dom.timeline}`).node().getBoundingClientRect();
- let WIDTH = boundingClient.width - WIDTH_CONTROLS;
- const HEIGHT = 140;
- const markerRadius = 15;
- // margin
- // NB: is it possible to do this with SCSS?
- // A: Maybe, although we are using it programmatically here for now
- const mg = {
- l: 120
- };
-
/**
* Create scales
*/
const scale = {};
-
- scale.x = d3.scaleTime()
- .domain(timerange)
- .range([mg.l, WIDTH]);
-
- // calculate group step between categories
- const groupStep = (106 - 30) / categories.length;
- const groupYs = new Array(categories.length);
- groupYs.map((g, i) => {
- return 30 + i * groupStep;
- });
-
+ scale.x = d3.scaleTime();
scale.y = d3.scaleOrdinal()
- .domain(categories)
- .range(groupYs);
+
/**
* Initilize SVG elements and groups
@@ -76,10 +71,10 @@ export default function(app, ui, methods) {
.attr('width', WIDTH_CONTROLS)
.attr('height', HEIGHT);
+
/*
* Axis group elements
*/
-
dom.axis = {};
dom.axis.x0 = dom.svg.append('g')
@@ -105,11 +100,14 @@ export default function(app, ui, methods) {
dom.axis.label1 = dom.svg.append('text')
.attr('class', 'timelabelF timeLabel');
+
/*
* Plottable elements
*/
dom.dataset = dom.svg.append('g');
dom.events = dom.dataset.append('g');
+ dom.markers = dom.svg.append('g');
+
/*
* Time Controls
@@ -125,10 +123,11 @@ export default function(app, ui, methods) {
dom.zooms = dom.controls.append('g');
dom.zooms.selectAll('.zoom-level-button')
- .data(zoomLevels)
+ .data(app.zoomLevels)
.enter().append('text')
.attr('class', 'zoom-level-button');
+
/*
* Initialize axis function and element group
*/
@@ -152,37 +151,8 @@ export default function(app, ui, methods) {
d3.axisLeft(scale.y)
.tickValues([]);
- /*
- * 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;
+ updateAxis();
- const newDomain0 = d3.timeSecond.offset(timerange[0], timeShift);
- const newDomainF = d3.timeSecond.offset(timerange[1], timeShift);
-
- scale.x.domain([newDomain0, newDomainF])
- render();
- })
- .on('end', () => {
- toggleTransition(true);
- methods.onUpdateTimerange(scale.x.domain());
- });
-
- /*
- * SVG groups for marker
- */
-
- dom.markers = dom.svg.append('g');
/**
* Adapt dimensions when resizing
@@ -192,6 +162,7 @@ export default function(app, ui, methods) {
.getBoundingClientRect().width;
}
+
/**
* Resize timeline one window resice
*/
@@ -201,8 +172,8 @@ export default function(app, ui, methods) {
WIDTH = getCurrentWidth() - WIDTH_CONTROLS;
dom.svg.attr('width', WIDTH);
- scale.x.range([mg.l, WIDTH]);
- axis.y.tickSize(WIDTH - mg.l);
+ scale.x.range([margin.left, WIDTH]);
+ axis.y.tickSize(WIDTH - margin.left);
dom.axis.y.attr('transform', `translate(${WIDTH}, 0)`)
render(null);
}
@@ -210,6 +181,7 @@ export default function(app, ui, methods) {
}
addResizeListener();
+
/**
* Return which color event circle should be based on incident type
* @param {object} eventPoint data object
@@ -218,6 +190,7 @@ export default function(app, ui, methods) {
return methods.getCategoryColor(eventPoint.category);
}
+
/**
* Given an event, get all the filtered events that happen simultaneously
* @param {object} eventPoint: regular eventPoint data
@@ -225,10 +198,11 @@ export default function(app, ui, methods) {
function getAllEventsAtOnce(eventPoint) {
const timestamp = eventPoint.timestamp;
const category = eventPoint.category;
- return events
+ 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
@@ -238,6 +212,7 @@ export default function(app, ui, methods) {
return scale.y(yGroup);
}
+
/*
* Get x position of eventPoint, considering the time scale
* @param {object} eventPoint: regular eventPoint data
@@ -250,6 +225,7 @@ export default function(app, ui, methods) {
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
@@ -259,8 +235,12 @@ export default function(app, ui, methods) {
return (minutes * WIDTH) / allMins;
}
+
+ /*
+ * TODO: Highlight zoom level based on time range selected
+ */
function highlightZoomLevel(zoom) {
- zoomLevels.forEach((level) => {
+ app.zoomLevels.forEach((level) => {
if (level.label === zoom.label) {
level.active = true;
} else {
@@ -272,6 +252,7 @@ export default function(app, ui, methods) {
.classed('active', level => level.active);
}
+
/**
* Apply zoom level to timeline
* @param {object} zoom: zoom level from zoomLevels
@@ -289,6 +270,7 @@ export default function(app, ui, methods) {
methods.onUpdateTimerange(scale.x.domain());
}
+
/**
* Shift time range by moving forward or backwards
* @param {String} direction: 'forward' / 'backwards'
@@ -316,6 +298,33 @@ export default function(app, ui, methods) {
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);
+ methods.onUpdateTimerange(scale.x.domain());
+ });
+
+
/**
* Highlight event circle on hover
*/
@@ -325,6 +334,7 @@ export default function(app, ui, methods) {
.classed('mouseover', true);
}
+
/**
* Unhighlight event when mouse out
*/
@@ -334,11 +344,12 @@ export default function(app, ui, methods) {
.classed('mouseover', false);
}
+
/**
* It automatically sets brush timeline to a domain set by the params
*/
function updateTimeRange() {
- scale.x.domain(timerange);
+ scale.x.domain(app.timerange);
axis.x0.scale(scale.x);
axis.x1.scale(scale.x);
}
@@ -351,23 +362,24 @@ export default function(app, ui, methods) {
dom.axis.label0
.attr('x', 5)
.attr('y', 15)
- .text(formatterWithYear(timerange[0]));
+ .text(formatterWithYear(app.timerange[0]));
dom.axis.label1
.attr('x', WIDTH - 5)
.attr('y', 15)
- .text(formatterWithYear(timerange[1]))
+ .text(formatterWithYear(app.timerange[1]))
.style('text-anchor', 'end');
}
+
/**
- * Makes a circular rinig mark in one particular location at a time
+ * 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(selected);
+ .data(app.selected);
markers
.enter()
@@ -382,13 +394,14 @@ export default function(app, ui, methods) {
markers.exit().remove();
}
+
/**
* Return event circles of different groups
*/
function renderEvents() {
const eventsDom = dom.events
.selectAll('.event')
- .data(events, d => d.id);
+ .data(domain.events, d => d.id);
eventsDom
.exit()
@@ -417,6 +430,7 @@ export default function(app, ui, methods) {
.attr('r', 5);
}
+
/**
* Render axis on timeline and viewbox boundaries
*/
@@ -437,7 +451,7 @@ export default function(app, ui, methods) {
.duration(transitionDuration)
.call(axis.x1);
- axis.y.tickSize(WIDTH - mg.l);
+ axis.y.tickSize(WIDTH - margin.left);
dom.axis.y
.call(axis.y)
@@ -453,12 +467,13 @@ export default function(app, ui, methods) {
.attr('x', scale.x.range()[1] - 5);
}
+
/**
* Render left and right time shifting controls
*/
function renderTimeControls() {
const zoomLabels = copy[app.language].timeline.zooms;
- zoomLevels.forEach((level, i) => {
+ app.zoomLevels.forEach((level, i) => {
level.label = zoomLabels[i];
});
@@ -495,49 +510,72 @@ export default function(app, ui, methods) {
.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(domain) {
- const categories = domain.categories
- const groupStep = (106 - 30) / categories.length;
- let groupYs = Array.apply(null, Array(categories.length));
+ function updateAxis() {
+ scale.x = d3.scaleTime()
+ .domain(app.timerange)
+ .range([margin.left, WIDTH]);
+
+ 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(categories)
+ .domain(domain.categories)
.range(groupYs);
axis.y =
d3.axisLeft(scale.y)
- .tickValues(categories.map(c => c.category));
+ .tickValues(domain.categories.map(c => c.category));
}
- function update(domain, app) {
- updateAxis(domain);
- renderAxis();
- events = domain.events;
- timerange = app.timerange;
- selected = app.selected.slice(0);
- updateTimeRange();
+ /**
+ * 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) {
+ if (hash(domain) !== hash(newDomain)) {
+ domain.categories = newDomain.categories;
+ domain.events = newDomain.events;
+ updateAxis();
+ renderContext();
+ }
+ if (hash(app) !== hash(newApp)) {
+ app.timerange = newApp.timerange;
+ app.selected = newApp.selected.slice(0);
+ updateTimeRange();
+ renderTimeLabels();
+ renderEventsAndHighlight();
+ }
}
- function render() {
+ function renderContext() {
renderAxis();
renderTimeControls();
renderTimeLabels();
+ }
+ function renderEventsAndHighlight() {
renderEvents();
renderHighlight();
}
+ function render() {
+ renderContext();
+ renderEventsAndHighlight();
+ }
+
return {
update,
- render,
};
}