Clean up and standarize update / render lifecycle

This commit is contained in:
Franc Camps-Febrer
2018-12-12 12:34:04 +01:00
parent 99c1c77b2d
commit 04cac7ef55

View File

@@ -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,71 @@ 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();
renderEventsAndHighlight();
}
}
function render() {
function renderContext() {
renderAxis();
renderTimeControls();
renderTimeLabels();
}
function renderEventsAndHighlight() {
renderEvents();
renderHighlight();
}
function render() {
renderContext();
renderEventsAndHighlight();
}
return {
update,
render,
};
}