mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-11 12:58:35 +03:00
Merge pull request #52 from forensic-architecture/topic/narrative-mode
Implement focused narrative mode
This commit is contained in:
@@ -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)
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}>←</div>
|
||||
<div className={`${(this.state.step >= this.props.narrative.steps.length - 1) ? 'disabled ' : ''} action`} onClick={() => this.goToNextKeyFrame()}>→</div>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
15
src/components/presentational/TimelineHeader.js
Normal file
15
src/components/presentational/TimelineHeader.js
Normal 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;
|
||||
@@ -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": [
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(''),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.narrative-mode {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
height: 0px;
|
||||
width: 100%;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user