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