Clean master commit

This commit is contained in:
Franc FC
2018-10-31 14:11:03 -04:00
parent 59aa005a64
commit 92e03fdb07
69 changed files with 12939 additions and 0 deletions

144
src/js/data/copy.json Normal file
View File

@@ -0,0 +1,144 @@
{
"es-MX": {
"loading": "Cargando...",
"legend": {
"view2d": {
"paragraphs": [
"Seleccionando una serie de filtros verá aparecer eventos en el mapa y en la línea del tiempo.",
"Cada evento estará coloreado según la persona que dio el testimonio del evento."
],
"colors": [
{ "class": "category_group00", "label": "Category Group 00" },
{ "class": "category_group01", "label": "Category Group 01" },
{ "class": "category_group02", "label": "Category Group 02" },
{ "class": "category_group03", "label": "Category Group 03" },
{ "class": "other", "label": "Other categories" }
]
}
},
"toolbar": {
"title": "TITLE",
"panels": {
"mentions": {
"title": "Personas",
"overview": "Seleccionar los nombres de personas mostrará eventos en los que esta persona o organización ha sido mencionada, incluyendo el propio testimonio. Entre paréntesis encontrará el número de menciones. Ej. (34)."
},
"categories": {
"title": "Testimonios",
"overview": "Seleccionar el nombre de una persona mostrará los eventos descritos por su testimonio. Entre paréntesis encontrará el número de eventos descritos. Ej. (34)."
},
"search": {
"title": "Directorio de etiquetas",
"placeholder": "Búsqueda"
}
}
},
"timeline": {
"zooms": [
"3 años",
"3 meses",
"3 días",
"12 horas",
"2 horas",
"30 min",
"10 min"
],
"labels_title": "Testimonios",
"labels": [
"Testimony Group 00",
"Testimony Group 01",
"Testimony Group 02",
"Testimony Group 03",
"Other categories"
],
"info": "Viendo eventos ocurridos entre"
},
"cardstack": {
"header": "eventos seleccionados",
"unknown_location": "Localización desconocida",
"timestamp": "Día y hora",
"estimated": "aproximado",
"location": "Localización",
"incident_type": "Tipo de acción",
"description": "Hechos",
"people": "Personas en el evento",
"source": "Fuente",
"category": "Según el testimonio de",
"communication": "Comunicación",
"transmitter": "Transmisor",
"receiver": "Receptor",
"warning": "(!) HECHOS CUESTIONADOS"
}
},
"en-US": {
"loading": "Loading...",
"legend": {
"view2d": {
"paragraphs": [
"Selecting a series of tags, you will be able to explore events on the map of Iguala and on the timeline.",
"Each event is colored according the person that gave category of the event."
],
"colors": [
{ "class": "category_group00", "label": "Category Group 00" },
{ "class": "category_group01", "label": "Category Group 01" },
{ "class": "category_group02", "label": "Category Group 02" },
{ "class": "category_group03", "label": "Category Group 03" },
{ "class": "other", "label": "Other categories" }
]
}
},
"toolbar": {
"title": "TITLE",
"panels": {
"mentions": {
"title": "Mentions",
"overview": "Selecting the names of people/organisation will show events in which these have been mentioned in their own testistimony and by others. The number in the parentheses shows how many events contain a mention of a person or organisation, e.g. (34)"
},
"categories": {
"title": "Testimonies",
"overview": "Selecting the name of a person will show the events only according to a persons category or category. The number in the parentheses show how many events are contained in each category, e.g. (34)."
},
"search": {
"title": "Directory of tags",
"placeholder": "Search"
}
}
},
"timeline": {
"zooms": [
"3 years",
"3 months",
"3 days",
"12 hours",
"2 hours",
"30 min",
"10 min"
],
"labels_title": "Testimonies",
"labels": [
"Testimony Group 00",
"Testimony Group 01",
"Testimony Group 02",
"Testimony Group 03",
"Other"
],
"info": "Seeing events occurred between"
},
"cardstack": {
"header": "selected events",
"timestamp": "Day and time",
"unknown_location": "Unknown location",
"estimated": "estimated",
"location": "Localization",
"incident_type": "Type of action",
"description": "Summary of facts",
"people": "People involved",
"source": "Source",
"category": "According to",
"communication": "Communication",
"transmitter": "Transmitter",
"receiver": "Receiver",
"warning": "(!) Highly questioned"
}
}
}

10
src/js/data/es-MX.json Normal file
View File

