Merge pull request #52 from forensic-architecture/topic/narrative-mode

Implement focused narrative mode
This commit is contained in:
Lachlan Kermode
2018-12-18 17:36:11 +00:00
committed by GitHub
20 changed files with 462 additions and 387 deletions

View File

@@ -97,7 +97,6 @@ export function fetchDomain () {
sourcesPromise
])
.then(response => {
dispatch(toggleFetchingDomain())
const result = {
events: response[0],
categories: response[1],
@@ -107,11 +106,16 @@ export function fetchDomain () {
sources: response[5],
notifications
}
if (Object.values(result).some(resp => resp.hasOwnProperty('error'))) {
throw new Error('Some URLs returned negative. If you are in development, check the server is running')
}
return result
})
.catch(err => {
dispatch(fetchError(err.message))
dispatch(toggleFetchingDomain())
// TODO: handle this appropriately in React hierarchy
alert(err.message)
})
};
}

View File

@@ -129,11 +129,11 @@ class Card extends React.Component {
renderHeader() {
return (
<div className="card-collapsed">
<div className="card-column">
<div className="card-row">
{this.renderTimestamp()}
{this.renderLocation()}
</div>
{/* {this.renderCategory()} */}
{this.renderCategory()}
<br/>
{this.renderSummary()}
</div>

View File

@@ -36,17 +36,6 @@ class CardStack extends React.Component {
return '';
}
renderLocation() {
let locationName = copy[this.props.language].cardstack.unknown_location;
if (this.props.selected.length > 0) {
if (isNotNullNorUndefined(this.props.selected[0].location)) {
locationName = this.props.selected[0].location;
}
return (<p className="header-copy">in:<b>{` ${locationName}`}</b></p>)
}
return '';
}
renderCardStackHeader() {
const header_lang = copy[this.props.language].cardstack.header;

View File

@@ -22,7 +22,7 @@ class Dashboard extends React.Component {
this.handleHighlight = this.handleHighlight.bind(this);
this.handleSelect = this.handleSelect.bind(this);
// this.handleToggle = this.handleToggle.bind(this);
this.handleSelectNarrative = this.handleSelectNarrative.bind(this);
this.handleTagFilter = this.handleTagFilter.bind(this);
this.updateTimerange = this.updateTimerange.bind(this);
@@ -55,6 +55,10 @@ class Dashboard extends React.Component {
}
}
handleSelectNarrative(narrative) {
this.props.actions.updateNarrative(narrative);
}
handleTagFilter(tag) {
this.props.actions.updateTagFilters(tag);
}
@@ -76,23 +80,18 @@ class Dashboard extends React.Component {
render() {
return (
<div>
<Toolbar
onFilter={this.handleTagFilter}
onSelectNarrative={this.handleSelectNarrative}
actions={this.props.actions}
/>
<Viewport
methods={{
onSelect: this.handleSelect,
onSelectNarrative: this.handleSelectNarrative,
getCategoryColor: category => this.getCategoryColor(category)
}}
/>
<Toolbar
onFilter={this.handleTagFilter}
actions={this.props.actions}
/>
<CardStack
onSelect={this.handleSelect}
onHighlight={this.handleHighlight}
onToggleCardstack={() => this.props.actions.updateSelected([])}
getNarrativeLinks={event => this.getNarrativeLinks(event)}
getCategoryColor={category => this.getCategoryColor(category)}
/>
<Timeline
methods={{
onSelect: this.handleSelect,
@@ -100,18 +99,25 @@ class Dashboard extends React.Component {
getCategoryColor: category => this.getCategoryColor(category)
}}
/>
{(this.props.app.narrative !== null)
? <NarrativeCard
onSelect={this.handleSelect}
onSelectNarrative={this.handleSelectNarrative}
/>
: ''
}
<CardStack
onSelect={this.handleSelect}
onHighlight={this.handleHighlight}
onToggleCardstack={() => this.props.actions.updateSelected([])}
getNarrativeLinks={event => this.getNarrativeLinks(event)}
getCategoryColor={category => this.getCategoryColor(category)}
/>
<InfoPopUp
ui={this.props.ui}
app={this.props.app}
toggle={() => this.props.actions.toggleInfoPopup()}
/>
<NarrativeCard
onSelect={this.handleSelect}
actions={this.props.actions}
/>
<NarrativeCard
onSelect={this.handleSelect}
/>
<Notification
isNotification={this.props.app.flags.isNotification}
notifications={this.props.domain.notifications}

View File

@@ -23,10 +23,22 @@ class NarrativeCard extends React.Component {
}
}
componentDidUpdate() {
if (this.props.narrative !== null) {
componentDidMount() {
const step = this.props.narrative.steps[this.state.step];
this.props.onSelect([step]);
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.narrative === this.props.narrative && this.state.step !== prevState.step) {
const step = this.props.narrative.steps[this.state.step];
this.props.onSelect([step]);
} else if (prevProps.narrative !== this.props.narrative && this.props.narrative !== null) {
this.setState({
step: 0
}, () => {
const step = this.props.narrative.steps[this.state.step];
this.props.onSelect([step]);
});
}
}
@@ -34,7 +46,7 @@ class NarrativeCard extends React.Component {
return (
<button
className="side-menu-burg is-active"
onClick={() => { this.props.actions.updateNarrative(null); }}
onClick={() => { this.props.onSelectNarrative(null); }}
>
<span></span>
</button>
@@ -42,16 +54,19 @@ class NarrativeCard extends React.Component {
}
render() {
if (this.props.narrative !== null && this.props.narrative.steps[this.state.step]) {
if (this.props.narrative.steps[this.state.step]) {
const steps = this.props.narrative.steps;
const step = steps[this.state.step];
return (
<div className="narrative-info">
{this.renderClose()}
<h6>{this.props.narrative.label}</h6>
<h3>{this.props.narrative.label}</h3>
<p>{this.props.narrative.description}</p>
<h3>{this.state.step + 1}/{steps.length}. {step.location}</h3>
<h6>
<i className="material-icons left">location_on</i>
{this.state.step + 1}/{steps.length}. {step.location}
</h6>
<div className="actions">
<div className={`${(!this.state.step) ? 'disabled ' : ''} action`} onClick={() => this.goToPrevKeyFrame()}>&larr;</div>
<div className={`${(this.state.step >= this.props.narrative.steps.length - 1) ? 'disabled ' : ''} action`} onClick={() => this.goToNextKeyFrame()}>&rarr;</div>

View File

@@ -1,9 +1,11 @@
import React from 'react';
import { connect } from 'react-redux';
import * as selectors from '../selectors';
import hash from 'object-hash';
import copy from '../js/data/copy.json';
import { formatterWithYear } from '../js/utilities';
import { formatterWithYear, isNotNullNorUndefined } from '../js/utilities';
import TimelineHeader from './presentational/TimelineHeader';
import TimelineLogic from '../js/timeline/timeline.js';
class Timeline extends React.Component {
@@ -15,18 +17,14 @@ 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);
if (hash(nextProps) !== hash(this.props)) {
this.timeline.update(nextProps.domain, nextProps.app);
}
}
onClickArrow() {
@@ -35,34 +33,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 (<div className="timeline-label">{label}</div>);
});
}
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]);
classes += (this.props.app.narrative !== null) ? ' narrative-mode' : '';
return (
<div className={classes}>
<div className="timeline-header">
<div className="timeline-toggle" onClick={() => this.onClickArrow()}>
<p><i className="arrow-down"></i></p>
</div>
<div className="timeline-info">
<p>{info_lang}</p>
<p>{date0} - {date1}</p>
</div>
</div>
<TimelineHeader
title={copy[this.props.app.language].timeline.info}
date0={formatterWithYear(this.props.app.timerange[0])}
date1={formatterWithYear(this.props.app.timerange[1])}
onClick={() => { this.onClickArrow(); }}
/>
<div className="timeline-content">
<div id="timeline" className="timeline" />
<div id={this.props.ui.dom.timeline} className="timeline" />
</div>
</div>
);
@@ -80,9 +63,12 @@ function mapStateToProps(state) {
timerange: selectors.getTimeRange(state),
selected: state.app.selected,
language: state.app.language,
zoomLevels: state.app.zoomLevels
zoomLevels: state.app.zoomLevels,
narrative: state.app.narrative
},
dom: state.ui.dom,
ui: {
dom: state.ui.dom,
}
}
}

View File

@@ -7,6 +7,7 @@ import Search from './Search.jsx';
import TagListPanel from './TagListPanel.jsx';
import ToolbarBottomActions from './ToolbarBottomActions.jsx';
import copy from '../js/data/copy.json';
import { isNotNullNorUndefined, trimAndEllipse } from '../js/utilities.js';
class Toolbar extends React.Component {
@@ -51,21 +52,21 @@ class Toolbar extends React.Component {
this.setState({
tabNum: -1
}, () => {
this.props.actions.updateNarrative(narrative);
this.props.onSelectNarrative(narrative);
});
}
renderToolbarNarrativePanel() {
return (
<TabPanel>
<h2>Focus stories</h2>
<p>Here are some highlighted stories</p>
<h2>{copy[this.props.language].toolbar.narrative_panel_title}</h2>
<p>{copy[this.props.language].toolbar.narrative_summary}</p>
{this.props.narratives.map((narr) => {
return (
<div className="panel-action action">
<button style={{ backgroundColor: '#000' }} onClick={() => { this.goToNarrative(narr); }}>
<p>{narr.label}</p>
<p><small>{narr.description}</small></p>
<p><small>{trimAndEllipse(narr.description, 120)}</small></p>
</button>
</div>
)
@@ -99,6 +100,7 @@ class Toolbar extends React.Component {
return (
<div className={classes} onClick={() => { this.toggleTab(tabNum); }}>
<i className="material-icons">timeline</i>
<div className="tab-caption">{label}</div>
</div>
);
@@ -174,8 +176,9 @@ class Toolbar extends React.Component {
}
render() {
const isNarrative = isNotNullNorUndefined(this.props.narrative);
return (
<div id="toolbar-wrapper" className="toolbar-wrapper">
<div id="toolbar-wrapper" className={`toolbar-wrapper ${(isNarrative) ? 'narrative-mode' : ''}`}>
{this.renderToolbarTabs()}
{this.renderToolbarPanels()}
</div>
@@ -193,6 +196,7 @@ function mapStateToProps(state) {
categoryFilter: state.app.filters.categories,
viewFilters: state.app.filters.views,
features: state.app.features,
narrative: state.app.narrative,
}
}

View File

@@ -1,6 +1,8 @@
import React from 'react'
import { connect } from 'react-redux'
import * as selectors from '../selectors'
import hash from 'object-hash';
import Map from '../js/map/map.js'
import { areEqual } from '../js/utilities.js'
@@ -15,12 +17,15 @@ class Viewport extends React.Component {
}
componentWillReceiveProps(nextProps) {
this.map.update(nextProps.domain, nextProps.app)
if (hash(nextProps) !== hash(this.props)) {
this.map.update(nextProps.domain, nextProps.app)
}
}
render() {
const classes = this.props.app.narrative ? 'map-wrapper narrative-mode' : 'map-wrapper';
return (
<div className='map-wrapper'>
<div className={classes}>
<div id="map" />
</div>
)
@@ -39,7 +44,8 @@ function mapStateToProps(state) {
views: state.app.filters.views,
selected: state.app.selected,
highlighted: state.app.highlighted,
mapAnchor: state.app.mapAnchor
mapAnchor: state.app.mapAnchor,
narrative: state.app.narrative
},
ui: {
dom: state.ui.dom,

View File

@@ -0,0 +1,15 @@
import React from 'react';
const TimelineHeader = ({ title, date0, date1, onClick }) => (
<div className="timeline-header">
<div className="timeline-toggle" onClick={() => onClick()}>
<p><i className="arrow-down"></i></p>
</div>
<div className="timeline-info">
<p>{title}</p>
<p>{date0} - {date1}</p>
</div>
</div>
);
export default TimelineHeader;

View File

@@ -103,7 +103,9 @@
"title": "Directory of tags",
"placeholder": "Search"
}
}
},
"narrative_panel_title": "Focus narratives",
"narrative_summary": "Here you can follow some curated stories we have found in the data."
},
"timeline": {
"zooms": [

View File

@@ -17,6 +17,7 @@ export default function(newApp, ui, methods) {
const app = {
selected: [],
highlighted: null,
narrative: null,
views: Object.assign({}, newApp.views),
}
@@ -56,7 +57,7 @@ export default function(newApp, ui, methods) {
const map = L.map(id)
.setView(center, zoom)
.setMinZoom(10)
.setMaxZoom(18)
.setMaxZoom(19)
.setMaxBounds(maxBoundaries)
// NB: configure tile endpoint
@@ -97,10 +98,25 @@ Stop and start the development process in terminal after you have added your tok
.attr('viewBox', '0 0 6 6')
.attr('refX', 3)
.attr('refY', 3)
.attr('markerWidth', 14)
.attr('markerHeight', 14)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.style('fill', 'red')
.attr('d', 'M0,3v-3l6,3l-6,3z');
svg.insert('defs', 'g')
.append('marker')
.attr('id', 'arrow-off')
.attr('viewBox', '0 0 6 6')
.attr('refX', 3)
.attr('refY', 3)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.style('fill', 'black')
.style('fill-opacity', 0.2)
.attr('d', 'M0,3v-3l6,3l-6,3z');
map.on('zoomstart', () => {
@@ -159,8 +175,8 @@ Stop and start the development process in terminal after you have added your tok
return `translate(${newPoint.x},${newPoint.y})`;
});
g.selectAll('.narrative')
.attr('d', sequenceLine);
svg.selectAll('.narrative')
.each((g, i, nodes) => { return updateNarrativeSteps(g, i, nodes); });
}
lMap.on("zoomend viewreset moveend", updateSVG);
@@ -232,9 +248,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
});
@@ -274,9 +288,8 @@ Stop and start the development process in terminal after you have added your tok
.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})`;
const newPoint = projectPoint([+d.latitude, +d.longitude]);
return `translate(${newPoint.x},${newPoint.y})`;
})
.on('click', (location) => {
methods.onSelect(location.events);
@@ -303,6 +316,9 @@ Stop and start the development process in terminal after you have added your tok
.transition()
.duration(500)
.attr('r', d => (d) ? Math.sqrt(16 * d) + 3 : 0);
eventsDom.selectAll('.location-event-marker')
.style('fill-opacity', '0.1 !important');
}
// NB: is this a function to be removed for future features?
@@ -340,18 +356,11 @@ Stop and start the development process in terminal after you have added your tok
}
}
/*const sequenceLine = d3.line()
.x(d => getCoords(d).x)
.y(d => getCoords(d).y)*/
const sequenceLine = d3.line()
.x(d => getCoords(d).x + getSVGBoundaries().transformX)
.y(d => getCoords(d).y + getSVGBoundaries().transformY);
/**
* Clears existing narrative layer
* Renders all narrativ as paths
* Adds eventlayer to map
*/
/**
* Clears existing narrative layer
* Renders all narrativ as paths
* Adds eventlayer to map
*/
function getNarrativeStyle(narrativeId) {
const styleName = narrativeId && narrativeId in narrativeProps
@@ -360,61 +369,132 @@ Stop and start the development process in terminal after you have added your tok
return narrativeProps[styleName];
}
function getMarker (d) {
if (!d || app.narrative === null) return 'none';
if (d.id === app.narrative.id) return 'url(#arrow)';
return 'url(#arrow-off)';
}
function renderNarratives() {
const narrativesDom = g.selectAll('.narrative')
.data(domain.narratives.map(d => d.steps))
const narrativesDom = svg.selectAll('.narrative')
.data((app.narrative !== null) ? domain.narratives : [])
narrativesDom
.exit()
.remove();
let styleName
narrativesDom
.enter().append('path')
if (app.narrative !== null) {
d3.selectAll('#arrow path')
.style('fill', getNarrativeStyle(app.narrative.id).stroke);
}
const narrativesEnter = narrativesDom
.enter().append('g')
.attr('id', d => 'narrative-' + d.id)
.attr('class', 'narrative')
.attr('d', sequenceLine)
.style('stroke-width', d => {
if (!d[0]) return 0;
// Note: [0] is a non-elegant way to get the narrative id out of the first
// event in the narrative sequence
const styleProps = getNarrativeStyle(d[0].narrative);
return styleProps.strokeWidth;
})
.style('stroke-dasharray', d => {
if (!d[0]) return 'none';
const styleProps = getNarrativeStyle(d[0].narrative);
return (styleProps.style === 'dotted') ? "2px 5px" : 'none';
})
.style('stroke', d => {
if (!d[0]) return 'none';
const styleProps = getNarrativeStyle(d[0].narrative);
return styleProps.stroke;
})
.style('fill', 'none');
narrativesDom.selectAll('.narrative')
.each((g, i, nodes) => { return updateNarrativeSteps(g, i, nodes); });
}
function updateNarrativeSteps(g, i, nodes) {
const n = d3.select(nodes[i]).data()[0];
const allsteps = n.steps.slice();
allsteps.push(n.steps[n.steps.length - 1]);
const steps = d3.select(nodes[i]).selectAll('.narrative-step')
.data(n.steps)
steps.enter().append('line')
.attr('class', 'narrative-step')
.attr('x1', d => getCoords(d).x + getSVGBoundaries().transformX)
.attr('x2', (d, j) => { return getCoords(allsteps[j + 1]).x + getSVGBoundaries().transformX; })
.attr('y1', d => getCoords(d).y + getSVGBoundaries().transformY)
.attr('y2', (d, j) => { return getCoords(allsteps[j + 1]).y + getSVGBoundaries().transformY; })
.style('stroke-width', d => {
if (!d) return 0;
const styleProps = getNarrativeStyle(n.id);
return styleProps.strokeWidth;
})
.style('stroke-dasharray', d => {
if (!d) return 'none';
const styleProps = getNarrativeStyle(n.id);
return (styleProps.style === 'dotted') ? "2px 5px" : 'none';
})
.style('stroke', d => {
if (!d || app.narrative === null) return 'none';
const styleProps = getNarrativeStyle(n.id);
return styleProps.stroke;
})
.style('stroke-opacity', d => {
if (app.narrative === null) return 0;
if (!d || d.id !== app.narrative.id) return 0.2;
return 1;
})
.attr('marker-start', (d, j) => !j ? getMarker(n) : 'none')
.attr('marker-end', getMarker(n))
.attr('mid-marker', getMarker(n))
.on('click', () => methods.onSelectNarrative(n) )
steps
.attr('x1', d => getCoords(d).x + getSVGBoundaries().transformX)
.attr('x2', (d, j) => { return getCoords(allsteps[j + 1]).x + getSVGBoundaries().transformX; })
.attr('y1', d => getCoords(d).y + getSVGBoundaries().transformY)
.attr('y2', (d, j) => { return getCoords(allsteps[j + 1]).y + getSVGBoundaries().transformY; })
.style('stroke-width', d => {
if (!d) return 0;
const styleProps = getNarrativeStyle(n.id);
return styleProps.strokeWidth;
})
.style('stroke-dasharray', d => {
if (!d) return 'none';
const styleProps = getNarrativeStyle(n.id);
return (styleProps.style === 'dotted') ? "2px 5px" : 'none';
})
.style('stroke', d => {
if (!d || app.narrative === null) return 'none';
const styleProps = getNarrativeStyle(n.id);
return styleProps.stroke;
})
.style('stroke-opacity', d => {
if (app.narrative === null) return 0;
if (!d || n.id !== app.narrative.id) return 0.2;
return 1;
})
.attr('marker-start', (d, j) => !j ? getMarker(n) : 'none')
.attr('marker-end', getMarker(n))
.attr('mid-marker', getMarker(n))
steps
.exit()
.remove();
}
/**
* 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();
const isNewDomain = (hash(domain) !== hash(newDomain));
const isNewAppProps = (hash(app) !== hash(newApp));
if (hash(domain) !== hash(newDomain)) {
if (isNewDomain) {
domain.locations = newDomain.locations;
domain.narratives = newDomain.narratives;
domain.categories = newDomain.categories;
domain.sites = newDomain.sites;
renderDomain();
}
if (hash(app) !== hash(newApp)) {
if (isNewAppProps) {
app.views = newApp.views;
app.selected = newApp.selected;
app.highlighted = newApp.highlighted;
app.views = newApp.views;
renderSelectedAndHighlight();
app.mapAnchor = newApp.mapAnchor;
app.narrative = newApp.narrative;
}
if (isNewDomain || isNewAppProps) renderDomain();
if (isNewAppProps) renderSelectedAndHighlight();
}
/**

View File

@@ -9,54 +9,52 @@ 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: [],
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;
// 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;
});
.domain(app.timerange)
.range([margin.left, WIDTH]);
scale.y = d3.scaleOrdinal()
.domain(categories)
.range(groupYs);
/**
* Initilize SVG elements and groups
@@ -69,6 +67,14 @@ export default function(app, ui, methods) {
.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')
@@ -76,10 +82,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 +111,15 @@ 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.body = dom.svg.append("g").attr("clip-path", "url(#clip)");
dom.events = dom.body.append('g');
dom.markers = dom.body.append('g');
/*
* Time Controls
@@ -125,10 +135,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 +163,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 +174,7 @@ export default function(app, ui, methods) {
.getBoundingClientRect().width;
}
/**
* Resize timeline one window resice
*/
@@ -201,8 +184,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 +193,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 +202,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 +210,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 +224,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 +237,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 +247,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 +264,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 +282,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 +310,34 @@ 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);
app.timerange = scale.x.domain();
methods.onUpdateTimerange(scale.x.domain());
});
/**
* Highlight event circle on hover
*/
@@ -325,6 +347,7 @@ export default function(app, ui, methods) {
.classed('mouseover', true);
}
/**
* Unhighlight event when mouse out
*/
@@ -334,11 +357,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 +375,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 +407,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 +443,7 @@ export default function(app, ui, methods) {
.attr('r', 5);
}
/**
* Render axis on timeline and viewbox boundaries
*/
@@ -437,7 +464,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 +480,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 +523,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() {
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) {
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 render() {
renderAxis();
function renderContext() {
renderTimeControls();
renderTimeLabels();
}
function renderContent() {
updateAxis();
renderAxis();
renderEvents();
renderHighlight();
}
function render() {
renderContext();
renderContent();
}
return {
update,
render,
};
}

View File

@@ -44,6 +44,13 @@ export function capitalizeFirstLetter(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
export function trimAndEllipse(string, stringNum) {
if (string.length > stringNum) {
return string.substring(0, 120) + '...'
}
return string;
}
/**
* Return a Date object given a datetime string of the format: "2016-09-10T07:00:00"
* @param {string} datetime

View File

@@ -11,7 +11,7 @@ const eventSchema = Joi.object().keys({
longitude: Joi.string().allow('').required(),
type: Joi.string().allow(''),
category: Joi.string().required(),
narrative: Joi.string().allow(''),
narratives: Joi.array(),
sources: Joi.array(),
tags: Joi.string().allow(''),
comments: Joi.string().allow(''),

View File

@@ -27,14 +27,22 @@
z-index: $hidden;
}
&.show {
z-index: $map;
z-index: $map;
}
&.narrative-mode {
left: 0;
}
.event {
fill: $event_default;
cursor: pointer;
opacity: 0.45;
}
.narrative {
cursor: pointer;
}
.link {
stroke: $midgrey;
fill: none;
@@ -147,7 +155,7 @@
fill: $event_default;
stroke-width: 0;
transition: 0.2s ease;
fill-opacity: 0.8;
/*fill-opacity: 0.8;*/
cursor: pointer;
&:hover {

View File

@@ -4,9 +4,9 @@ NARRATIVE INFO
.narrative-info {
position: fixed;
top: 10px;
left: 130px;
left: 10px;
height: auto;
width: 270px;
width: 370px;
box-sizing: border-box;
padding: 15px;
max-height: calc(100% - 250px);
@@ -23,6 +23,17 @@ NARRATIVE INFO
h3 {
font-size: $large;
font-family: 'Merriweather', 'Georgia', serif;
letter-spacing: 0.1em;
text-transform: uppercase;
font-weight: 100;
}
h6 {
margin: 10px 0;
i {
font-size: $normal;
}
}
p {

View File

@@ -22,6 +22,10 @@
}
}
&.narrative-mode {
left: 0;
}
.timeline-header {
height: 0px;
width: 100%;

View File

@@ -9,6 +9,10 @@
z-index: $header;
background: $midgrey;
&.narrative-mode {
left: -110px;
}
.toolbar {
position: relative;
width: 110px;
@@ -164,6 +168,7 @@
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
height: 60px;
width: 110px;
padding: 5px 0 5px 0;
@@ -292,37 +297,6 @@
}
}
.people-tab {
width: 50%;
font-family: 'Lato', Helvetica, sans-serif;
font-size: $normal;
text-transform: uppercase;
letter-spacing: 0.1em;
svg {
transform: translate(-2px,0)scale(0.6);
&:hover {
transition: 0.2s ease;
stroke: $offwhite;
}
}
&.react-tabs__tab--selected {
svg circle,
svg path {
stroke: $offwhite;
}
}
svg circle,
svg path {
transition: 0.2s ease;
fill: none;
stroke: $midwhite;
stroke-width: 3;
}
}
.react-tabs__tab-list {
height: 40px;
overflow: hidden;
@@ -362,6 +336,14 @@
height: 0;
margin: 0;
}
.panel-header {
visibility: hidden;
.caret {
transform: translate(8px, 5px)rotate(225deg);
}
}
}
input {
@@ -473,7 +455,7 @@
height: 140px;
line-height: 140px;
width: 100%;
padding: 0;
padding: 10px;
border: 1px solid $offwhite;
background-size: 100%;
color: $offwhite;
@@ -489,9 +471,13 @@
transition: 0.2s ease;
letter-spacing: 0.15em;
}
p {
text-transform: none;
}
}
&:first-child {
/*&:first-child {
button { background-image: url("/static/archive/img/scene01.jpg"); }
}
&:nth-child(2n) {
@@ -503,114 +489,7 @@
&.back-to-map {
button { background-image: url("/static/archive/img/map.jpg"); }
}
}
}
.taggroup-wrapper {
margin-top: 30px;
z-index: 10;
border-bottom: none;
&:last-child {
margin-bottom: 0;
border-bottom: 1px solid rgba(white, 0);
}
&:hover {
transition: 0.1s ease;
}
.collapsible-item {
width: calc(100% - 32px);
float: left;
}
.taggroup-header {
width: 100%;
margin: 0;
font-size: $large;
h2::first-letter {
margin-top: 0;
}
}
.taggroup-content {
width: 100%;
display: inline-block;
padding-left: 10px;
box-sizing: border-box;
transition: 0.2s ease;
.tagsubgroup-wrapper {
border: none;
border-bottom: 1px solid rgba(white, 0.25);
&:first-letter {
text-transform: uppercase;
}
&:last-child {
border-bottom: 0;
}
.tagsubgroup-header {
cursor: pointer;
}
&.folded {
.tagsubgroup-content {
overflow: hidden;
padding: 0 10px;
transition: 0.2s ease;
height: 0;
border-top: 0;
}
}
.item {
overflow: auto;
min-height: 32px;
height: auto;
span {
height: auto;
}
}
}
.tag-filter {
outline: none;
border: 0;
background: none;
color: $offwhite;
margin-left: 20px;
width: calc(100% - 20px);
box-sizing: border-box;
padding: 0;
font-size: $normal;
font-weight: 400;
text-align: left;
cursor: pointer;
border: 1px solid $black;
border-bottom: 1px solid rgba(white, 0.25);
&:first-letter {
text-transform: uppercase;
}
&:last-child {
border-bottom: 1px solid rgba(white, 0);
}
}
}
&.folded {
.filter-list-content {
padding: 0 10px;
border-top: 0;
transition: 0.2s ease;
height: 0;
}
}*/
}
}
@@ -641,10 +520,10 @@
height: 60px;
padding: 0;
.tab-caption {
/*.tab-caption {
transition: 0.2s ease;
opacity: 0;
}
}*/
&:hover {
.tab-caption {

View File

@@ -95,16 +95,18 @@ export const selectNarratives = createSelector(
events.forEach((evt) => {
const isTagged = isTaggedIn(evt, tagFilters) || isNoTags(tagFilters);
const isTimeRanged = isTimeRangedIn(evt, timeRange);
const isInNarrative = evt.narrative;
const isInNarrative = evt.narratives.length > 0;
if (!narratives[evt.narrative]) {
narratives[evt.narrative] = { id: evt.narrative, steps: [], byId: {} };
}
evt.narratives.map(narrative => {
if (!narratives[narrative]) {
narratives[narrative] = { id: narrative, steps: [], byId: {} };
}
if (/*isTimeRanged && isTagged && */isInNarrative) {
narratives[evt.narrative].steps.push(evt);
narratives[evt.narrative].byId[evt.id] = { next: null, prev: null };
}
if (isInNarrative) {
narratives[narrative].steps.push(evt);
narratives[narrative].byId[evt.id] = { next: null, prev: null };
}
})
});
Object.keys(narratives).forEach((key) => {

View File

@@ -116,10 +116,16 @@ const initial = {
narratives: {
default: {
style: 'solid', // ['dotted', 'solid']
opacity: 0.5, // range between 0 and 1
stroke: 'transparent', // Any hex or rgb code
strokeWidth: 2
opacity: 0.9, // range between 0 and 1
stroke: 'red', // Any hex or rgb code
strokeWidth: 3
},
narrative_1: {
style: 'solid', // ['dotted', 'solid']
opacity: 0.4, // range between 0 and 1
stroke: '#f18f01', // Any hex or rgb code
strokeWidth: 3
}
}
},
dom: {