@@ -0,0 +1,10 @@
{
"dateTime": "%x, %X",
"date": "%d/%m/%Y",
"time": "%-I:%M:%S %p",
"periods": ["AM", "PM"],
"days": ["domingo", "lunes", "martes", "miércoles", "jueves", "viernes", "sábado"],
"shortDays": ["dom", "lun", "mar", "mié", "jue", "vie", "sáb"],
"months": ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"],
"shortMonths": ["ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sep", "oct", "nov", "dic"]
}

37
src/js/data/utilities.js Normal file
View File

@@ -0,0 +1,37 @@
/**
* Get URI params to start with predefined set of
* https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript
* @param {string} name: name of paramater to search
* @param {string} url: url passed as variable, defaults to window.location.href
*/
export function getParameterByName(name, url) {
if (!url) url = window.location.href;
name = name.replace(/[\[\]]/g, `\\$&`);
const regex = new RegExp(`[?&]${name}(=([^&#]*)|&|#|$)`);
const results = regex.exec(url);
if (!results) return null;
if (!results[2]) return '';
return decodeURIComponent(results[2].replace(/\+/g, ' '));
}
/**
* Compare two arrays of scalars
* @param {array} arr1: array of numbers
* @param {array} arr2: array of numbers
*/
export function areEqual(arr1, arr2) {
return ((arr1.length === arr2.length) && arr1.every((element, index) => {
return element === arr2[index];
}));
}
/**
* Return whether the variable is neither null nor undefined
* @param {object} variable
*/
export function isNotNullNorUndefined(variable) {
return (typeof variable !== 'undefined' && variable !== null);
}

393
src/js/map/map.js Normal file
View File

@@ -0,0 +1,393 @@
import {
areEqual,
isNotNullNorUndefined
} from '../data/utilities';
import hash from 'object-hash';
import 'leaflet-polylinedecorator';
export default function(newApp, ui, select) {
let svg, g, defs;
let categoryColorGroups = {};
const domain = {
locations: [],
categoryGroups: [],
sites: []
}
const app = {
selected: [],
highlighted: null,
views: Object.assign({}, newApp.views),
}
const getCategoryGroup = newApp.getCategoryGroup;
const getCategoryGroupColor = newApp.getCategoryGroupColor;
const groupColors = ui.style.groupColors;
// Map Settings
const center = newApp.mapAnchor;
const maxBoundaries = [[180, -180], [-180, 180]];
const zoomLevel = 14;
// Initialize layer
const sitesLayer = L.layerGroup();
const pathLayer = L.layerGroup();
// Icons for markPoint flags (a yellow ring around a location)
const eventCircleMarkers = {};
// Styles for elements in map
const settingsSiteLabel = {
className: 'site-label',
opacity: 1,
permanent: true,
direction: 'top',
};
/**
* Creates a Leaflet map and a tilelayer for the map background
* @param {string} id: DOM element to create map onto
* @param {array} center: [lat, long] coordinates the map will be centered on
* @param {number} zoom: zoom level
*/
function initBackgroundMap(id, zoom) {
/* http://bl.ocks.org/sumbera/10463358 */
const map = L.map(id)
.setView(center, zoom)
.setMinZoom(10)
.setMaxZoom(18)
.setMaxBounds(maxBoundaries);
// NB: configure tile endpoint
let s;
if (process.env.MAPBOX_TOKEN) {
s = L.tileLayer(
`http://a.tiles.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.png?access_token=${process.env.MAPBOX_TOKEN}`
);
} else {
s = L.tileLayer(`${process.env.SERVER_ROOT}/mapbox/{z}/{x}/{y}`);
}
s = s.addTo(map);
map.keyboard.disable();
const pane = d3.select(map.getPanes().overlayPane);
const boundingClient = d3.select(`#${id}`).node().getBoundingClientRect();
const width = boundingClient.width;
const height = boundingClient.height;
svg = pane.append('svg')
.attr('class', 'leaflet-svg')
.attr('width', width)
.attr('height', height);
g = svg.append('g');
svg.insert('defs', 'g')
.append('marker')
.attr('id', 'arrow')
.attr('viewBox', '0 0 6 6')
.attr('refX', 3)
.attr('refY', 3)
.attr('markerWidth', 14)
.attr('markerHeight', 14)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,3v-3l6,3l-6,3z');
map.on('zoomstart', () => {
svg.classed('hide', true);
});
map.on('zoomend', () => {
svg.classed('hide', false);
});
return map;
}
// Initialize leaflet map and layers for each type of data
const lMap = initBackgroundMap(ui.dom.map, zoomLevel);
function projectPoint(location) {
const latLng = new L.LatLng(location[0], location[1]);
return lMap.latLngToLayerPoint(latLng);
}
function getSVGBoundaries() {
return {
topLeft: projectPoint(maxBoundaries[0]),
bottomRight: projectPoint(maxBoundaries[1])
}
}
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`);
g.attr('transform', `translate(${-(topLeft.x - 100)},${-(topLeft.y - 100)})`);
g.selectAll('.location').attr('transform', (d) => {
const newPoint = projectPoint([+d.latitude, +d.longitude]);
return `translate(${newPoint.x},${newPoint.y})`;
});
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);
/**
* Returns latitud / longitude
* @param {Object} eventPoint: data for an evenPoint - time, loc, tags, etc
*/
function getEventLocation(eventPoint) {
return {
latitude: +eventPoint.location.latitude,
longitude: +eventPoint.location.longitude,
};
}
/*
* INTERACTIVE FUNCTIONS
*/
/**
* Removes the circular ring to mark a particular location
*/
function unmarkPoint() {
Object.keys(eventCircleMarkers).forEach(markerId => {
lMap.removeLayer(eventCircleMarkers[markerId]);
delete eventCircleMarkers[markerId];
});
}
/**
* Makes a circular ring mark in one particular location at a time
* @param {object} location object, with lat and long
*/
function renderSelected() {
unmarkPoint();
app.selected.forEach(eventPoint => {
if (isNotNullNorUndefined(eventPoint) && isNotNullNorUndefined(eventPoint.location)) {
if (eventPoint.latitude && eventPoint.longitude) {
const location = new L.LatLng(eventPoint.latitude, eventPoint.longitude);
eventCircleMarkers[eventPoint.id] = L.circleMarker(location, {
radius: 32,
fill: false,
color: '#ffffff',
weight: 3,
lineCap: '',
dashArray: '5,2'
});
eventCircleMarkers[eventPoint.id].addTo(lMap);
}
}
})
}
function renderHighlighted() {
// Fly to first of events selected
const eventPoint = (app.selected.length > 0) ? app.selected[0] : null;
if (isNotNullNorUndefined(eventPoint) && isNotNullNorUndefined(eventPoint.location)) {
if (eventPoint.latitude && eventPoint.longitude) {
const location = new L.LatLng(eventPoint.latitude, eventPoint.longitude);
lMap.flyTo(location);
}
}
}
/*
* RENDERING FUNCTIONS
*/
function getLocationEventsDistribution(location) {
const eventsHere = {};
const categoryGroups = domain.categoryGroups;
categoryGroups.sort((a, b) => {
return (+a.slice(-2) > +b.slice(-2));
});
categoryGroups.forEach(group => {
eventsHere[group] = 0
});
location.events.forEach((event) => {
const group = getCategoryGroup(event.category);
eventsHere[group] += 1;
});
let i = 0;
const events = [];
while (i < categoryGroups.length) {
let eventsCount = eventsHere[categoryGroups[i]];
for (let j = i + 1; j < categoryGroups.length; j++) {
eventsCount += eventsHere[categoryGroups[j]];
}
events.push(eventsCount);
i++;
}
return events;
}
/**
* Clears existing event layer
* Renders all events as markers
* Adds eventlayer to map
*/
function renderEvents() {
const locationsDom = g.selectAll('.location')
.data(domain.locations, d => d.id)
locationsDom
.exit()
.remove();
locationsDom
.enter().append('g')
.attr('class', 'location')
.attr('transform', (d) => {
d.LatLng = new L.LatLng(+d.latitude, +d.longitude);
return `translate(${lMap.latLngToLayerPoint(d.LatLng).x},
${lMap.latLngToLayerPoint(d.LatLng).y})`;
})
.on('click', (location) => {
select(location.events);
});
const eventsDom = g.selectAll('.location').selectAll('.location-event-marker')
.data((d, i) => getLocationEventsDistribution(domain.locations[i]),
(d, i) => 'location-' + i);
eventsDom
.exit()
.attr('r', 0)
.remove();
eventsDom
.transition()
.duration(500)
.attr('r', d => (d) ? Math.sqrt(16 * d) + 3 : 0);
eventsDom
.enter().append('circle')
.attr('class', 'location-event-marker')
.style('fill', (d, i) => groupColors[domain.categoryGroups[i]])
.transition()
.duration(500)
.attr('r', d => (d) ? Math.sqrt(16 * d) + 3 : 0);
}
// NB: is this a function to be removed for future features?
function renderSites() {
sitesLayer.clearLayers();
lMap.removeLayer(sitesLayer);
// Create a label for each attack site, persistent across filtering
if (app.views.sites) {
domain.sites.forEach((site) => {
if (isNotNullNorUndefined(site)) {
// Create an invisible marker for each site label
const siteMarker = L.circleMarker([+site.latitude, +site.longitude], {
radius: 0,
stroke: 0
});
siteMarker.bindTooltip(site.site, settingsSiteLabel).openTooltip();
// Add this one attack marker to group attack layer
sitesLayer.addLayer(siteMarker);
}
});
lMap.addLayer(sitesLayer);
}
}
// NB: is this a function to be removed for future features?
/**
* Creats a marker for an eventPoint along a path
* @param {Object} eventPoint: data for an evenPoint - time, loc, tags, etc
* @param {number} step: the portion of the entire path this event corresponds to
*/
function createPathEventMarker(eventPoint, step) {
const {
latitude,
longitude
} = getEventLocation(eventPoint);
const pathEventMarker = L.circleMarker(
[latitude, longitude], {
color: ui.colors.DARKGREY,
fill: ui.colors.DARKGREY,
weight: 2,
fillOpacity: 0.6,
radius: 10 * step,
},
);
// Add marker event handlers
pathEventMarker.bindPopup('');
pathEventMarker.on('popupopen', () => {
select([eventPoint]);
});
pathEventMarker.on('popupclose', () => {
select();
});
return pathEventMarker;
}
/**
* Updates displayable data on the map: events, coevents and paths
* @param {Object} domain: object of arrays of events, coevs, attacks, paths, sites
*/
function update(newDomain, newApp) {
updateSVG();
if (hash(domain) !== hash(newDomain)) {
domain.locations = newDomain.locations;
domain.categoryGroups = newDomain.categoryGroups;
domain.sites = newDomain.sites;
renderDomain();
}
if (hash(app) !== hash(newApp)) {
app.selected = newApp.selected;
app.highlighted = newApp.highlighted;
app.views = newApp.views;
renderSelectedAndHighlight();
}
}
/**
* Renders events on the map: takes data, and enters, updates and exits
*/
function renderDomain () {
renderSites();
renderEvents();
}
function renderSelectedAndHighlight () {
renderSelected();
renderHighlighted();
}
/**
* Expose only relevant functions
*/
return {
update
};
}

645
src/js/timeline/timeline.js Normal file
View File

@@ -0,0 +1,645 @@
/*
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
} from '../data/utilities';
import esLocale from '../data/es-MX.json';
import copy from '../data/copy.json';
export default function(app, ui) {
d3.timeFormatDefaultLocale(esLocale);
const formatterWithYear = ui.tools.formatterWithYear;
const parser = ui.tools.parser;
const zoomLevels = [{
label: '3 años',
duration: 1576800,
active: false
},
{
label: '3 meses',
duration: 129600,
active: false
},
{
label: '3 días',
duration: 4320,
active: true
},
{
label: '12 horas',
duration: 720,
active: false
},
{
label: '2 horas',
duration: 120,
active: false
},
{
label: '30 min',
duration: 30,
active: false
},
{
label: '10 min',
duration: 10,
active: false
},
];
let events = [];
let categoryGroups = [];
let selected = [];
let range = app.range;
const filter = app.filter;
const select = app.select;
const getCategoryLabel = app.getCategoryLabel;
const getCategoryGroupColor = app.getCategoryGroupColor;
const getCategoryGroup = app.getCategoryGroup;
// Play functions
window.playInterval;
let isPlaying = false;
const playDuration = 1000;
// Drag behavior
let dragPos0;
let transitionDuration = 500;
// Dimension of the client
const WIDTH_CONTROLS = 180;
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(range)
.range([mg.l, WIDTH]);
const groupStep = (106 - 30) / categoryGroups.length;
const groupYs = new Array(categoryGroups.length);
groupYs.map((g, i) => {
return 30 + i * groupStep;
});
scale.y = d3.scaleOrdinal()
.domain(categoryGroups)
.range(groupYs);
/**
* Initilize SVG elements and groups
*/
const dom = {};
dom.svg =
d3.select(`#${ui.dom.timeline}`)
.append('svg')
.attr('width', WIDTH)
.attr('height', HEIGHT);
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.dataset = dom.svg.append('g');
dom.events = dom.dataset.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.playGroup = dom.controls.append('g');
dom.playGroup.append('circle');
dom.play = dom.playGroup.append('g');
dom.play.append('path');
dom.pause = dom.playGroup.append('g').style('opacity', 0);
dom.pause.append('rect');
dom.pause.append('rect');
dom.zooms = dom.controls.append('g');
dom.zooms.selectAll('.zoom-level-button')
.data(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([]);
/*
* 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(range[0], timeShift);
const newDomainF = d3.timeSecond.offset(range[1], timeShift);
scale.x.domain([newDomain0, newDomainF])
render();
})
.on('end', () => {
toggleTransition(true);
filter({
range: scale.x.domain()
});
});
/*
* SVG groups for marker
*/
dom.markers = dom.svg.append('g');
/**
* 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([mg.l, WIDTH]);
axis.y.tickSize(WIDTH - mg.l);
dom.axis.y.attr('transform', `translate(${WIDTH}, 0)`)
render(null);
}
});
}
addResizeListener();
/**
* PLAY FUNCTIONALITY
*/
function stopBrushTransition() {
clearInterval(window.playInterval);
isPlaying = false;
dom.play.style('opacity', 1);
dom.pause.style('opacity', 0);
}
/**
* START PLAY SERIES OF TRANSITIONS
*/
function playBrushTransition() {
isPlaying = true;
dom.play.style('opacity', 0);
dom.pause.style('opacity', 1);
window.playInterval = setInterval(() => {
moveTime('forward');
}, playDuration);
}
/**
* Return which color event circle should be based on incident type
* @param {object} eventPoint data object
*/
function getEventPointFillColor(eventPoint) {
return getCategoryGroupColor(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 categoryGroup = getCategoryGroup(eventPoint.category);
return events.filter(event => {
return (event.timestamp === timestamp &&
categoryGroup === getCategoryGroup(event.category))
}).map(event => event.id);
}
/*
* Get y height of eventPoint, considering the ordinal Y scale
* @param {object} eventPoint: regular eventPoint data
*/
function getEventY(eventPoint) {
const yGroup = getCategoryGroup(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(parser(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;
}
function highlightZoomLevel(zoom) {
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]);
filter({
range: scale.x.domain()
});
}
/**
* Shift time range by moving forward or backwards
* @param {Stirng} direction: 'forward' / 'backwards'
*/
function moveTime(direction) {
select();
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]);
filter({
range: scale.x.domain()
});
}
function toggleTransition(isTransition) {
transitionDuration = (isTransition) ? 500 : 0;
}
/**
* 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(range);
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(range[0]));
dom.axis.label1
.attr('x', WIDTH - 5)
.attr('y', 15)
.text(formatterWithYear(range[1]))
.style('text-anchor', 'end');
}
/**
* Makes a circular rinig mark in one particular location at a time
* @param {object} eventPoint: object with eventPoint data (time, loc, tags)
*/
function renderHighlight() {
const markers = dom.markers
.selectAll('circle')
.data(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(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 => select(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 - mg.l);
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;
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)`);
// These controls on separate svg
dom.playGroup.select('circle')
.attr('transform', 'translate(135, 60)rotate(90)')
.attr('r', 25);
dom.play.select('path')
.attr('d', d3.symbol().type(d3.symbolTriangle).size(260))
.attr('transform', 'translate(135, 60)rotate(90)');
dom.pause.selectAll('rect')
.attr('transform', (d, i) => `translate(${125 + (i * 15)}, 47)`)
.attr('height', 25)
.attr('width', 5);
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.playGroup
.on('click', () => {
return (isPlaying) ? stopBrushTransition() : playBrushTransition();
});
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(domain) {
categoryGroups = domain.categoryGroups
const groupStep = (106 - 30) / categoryGroups.length;
let groupYs = Array.apply(null, Array(categoryGroups.length));
groupYs = groupYs.map((g, i) => {
return 30 + i * groupStep;
});
scale.y = d3.scaleOrdinal()
.domain(categoryGroups)
.range(groupYs);
axis.y =
d3.axisLeft(scale.y)
.tickValues(categoryGroups);
}
function update(domain, app) {
updateAxis(domain);
renderAxis();
events = domain.events;
range = app.range;
selected = app.selected.slice(0);
updateTimeRange();
}
function render() {
renderAxis();
renderTimeControls();
renderTimeLabels();
renderEvents();
renderHighlight();
}
return {
update,
render,
};
}