mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-11 21:08:36 +03:00
Merge pull request #39 from forensic-architecture/topic/guided-narratives
Topic/guided narratives
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
build/
|
||||
node_modules/
|
||||
config.js
|
||||
dev.config.js
|
||||
|
||||
@@ -4,6 +4,7 @@ module.exports = {
|
||||
EVENT_EXT: '/api/example/export_events/rows',
|
||||
CATEGORY_EXT: '/api/example/export_categories/rows',
|
||||
SOURCES_EXT: '/api/example/export_events/ids',
|
||||
NARRATIVE_EXT: '/api/example/export_narratives/rows',
|
||||
TAGS_EXT: '/api/example/export_tags/tree',
|
||||
SITES_EXT: '/api/example/export_sites/rows',
|
||||
MAP_ANCHOR: [31.356397, 34.784818],
|
||||
|
||||
@@ -8,12 +8,13 @@ function urlFromEnv(ext) {
|
||||
}
|
||||
|
||||
// TODO: relegate these URLs entirely to environment variables
|
||||
const EVENT_DATA_URL = urlFromEnv('EVENT_EXT')
|
||||
const CATEGORY_URL = urlFromEnv('CATEGORY_EXT')
|
||||
const TAG_URL = urlFromEnv('TAGS_EXT')
|
||||
const SOURCES_URL = urlFromEnv('SOURCES_EXT')
|
||||
const SITES_URL = urlFromEnv('SITES_EXT')
|
||||
const eventUrlMap = (event) => `${process.env.SERVER_ROOT}${process.env.EVENT_DESC_ROOT}/${(event.id) ? event.id : event}`
|
||||
const EVENT_DATA_URL = urlFromEnv('EVENT_EXT');
|
||||
const CATEGORY_URL = urlFromEnv('CATEGORY_EXT');
|
||||
const TAG_URL = urlFromEnv('TAGS_EXT');
|
||||
const SOURCES_URL = urlFromEnv('SOURCES_EXT');
|
||||
const NARRATIVE_URL = urlFromEnv('NARRATIVE_EXT');
|
||||
const SITES_URL = urlFromEnv('SITES_EXT');
|
||||
const eventUrlMap = (event) => `${process.env.SERVER_ROOT}${process.env.EVENT_DESC_ROOT}/${(event.id) ? event.id : event}`;
|
||||
|
||||
/*
|
||||
* Create an error notification object
|
||||
@@ -52,6 +53,10 @@ export function fetchDomain () {
|
||||
.then(response => response.json())
|
||||
.catch(handleError('categories'))
|
||||
|
||||
const narPromise = fetch(NARRATIVE_URL)
|
||||
.then(response => response.json())
|
||||
.catch(handleError('narratives'))
|
||||
|
||||
let sitesPromise = Promise.resolve([])
|
||||
if (process.env.features.USE_SITES) {
|
||||
sitesPromise = fetch(SITES_URL)
|
||||
@@ -66,14 +71,16 @@ export function fetchDomain () {
|
||||
.catch(handleError('tags'))
|
||||
}
|
||||
|
||||
return Promise.all([ eventPromise, catPromise, sitesPromise, tagsPromise])
|
||||
return Promise.all([eventPromise, catPromise, narPromise,
|
||||
sitesPromise, tagsPromise])
|
||||
.then(response => {
|
||||
dispatch(toggleFetchingDomain())
|
||||
const result = {
|
||||
events: response[0],
|
||||
categories: response[1],
|
||||
sites: response[2],
|
||||
tags: response[3],
|
||||
narratives: response[2],
|
||||
sites: response[3],
|
||||
tags: response[4],
|
||||
notifications
|
||||
}
|
||||
return result
|
||||
@@ -102,6 +109,7 @@ export function updateDomain(domain) {
|
||||
categories: domain.categories,
|
||||
tags: domain.tags,
|
||||
sites: domain.sites,
|
||||
narratives: domain.narratives,
|
||||
notifications: domain.notifications
|
||||
}
|
||||
}
|
||||
@@ -156,6 +164,14 @@ export function updateTagFilters(tag) {
|
||||
}
|
||||
}
|
||||
|
||||
export const UPDATE_NARRATIVE = 'UPDATE_NARRATIVE';
|
||||
export function updateNarrative(narrative) {
|
||||
return {
|
||||
type: UPDATE_NARRATIVE,
|
||||
narrative
|
||||
}
|
||||
}
|
||||
|
||||
export const UPDATE_TIMERANGE = 'UPDATE_TIMERANGE';
|
||||
export function updateTimeRange(timerange) {
|
||||
return {
|
||||
@@ -209,6 +225,14 @@ export function toggleInfoPopup() {
|
||||
}
|
||||
}
|
||||
|
||||
export const TOGGLE_MAPVIEW = 'TOGGLE_MAPVIEW';
|
||||
export function toggleMapView(layer) {
|
||||
return {
|
||||
type: TOGGLE_MAPVIEW,
|
||||
layer
|
||||
}
|
||||
}
|
||||
|
||||
export const TOGGLE_NOTIFICATIONS = 'TOGGLE_NOTIFICATIONS'
|
||||
export function toggleNotifications() {
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import copy from '../js/data/copy.json';
|
||||
import {isNotNullNorUndefined} from '../js/data/utilities';
|
||||
import {
|
||||
isNotNullNorUndefined,
|
||||
parseDate,
|
||||
formatterWithYear
|
||||
} from '../js/utilities';
|
||||
import React from 'react';
|
||||
|
||||
import Spinner from './presentational/Spinner';
|
||||
@@ -35,8 +39,8 @@ class Card extends React.Component {
|
||||
|
||||
makeTimelabel(timestamp) {
|
||||
if (timestamp === null) return null;
|
||||
const parsedTimestamp = this.props.tools.parser(timestamp);
|
||||
const timelabel = this.props.tools.formatterWithYear(parsedTimestamp);
|
||||
const parsedTimestamp = parseDate(timestamp);
|
||||
const timelabel = formatterWithYear(parsedTimestamp);
|
||||
return timelabel;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import Card from './Card.jsx';
|
||||
import copy from '../js/data/copy.json';
|
||||
import {
|
||||
isNotNullNorUndefined
|
||||
} from '../js/data/utilities.js';
|
||||
} from '../js/utilities.js';
|
||||
|
||||
class CardStack extends React.Component {
|
||||
|
||||
@@ -21,7 +21,7 @@ class CardStack extends React.Component {
|
||||
<Card
|
||||
event={event}
|
||||
language={this.props.language}
|
||||
tools={this.props.tools}
|
||||
isLoading={this.props.isLoading}
|
||||
getNarrativeLinks={this.props.getNarrativeLinks}
|
||||
getCategoryGroup={this.props.getCategoryGroup}
|
||||
getCategoryColor={this.props.getCategoryColor}
|
||||
@@ -90,9 +90,8 @@ function mapStateToProps(state) {
|
||||
return {
|
||||
selected: state.app.selected,
|
||||
language: state.app.language,
|
||||
tools: state.ui.tools,
|
||||
isCardstack: state.ui.flags.isCardstack,
|
||||
isFetchingSources: state.ui.flags.isFetchingSources
|
||||
isCardstack: state.app.flags.isCardstack,
|
||||
isLoading: state.app.flags.isFetchingEvents
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,13 @@ import LoadingOverlay from './presentational/LoadingOverlay';
|
||||
import Viewport from './Viewport.jsx';
|
||||
import Toolbar from './Toolbar.jsx';
|
||||
import CardStack from './CardStack.jsx';
|
||||
import NarrativeCard from './NarrativeCard.js';
|
||||
import InfoPopUp from './InfoPopup.jsx';
|
||||
import Timeline from './Timeline.jsx';
|
||||
import Notification from './Notification.jsx';
|
||||
|
||||
import { parseDate } from '../js/utilities';
|
||||
|
||||
class Dashboard extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -23,7 +26,7 @@ class Dashboard extends React.Component {
|
||||
this.handleTagFilter = this.handleTagFilter.bind(this);
|
||||
this.updateTimerange = this.updateTimerange.bind(this);
|
||||
|
||||
this.eventsById = {};
|
||||
this.eventsById = {}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -46,9 +49,7 @@ class Dashboard extends React.Component {
|
||||
handleSelect(selected) {
|
||||
if (selected) {
|
||||
let eventsToSelect = selected.map(event => this.getEventById(event.id));
|
||||
const p = this.props.ui.tools.parser;
|
||||
|
||||
eventsToSelect = eventsToSelect.sort((a, b) => p(a.timestamp) - p(b.timestamp))
|
||||
eventsToSelect = eventsToSelect.sort((a, b) => parseDate(a.timestamp) - parseDate(b.timestamp))
|
||||
|
||||
this.props.actions.fetchSelected(eventsToSelect)
|
||||
}
|
||||
@@ -67,7 +68,7 @@ class Dashboard extends React.Component {
|
||||
}
|
||||
|
||||
getNarrativeLinks(event) {
|
||||
const narrative = this.props.domain.narratives.find(nv => nv.key === event.narrative);
|
||||
const narrative = this.props.domain.narratives.find(nv => nv.id === event.narrative);
|
||||
if (narrative) return narrative.byId[event.id];
|
||||
return null;
|
||||
}
|
||||
@@ -104,13 +105,17 @@ class Dashboard extends React.Component {
|
||||
app={this.props.app}
|
||||
toggle={() => this.props.actions.toggleInfoPopup()}
|
||||
/>
|
||||
<NarrativeCard
|
||||
onSelect={this.handleSelect}
|
||||
actions={this.props.actions}
|
||||
/>
|
||||
<Notification
|
||||
isNotification={this.props.ui.flags.isNotification}
|
||||
isNotification={this.props.app.flags.isNotification}
|
||||
notifications={this.props.domain.notifications}
|
||||
onToggle={this.props.actions.markNotificationsRead}
|
||||
/>
|
||||
<LoadingOverlay
|
||||
ui={this.props.ui.flags.isFetchingDomain}
|
||||
ui={this.props.app.flags.isFetchingDomain}
|
||||
language={this.props.app.language}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@ export default class InfoPopUp extends React.Component{
|
||||
|
||||
renderView2DLegend() {
|
||||
return (
|
||||
<div className={`infopopup ${(this.props.ui.flags.isInfopopup) ? '' : 'hidden'}`}>
|
||||
<div className={`infopopup ${(this.props.app.flags.isInfopopup) ? '' : 'hidden'}`}>
|
||||
<button onClick={() => this.props.toggle()} className="side-menu-burg over-white is-active"><span /></button>
|
||||
{this.renderView2DCopy()}
|
||||
<div className="legend">
|
||||
|
||||
71
src/components/NarrativeCard.js
Normal file
71
src/components/NarrativeCard.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
class NarrativeCard extends React.Component {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.state = {
|
||||
step: 0
|
||||
}
|
||||
}
|
||||
|
||||
goToPrevKeyFrame() {
|
||||
if (this.state.step > 0) {
|
||||
this.setState({ step: this.state.step - 1 });
|
||||
}
|
||||
}
|
||||
|
||||
goToNextKeyFrame() {
|
||||
if (this.state.step < this.props.narrative.steps.length - 1) {
|
||||
this.setState({ step: this.state.step + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.props.narrative !== null) {
|
||||
const step = this.props.narrative.steps[this.state.step];
|
||||
this.props.onSelect([step]);
|
||||
}
|
||||
}
|
||||
|
||||
renderClose() {
|
||||
return (
|
||||
<button
|
||||
className="side-menu-burg is-active"
|
||||
onClick={() => { this.props.actions.updateNarrative(null); }}
|
||||
>
|
||||
<span></span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.narrative !== null && 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>
|
||||
<p>{this.props.narrative.description}</p>
|
||||
<h3>{this.state.step + 1}/{steps.length}. {step.location}</h3>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (<div/>);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
narrative: state.app.narrative
|
||||
}
|
||||
}
|
||||
export default connect(mapStateToProps)(NarrativeCard);
|
||||
@@ -28,12 +28,12 @@ export default class Notification extends React.Component{
|
||||
}
|
||||
|
||||
renderNotificationContent(notification) {
|
||||
const { type, message, items } = notification;
|
||||
let { type, message, items } = notification;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`message ${type}`}>
|
||||
{`${message}`}
|
||||
{message}
|
||||
</div>
|
||||
<div className={`details ${this.state.isExtended}`}>
|
||||
{(items !== null) ? this.renderItems(items) : ''}
|
||||
@@ -48,7 +48,6 @@ export default class Notification extends React.Component{
|
||||
return (
|
||||
<div className={`notification-wrapper`}>
|
||||
{this.props.notifications.map((notification) => {
|
||||
|
||||
return (
|
||||
<div className='notification' onClick={() => this.toggleDetails() }>
|
||||
<button
|
||||
|
||||
@@ -13,11 +13,11 @@ class TagListPanel extends React.Component {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.computeTree(this.props.tags.children[this.props.tagType]);
|
||||
this.computeTree(this.props.tags);//.children[this.props.tagType]);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
this.computeTree(nextProps.tags.children[nextProps.tagType]);
|
||||
this.computeTree(nextProps.tags);//.children[nextProps.tagType]);
|
||||
}
|
||||
|
||||
onClickCheckbox(tag) {
|
||||
@@ -65,8 +65,11 @@ class TagListPanel extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
return (
|
||||
<div className="react-innertabpanel">
|
||||
<h2>Explore data by tag</h2>
|
||||
<p>Explore freely all the data by selecting tags.</p>
|
||||
{this.renderTree()}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { connect } from 'react-redux';
|
||||
import * as selectors from '../selectors';
|
||||
|
||||
import copy from '../js/data/copy.json';
|
||||
import { formatterWithYear } from '../js/utilities';
|
||||
import TimelineLogic from '../js/timeline/timeline.js';
|
||||
|
||||
class Timeline extends React.Component {
|
||||
@@ -15,7 +16,6 @@ class Timeline extends React.Component {
|
||||
|
||||
componentDidMount() {
|
||||
const ui = {
|
||||
tools: this.props.tools,
|
||||
dom: this.props.dom
|
||||
}
|
||||
|
||||
@@ -47,8 +47,8 @@ class Timeline extends React.Component {
|
||||
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 = this.props.tools.formatterWithYear(this.props.app.timerange[0]);
|
||||
const date1 = this.props.tools.formatterWithYear(this.props.app.timerange[1]);
|
||||
const date0 = formatterWithYear(this.props.app.timerange[0]);
|
||||
const date1 = formatterWithYear(this.props.app.timerange[1]);
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
@@ -82,9 +82,8 @@ function mapStateToProps(state) {
|
||||
language: state.app.language,
|
||||
zoomLevels: state.app.zoomLevels
|
||||
},
|
||||
tools: state.ui.tools,
|
||||
dom: state.ui.dom,
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(Timeline);
|
||||
export default connect(mapStateToProps)(Timeline);
|
||||
|
||||
@@ -5,116 +5,24 @@ import * as selectors from '../selectors'
|
||||
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
|
||||
import Search from './Search.jsx';
|
||||
import TagListPanel from './TagListPanel.jsx';
|
||||
import Icon from './Icon.jsx';
|
||||
import ToolbarBottomActions from './ToolbarBottomActions.jsx';
|
||||
import copy from '../js/data/copy.json';
|
||||
// NB: i think this entire component can actually be part of a future feature...
|
||||
|
||||
class Toolbar extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
tab: -1
|
||||
};
|
||||
}
|
||||
this.state = {
|
||||
tabNum: -1
|
||||
};
|
||||
}
|
||||
|
||||
toggleTab(tabIndex) {
|
||||
if ( this.state.tab === tabIndex ) {
|
||||
this.setState({ tab: -1 });
|
||||
} else {
|
||||
this.setState({ tab: tabIndex });
|
||||
}
|
||||
}
|
||||
toggleTab(tabNum) {
|
||||
this.setState({ tabNum: (this.state.tabNum === tabNum) ? -1 : tabNum });
|
||||
}
|
||||
|
||||
resetAllFilters() {
|
||||
this.props.actions.resetAllFilters();
|
||||
}
|
||||
|
||||
toggleInfoPopup() {
|
||||
this.props.actions.toggleInfoPopup();
|
||||
}
|
||||
|
||||
toggleLanguage() {
|
||||
this.props.actions.toggleLanguage();
|
||||
}
|
||||
|
||||
toggleMapViews(layer) {
|
||||
const isLayerInView = !this.props.viewFilters[layer];
|
||||
const newViews = {};
|
||||
newViews[layer] = isLayerInView;
|
||||
const views = Object.assign({}, this.props.viewFilters, newViews);
|
||||
this.props.actions.updateFilters({ views });
|
||||
}
|
||||
|
||||
renderMapActions() {
|
||||
const isViewLayer = this.props.viewFilters;
|
||||
const routeClass = (isViewLayer.routes) ? 'action-button active disabled' : 'action-button disabled'
|
||||
const sitesClass = (isViewLayer.sites) ? 'action-button active disabled' : 'action-button disabled';
|
||||
const coeventsClass = (isViewLayer.coevents) ? 'action-button active disabled' : 'action-button disabled';
|
||||
|
||||
return (
|
||||
<div className="bottom-action-block">
|
||||
<button
|
||||
className={routeClass}
|
||||
onClick={() => this.toggleMapViews('routes')}
|
||||
>
|
||||
<svg x="0px" y="0px" width="30px" height="20px" viewBox="0 0 30 20" enableBackground="new 0 0 30 20">
|
||||
<path d="M0.806,13.646h7.619c2.762,0,3-0.238,3-3v-0.414c0-2.762,0.301-3,3.246-3h14.523"/>
|
||||
<polyline points="16.671,9.228 19.103,7.233 16.671,5.237 "/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={sitesClass}
|
||||
onClick={() => this.toggleMapViews('sites')}
|
||||
>
|
||||
<svg x="0px" y="0px" width="30px" height="20px" viewBox="0 0 30 20" enableBackground="new 0 0 30 20">
|
||||
<path d="M24.615,6.793H5.385c-2.761,0-3,0.239-3,3v0.414
|
||||
c0,2.762,0.239,3,3,3h7.621l1.996,2.432l1.996-2.432h7.618c2.762,0,3-0.238,3-3V9.793C27.615,7.032,27.377,6.793,24.615,6.793z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={coeventsClass}
|
||||
onClick={() => this.toggleMapViews('coevents')}
|
||||
>
|
||||
<svg className="coevents" x="0px" y="0px" width="30px" height="20px" viewBox="0 0 30 20" enableBackground="new 0 0 30 20">
|
||||
<polygon stroke-linejoin="round" stroke-miterlimit="10" points="19.178,20 10.823,20 10.473,14.081
|
||||
10,13.396 10,6.084 20,6.084 20,13.396 19.445,14.021 "/>
|
||||
<rect className="no-fill" x="11.4" y="7.867" width="7.2" height="3.35"/>
|
||||
<line stroke-linejoin="round" stroke-miterlimit="10" x1="12.125" y1="1" x2="12.125" y2="5.35"/>
|
||||
<rect x="11.4" y="4.271" width="1.496" height="1.079"/>
|
||||
<rect x="17.104" y="4.271" width="1.496" height="1.079"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderBottomActions() {
|
||||
return (
|
||||
<div className="bottom-actions">
|
||||
{this.renderMapActions()}
|
||||
<div className="bottom-action-block">
|
||||
<button className="action-button tiny default" onClick={() => { /*this.toggleLanguage()*/}}>
|
||||
{(this.props.language === 'es-MX') ? 'ES' : 'EN' }
|
||||
</button>
|
||||
<button className="action-button info tiny default" onClick={() => {/*this.toggleInfoPopup()*/}}>
|
||||
i
|
||||
</button>
|
||||
<button className="action-button tiny" onClick={() => this.resetAllFilters()}>
|
||||
<svg className="reset" x="0px" y="0px" width="25px" height="25px" viewBox="7.5 7.5 25 25" enableBackground="new 7.5 7.5 25 25">
|
||||
<path stroke-width="2" stroke-miterlimit="10" d="M28.822,16.386c1.354,3.219,0.898,7.064-1.5,9.924
|
||||
c-3.419,4.073-9.49,4.604-13.562,1.186c-4.073-3.417-4.604-9.49-1.187-13.562c1.987-2.368,4.874-3.54,7.74-3.433" />
|
||||
<polygon points="26.137,12.748 27.621,19.464 28.9,16.741 31.898,16.503" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
renderPanelHeader() {
|
||||
renderClosePanel() {
|
||||
return (
|
||||
<div className="panel-header" onClick={() => this.toggleTab(-1)}>
|
||||
<div className="caret"></div>
|
||||
@@ -122,107 +30,118 @@ class Toolbar extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
renderToolbarTab(tabNum, key) {
|
||||
const isActive = (tabNum === this.state.tab);
|
||||
//let caption_lang = copy[this.props.language].toolbar.tabs[tabNum];
|
||||
let classes = (isActive) ? 'toolbar-tab active' : 'toolbar-tab';
|
||||
return (
|
||||
<div className={classes} onClick={() => { this.toggleTab(tabNum); }}>
|
||||
{/*<Icon iconType={key} />*/}
|
||||
<div className="tab-caption">{key}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderToolbarTagRoot() {
|
||||
if (this.props.features.USE_TAGS &&
|
||||
this.props.tags.children) {
|
||||
const roots = Object.values(this.props.tags.children);
|
||||
return roots.map((root, idx) => {
|
||||
return this.renderToolbarTab(idx, root.key);
|
||||
})
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
renderToolbarTabs() {
|
||||
const title = copy[this.props.language].toolbar.title;
|
||||
return (
|
||||
<div className="toolbar">
|
||||
<div className="toolbar-header"><p>{title}</p></div>
|
||||
<div className="toolbar-tabs">
|
||||
{/*this.renderToolbarTab(0, 'search')*/}
|
||||
{this.renderToolbarTagRoot()}
|
||||
</div>
|
||||
{/* {this.renderBottomActions()} */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderTagListPanel(tagType) {
|
||||
const panels_lang = copy[this.props.language].toolbar.panels;
|
||||
const title = (panels_lang[tagType]) ? panels_lang[tagType].title : tagType;
|
||||
const overview = (panels_lang[tagType]) ? panels_lang[tagType].overview : '';
|
||||
|
||||
return (
|
||||
<TagListPanel
|
||||
tags={this.props.tags}
|
||||
categories={this.props.categories}
|
||||
tagFilters={this.props.tagFilters}
|
||||
categoryFilters={this.props.categoryFilters}
|
||||
filter={this.props.onFilter}
|
||||
title={title}
|
||||
overview={overview}
|
||||
language={this.props.language}
|
||||
tagType={tagType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderSearch() {
|
||||
if (this.props.features.USE_SEARCH) {
|
||||
return (
|
||||
<TabPanel>
|
||||
<Search
|
||||
language={this.props.language}
|
||||
tags={this.props.tags}
|
||||
categories={this.props.categories}
|
||||
tagFilters={this.props.tagFilters}
|
||||
categoryFilters={this.props.categoryFilters}
|
||||
filter={this.props.onFilter}
|
||||
/>
|
||||
<Search
|
||||
language={this.props.language}
|
||||
tags={this.props.tags}
|
||||
categories={this.props.categories}
|
||||
tagFilters={this.props.tagFilters}
|
||||
categoryFilters={this.props.categoryFilters}
|
||||
filter={this.props.filter}
|
||||
/>
|
||||
</TabPanel>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
renderToolbarTagList() {
|
||||
goToNarrative(narrative) {
|
||||
this.setState({
|
||||
tabNum: -1
|
||||
}, () => {
|
||||
this.props.actions.updateNarrative(narrative);
|
||||
});
|
||||
}
|
||||
|
||||
renderToolbarNarrativePanel() {
|
||||
return (
|
||||
<TabPanel>
|
||||
<h2>Focus stories</h2>
|
||||
<p>Here are some highlighted stories</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>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</TabPanel>
|
||||
);
|
||||
}
|
||||
|
||||
renderToolbarTagPanel() {
|
||||
if (this.props.features.USE_TAGS &&
|
||||
this.props.tags.children) {
|
||||
const roots = Object.values(this.props.tags.children);
|
||||
return roots.map((root, idx) => {
|
||||
return (
|
||||
<TabPanel>
|
||||
{this.renderTagListPanel(root.key)}
|
||||
</TabPanel>
|
||||
)
|
||||
})
|
||||
return (
|
||||
<TabPanel>
|
||||
<TagListPanel
|
||||
tags={this.props.tags}
|
||||
categories={this.props.categories}
|
||||
tagFilters={this.props.tagFilters}
|
||||
categoryFilters={this.props.categoryFilters}
|
||||
filter={this.props.filter}
|
||||
language={this.props.language}
|
||||
/>
|
||||
</TabPanel>
|
||||
)
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
render() {
|
||||
let classes = (this.state.tab !== -1) ? 'toolbar-panels' : 'toolbar-panels folded';
|
||||
renderToolbarTab(tabNum, label) {
|
||||
const isActive = (this.state.tabNum === tabNum);
|
||||
let classes = (isActive) ? 'toolbar-tab active' : 'toolbar-tab';
|
||||
|
||||
return (
|
||||
<div className={classes} onClick={() => { this.toggleTab(tabNum); }}>
|
||||
<div className="tab-caption">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderToolbarTabs() {
|
||||
const title = copy[this.props.language].toolbar.title;
|
||||
const isTags = this.props.tags && (this.props.tags.children > 0);
|
||||
|
||||
return (
|
||||
<div className="toolbar">
|
||||
<div className="toolbar-header"><p>{title}</p></div>
|
||||
<div className="toolbar-tabs">
|
||||
{/*this.renderToolbarTab(0, 'search')*/}
|
||||
{this.renderToolbarTab(0, 'Narratives')}
|
||||
{(isTags) ? this.renderToolbarTab(1, 'Explore by tag') : ''}
|
||||
</div>
|
||||
<ToolbarBottomActions
|
||||
actions={this.props.actions}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderToolbarPanels() {
|
||||
let classes = (this.state.tabNum !== -1) ? 'toolbar-panels' : 'toolbar-panels folded';
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
{this.renderClosePanel()}
|
||||
<Tabs selectedIndex={this.state.tabNum}>
|
||||
{this.renderToolbarNarrativePanel()}
|
||||
{this.renderToolbarTagPanel()}
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id="toolbar-wrapper" className="toolbar-wrapper">
|
||||
{this.renderToolbarTabs()}
|
||||
<div className={classes}>
|
||||
{this.renderPanelHeader()}
|
||||
<Tabs selectedIndex={this.state.tab}>
|
||||
{this.renderToolbarTagList()}
|
||||
</Tabs>
|
||||
</div>
|
||||
{this.renderToolbarPanels()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -232,11 +151,12 @@ function mapStateToProps(state) {
|
||||
return {
|
||||
tags: selectors.getTagTree(state),
|
||||
categories: selectors.selectCategories(state),
|
||||
narratives: selectors.selectNarratives(state),
|
||||
language: state.app.language,
|
||||
tagFilters: selectors.selectTagList(state),
|
||||
categoryFilter: state.app.filters.categories,
|
||||
viewFilters: state.app.filters.views,
|
||||
features: state.app.features
|
||||
features: state.app.features,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
64
src/components/ToolbarBottomActions.jsx
Normal file
64
src/components/ToolbarBottomActions.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
|
||||
import SitesIcon from './presentational/Icons/SitesIcon.js';
|
||||
import RefreshIcon from './presentational/Icons/RefreshIcon.js';
|
||||
import CoeventIcon from './presentational/Icons/CoeventIcon.js';
|
||||
import RouteIcon from './presentational/Icons/RouteIcon.js';
|
||||
|
||||
class ToolbarBottomActions extends React.Component {
|
||||
resetAllFilters() {
|
||||
this.props.actions.resetAllFilters();
|
||||
}
|
||||
|
||||
toggleInfoPopup() {
|
||||
this.props.actions.toggleInfoPopup();
|
||||
}
|
||||
|
||||
toggleLanguage() {
|
||||
this.props.actions.toggleLanguage();
|
||||
}
|
||||
|
||||
toggleMapViews(layer) {
|
||||
this.props.actions.toggleMapView(layer);
|
||||
}
|
||||
|
||||
renderMapActions() {
|
||||
return (
|
||||
<div className="bottom-action-block">
|
||||
<RouteIcon
|
||||
onClick={(view) => this.toggleMapViews(view)}
|
||||
isEnabled={this.props.viewFilters.routes}
|
||||
/>
|
||||
<SitesIcon
|
||||
onClick={(view) => this.toggleMapViews(view)}
|
||||
isEnabled={this.props.viewFilters.sites}
|
||||
/>
|
||||
<CoeventIcon
|
||||
onClick={(view) => this.toggleMapViews(view)}
|
||||
isEnabled={this.props.viewFilters.coevents}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="bottom-actions">
|
||||
{/*}{this.renderMapActions()}
|
||||
<div className="bottom-action-block">
|
||||
<button className="action-button tiny default" onClick={() => { this.toggleLanguage()}}>
|
||||
{(this.props.language === 'es-MX') ? 'ES' : 'EN' }
|
||||
</button>
|
||||
<button className="action-button info tiny default" onClick={() => { this.toggleInfoPopup()}}>
|
||||
i
|
||||
</button>
|
||||
<button className="action-button tiny" onClick={() => this.resetAllFilters()}>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
</div>*/}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ToolbarBottomActions;
|
||||
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import * as selectors from '../selectors'
|
||||
import Map from '../js/map/map.js'
|
||||
import { areEqual } from '../js/data/utilities.js'
|
||||
import { areEqual } from '../js/utilities.js'
|
||||
|
||||
class Viewport extends React.Component {
|
||||
constructor(props) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import copy from '../../js/data/copy.json';
|
||||
import {isNotNullNorUndefined} from '../../js/data/utilities';
|
||||
import { isNotNullNorUndefined } from '../../js/utilities';
|
||||
|
||||
const CardLocation = ({ language, location }) => {
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import copy from '../../js/data/copy.json';
|
||||
import {isNotNullNorUndefined} from '../../js/data/utilities';
|
||||
import { isNotNullNorUndefined } from '../../js/utilities';
|
||||
|
||||
const CardTimestamp = ({ makeTimelabel, language, timestamp }) => {
|
||||
|
||||
|
||||
24
src/components/presentational/Icons/CoeventIcon.js
Normal file
24
src/components/presentational/Icons/CoeventIcon.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
const CoeventIcon = ({ isEnabled, toggleMapViews }) => {
|
||||
|
||||
const classes = (isEnabled) ? 'action-button active disabled' : 'action-button disabled';
|
||||
|
||||
return (
|
||||
<button
|
||||
className={sitesClass}
|
||||
onClick={() => toggleMapViews('coevents')}
|
||||
>
|
||||
<svg className="coevents" x="0px" y="0px" width="30px" height="20px" viewBox="0 0 30 20" enableBackground="new 0 0 30 20">
|
||||
<polygon stroke-linejoin="round" stroke-miterlimit="10" points="19.178,20 10.823,20 10.473,14.081
|
||||
10,13.396 10,6.084 20,6.084 20,13.396 19.445,14.021 "/>
|
||||
<rect className="no-fill" x="11.4" y="7.867" width="7.2" height="3.35"/>
|
||||
<line stroke-linejoin="round" stroke-miterlimit="10" x1="12.125" y1="1" x2="12.125" y2="5.35"/>
|
||||
<rect x="11.4" y="4.271" width="1.496" height="1.079"/>
|
||||
<rect x="17.104" y="4.271" width="1.496" height="1.079"/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default CoeventIcon;
|
||||
14
src/components/presentational/Icons/RefreshIcon.js
Normal file
14
src/components/presentational/Icons/RefreshIcon.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const RefreshIcon = ({ }) => {
|
||||
|
||||
return (
|
||||
<svg className="reset" x="0px" y="0px" width="25px" height="25px" viewBox="7.5 7.5 25 25" enableBackground="new 7.5 7.5 25 25">
|
||||
<path stroke-width="2" stroke-miterlimit="10" d="M28.822,16.386c1.354,3.219,0.898,7.064-1.5,9.924
|
||||
c-3.419,4.073-9.49,4.604-13.562,1.186c-4.073-3.417-4.604-9.49-1.187-13.562c1.987-2.368,4.874-3.54,7.74-3.433" />
|
||||
<polygon points="26.137,12.748 27.621,19.464 28.9,16.741 31.898,16.503" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default RefreshIcon;
|
||||
20
src/components/presentational/Icons/RouteIcon.js
Normal file
20
src/components/presentational/Icons/RouteIcon.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
const RouteIcon = ({ isEnabled, toggleMapViews }) => {
|
||||
|
||||
const classes = (isEnabled) ? 'action-button active disabled' : 'action-button disabled';
|
||||
|
||||
return (
|
||||
<button
|
||||
className={sitesClass}
|
||||
onClick={() => toggleMapViews('routes')}
|
||||
>
|
||||
<svg x="0px" y="0px" width="30px" height="20px" viewBox="0 0 30 20" enableBackground="new 0 0 30 20">
|
||||
<path d="M0.806,13.646h7.619c2.762,0,3-0.238,3-3v-0.414c0-2.762,0.301-3,3.246-3h14.523"/>
|
||||
<polyline points="16.671,9.228 19.103,7.233 16.671,5.237 "/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default RouteIcon;
|
||||
19
src/components/presentational/Icons/SitesIcon.js
Normal file
19
src/components/presentational/Icons/SitesIcon.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
const SitesIcon = ({ isEnabled, toggleMapViews }) => {
|
||||
|
||||
const classes = (isEnabled) ? 'action-button active disabled' : 'action-button disabled';
|
||||
|
||||
return (
|
||||
<button
|
||||
className={sitesClass}
|
||||
onClick={() => toggleMapViews('sites')}
|
||||
>
|
||||
<svg x="0px" y="0px" width="30px" height="20px" viewBox="0 0 30 20" enableBackground="new 0 0 30 20">
|
||||
<path d="M24.615,6.793H5.385c-2.761,0-3,0.239-3,3v0.414c0,2.762,0.239,3,3,3h7.621l1.996,2.432l1.996-2.432h7.618c2.762,0,3-0.238,3-3V9.793C27.615,7.032,27.377,6.793,24.615,6.793z"/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default SitesIcon;
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
areEqual,
|
||||
isNotNullNorUndefined
|
||||
} from '../data/utilities';
|
||||
} from '../utilities';
|
||||
import hash from 'object-hash';
|
||||
import 'leaflet-polylinedecorator';
|
||||
|
||||
@@ -227,8 +227,8 @@ Stop and start the development process in terminal after you have added your tok
|
||||
// categories.sort((a, b) => {
|
||||
// return (+a.slice(-2) > +b.slice(-2));
|
||||
// });
|
||||
categories.forEach(group => {
|
||||
eventCount[group] = 0
|
||||
categories.forEach(cat => {
|
||||
eventCount[cat.category] = 0
|
||||
});
|
||||
|
||||
location.events.forEach((event) => {;
|
||||
@@ -239,9 +239,9 @@ Stop and start the development process in terminal after you have added your tok
|
||||
const events = [];
|
||||
|
||||
while (i < categories.length) {
|
||||
let _eventsCount = eventCount[categories[i]];
|
||||
let _eventsCount = eventCount[categories[i].category];
|
||||
for (let j = i + 1; j < categories.length; j++) {
|
||||
_eventsCount += eventCount[categories[j]];
|
||||
_eventsCount += eventCount[categories[j].category];
|
||||
}
|
||||
events.push(_eventsCount);
|
||||
i++;
|
||||
@@ -291,7 +291,7 @@ Stop and start the development process in terminal after you have added your tok
|
||||
eventsDom
|
||||
.enter().append('circle')
|
||||
.attr('class', 'location-event-marker')
|
||||
.style('fill', (d, i) => getCategoryColor(domain.categories[i]))
|
||||
.style('fill', (d, i) => getCategoryColor(domain.categories[i].category))
|
||||
.transition()
|
||||
.duration(500)
|
||||
.attr('r', d => (d) ? Math.sqrt(16 * d) + 3 : 0);
|
||||
@@ -342,6 +342,13 @@ Stop and start the development process in terminal after you have added your tok
|
||||
* Adds eventlayer to map
|
||||
*/
|
||||
|
||||
function getNarrativeStyle(narrativeId) {
|
||||
const styleName = narrativeId && narrativeId in narrativeProps
|
||||
? narrativeId
|
||||
: 'default';
|
||||
return narrativeProps[styleName];
|
||||
}
|
||||
|
||||
function renderNarratives() {
|
||||
const narrativesDom = g.selectAll('.narrative')
|
||||
.data(domain.narratives.map(d => d.steps))
|
||||
@@ -356,20 +363,21 @@ Stop and start the development process in terminal after you have added your tok
|
||||
.attr('class', 'narrative')
|
||||
.attr('d', sequenceLine)
|
||||
.style('stroke-width', d => {
|
||||
styleName = d[0].narrative && d[0].narrative in narrativeProps
|
||||
? d[0].narrative
|
||||
: 'default'
|
||||
const n = d[0].narrative;
|
||||
return (n) ? narrativeProps[styleName].strokeWidth : 3;
|
||||
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 => {
|
||||
const n = d[0].narrative;
|
||||
if (narrativeProps[styleName].style === 'dotted') return "2px 5px";
|
||||
return 'none';
|
||||
if (!d[0]) return 'none';
|
||||
const styleProps = getNarrativeStyle(d[0].narrative);
|
||||
return (styleProps.style === 'dotted') ? "2px 5px" : 'none';
|
||||
})
|
||||
.style('stroke', d => {
|
||||
const n = d[0].narrative;
|
||||
return (n) ? narrativeProps[styleName].stroke : '#fff';
|
||||
if (!d[0]) return 'none';
|
||||
const styleProps = getNarrativeStyle(d[0].narrative);
|
||||
return styleProps.stroke;
|
||||
})
|
||||
.style('fill', 'none');
|
||||
}
|
||||
|
||||
@@ -5,15 +5,16 @@
|
||||
TODO: is it possible to express this idiomatically as React?
|
||||
*/
|
||||
import {
|
||||
areEqual
|
||||
} from '../data/utilities';
|
||||
areEqual,
|
||||
parseDate,
|
||||
formatterWithYear
|
||||
} from '../utilities';
|
||||
import esLocale from '../data/es-MX.json';
|
||||
import copy from '../data/copy.json';
|
||||
|
||||
export default function(app, ui, methods) {
|
||||
d3.timeFormatDefaultLocale(esLocale);
|
||||
const formatterWithYear = ui.tools.formatterWithYear;
|
||||
const parser = ui.tools.parser;
|
||||
|
||||
const zoomLevels = app.zoomLevels;
|
||||
let events = [];
|
||||
let categories = [];
|
||||
@@ -25,7 +26,7 @@ export default function(app, ui, methods) {
|
||||
let transitionDuration = 500;
|
||||
|
||||
// Dimension of the client
|
||||
const WIDTH_CONTROLS = 180;
|
||||
const WIDTH_CONTROLS = 100;
|
||||
const boundingClient = d3.select(`#${ui.dom.timeline}`).node().getBoundingClientRect();
|
||||
let WIDTH = boundingClient.width - WIDTH_CONTROLS;
|
||||
const HEIGHT = 140;
|
||||
@@ -121,16 +122,6 @@ export default function(app, ui, methods) {
|
||||
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')
|
||||
@@ -219,28 +210,6 @@ export default function(app, ui, methods) {
|
||||
}
|
||||
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
|
||||
@@ -274,7 +243,7 @@ export default function(app, ui, methods) {
|
||||
* @param {object} eventPoint: regular eventPoint data
|
||||
*/
|
||||
function getEventX(eventPoint) {
|
||||
return scale.x(parser(eventPoint.timestamp));
|
||||
return scale.x(parseDate(eventPoint.timestamp));
|
||||
}
|
||||
|
||||
function getTimeScaleExtent() {
|
||||
@@ -510,20 +479,6 @@ export default function(app, ui, methods) {
|
||||
.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)
|
||||
@@ -536,11 +491,6 @@ export default function(app, ui, methods) {
|
||||
dom.backwards
|
||||
.on('click', () => moveTime('backwards'));
|
||||
|
||||
dom.playGroup
|
||||
.on('click', () => {
|
||||
return (isPlaying) ? stopBrushTransition() : playBrushTransition();
|
||||
});
|
||||
|
||||
dom.zooms.selectAll('text')
|
||||
.on('click', zoom => applyZoom(zoom));
|
||||
}
|
||||
@@ -564,7 +514,7 @@ export default function(app, ui, methods) {
|
||||
|
||||
axis.y =
|
||||
d3.axisLeft(scale.y)
|
||||
.tickValues(categories);
|
||||
.tickValues(categories.map(c => c.category));
|
||||
}
|
||||
|
||||
function update(domain, app) {
|
||||
|
||||
@@ -35,3 +35,25 @@ export function areEqual(arr1, arr2) {
|
||||
export function isNotNullNorUndefined(variable) {
|
||||
return (typeof variable !== 'undefined' && variable !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a Date object given a datetime string of the format: "2016-09-10T07:00:00"
|
||||
* @param {string} datetime
|
||||
*/
|
||||
export function parseDate(datetime) {
|
||||
return new Date(datetime.slice(0, 4),
|
||||
datetime.slice(5, 7) - 1,
|
||||
datetime.slice(8, 10),
|
||||
datetime.slice(11, 13),
|
||||
datetime.slice(14, 16),
|
||||
datetime.slice(17, 19)
|
||||
);
|
||||
}
|
||||
|
||||
export function formatterWithYear(datetime) {
|
||||
return d3.timeFormat("%d %b %Y, %H:%M")(datetime);
|
||||
}
|
||||
|
||||
export function formatter(datetime) {
|
||||
return d3.timeFormat("%d %b, %H:%M")(datetime);
|
||||
}
|
||||
@@ -1,12 +1,20 @@
|
||||
import initial from '../store/initial.js';
|
||||
|
||||
import { parseDate } from '../js/utilities.js';
|
||||
|
||||
import {
|
||||
UPDATE_HIGHLIGHTED,
|
||||
UPDATE_SELECTED,
|
||||
UPDATE_TAGFILTERS,
|
||||
UPDATE_TIMERANGE,
|
||||
UPDATE_NARRATIVE,
|
||||
RESET_ALLFILTERS,
|
||||
TOGGLE_LANGUAGE,
|
||||
TOGGLE_MAPVIEW,
|
||||
TOGGLE_FETCHING_DOMAIN,
|
||||
TOGGLE_FETCHING_SOURCES,
|
||||
TOGGLE_INFOPOPUP,
|
||||
TOGGLE_NOTIFICATIONS,
|
||||
FETCH_ERROR,
|
||||
} from '../actions';
|
||||
|
||||
@@ -22,6 +30,28 @@ function updateSelected(appState, action) {
|
||||
});
|
||||
}
|
||||
|
||||
function updateNarrative(appState, action) {
|
||||
if (action.narrative === null) {
|
||||
return Object.assign({}, appState, {
|
||||
narrative: action.narrative,
|
||||
});
|
||||
} else {
|
||||
const dates = action.narrative.steps.map(n => parseDate(n.timestamp).getTime())
|
||||
let minDate = Math.min(...dates);
|
||||
let maxDate = Math.max(...dates);
|
||||
// Add some margin to the datetime extent
|
||||
minDate = minDate - ((maxDate - minDate) / 20);
|
||||
maxDate = maxDate + ((maxDate - minDate) / 20);
|
||||
|
||||
return Object.assign({}, appState, {
|
||||
narrative: action.narrative,
|
||||
filters: Object.assign({}, appState.filters, {
|
||||
timerange: [new Date(minDate), new Date(maxDate)]
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateTagFilters(appState, action) {
|
||||
const tagFilters = appState.filters.tags.slice(0);
|
||||
const nextActiveState = action.tag.active
|
||||
@@ -74,6 +104,18 @@ function toggleLanguage(appState, action) {
|
||||
});
|
||||
}
|
||||
|
||||
function toggleMapView(appState, action) {
|
||||
const isLayerInView = !appState.views[layer];
|
||||
const newViews = {};
|
||||
newViews[layer] = isLayerInView;
|
||||
const views = Object.assign({}, appState.views, newViews);
|
||||
return Object.assign({}, appState, {
|
||||
filters: Object.assign({}, appState.filters, {
|
||||
views
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function fetchError(state, action) {
|
||||
return {
|
||||
...state,
|
||||
@@ -82,6 +124,39 @@ function fetchError(state, action) {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFetchingDomain(appState, action) {
|
||||
return Object.assign({}, appState, {
|
||||
flags: Object.assign({}, appState.flags, {
|
||||
isFetchingDomain: !appState.flags.isFetchingDomain
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function toggleFetchingSources(appState, action) {
|
||||
return Object.assign({}, appState, {
|
||||
flags: Object.assign({}, appState.flags, {
|
||||
isFetchingSources: !appState.flags.isFetchingSources
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function toggleInfoPopup(appState, action) {
|
||||
return Object.assign({}, appState, {
|
||||
flags: Object.assign({}, appState.flags, {
|
||||
isInfopopup: !appState.flags.isInfopopup
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function toggleNotifications(appState, action) {
|
||||
return Object.assign({}, appState, {
|
||||
flags: Object.assign({}, appState.flags, {
|
||||
isNotification: !appState.flags.isNotification
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
function app(appState = initial.app, action) {
|
||||
switch (action.type) {
|
||||
@@ -93,12 +168,24 @@ function app(appState = initial.app, action) {
|
||||
return updateTagFilters(appState, action);
|
||||
case UPDATE_TIMERANGE:
|
||||
return updateTimeRange(appState, action);
|
||||
case UPDATE_NARRATIVE:
|
||||
return updateNarrative(appState, action);
|
||||
case RESET_ALLFILTERS:
|
||||
return resetAllFilters(appState, action);
|
||||
case TOGGLE_LANGUAGE:
|
||||
return toggleLanguage(appState, action);
|
||||
case TOGGLE_MAPVIEW:
|
||||
return toggleMapView(appState, action);
|
||||
case FETCH_ERROR:
|
||||
return fetchError(appState, action);
|
||||
case TOGGLE_FETCHING_DOMAIN:
|
||||
return toggleFetchingDomain(appState, action);
|
||||
case TOGGLE_FETCHING_SOURCES:
|
||||
return toggleFetchingSources(appState, action);
|
||||
case TOGGLE_INFOPOPUP:
|
||||
return toggleInfoPopup(appState, action);
|
||||
case TOGGLE_NOTIFICATIONS:
|
||||
return toggleNotifications(appState, action);
|
||||
default:
|
||||
return appState;
|
||||
}
|
||||
|
||||
9
src/reducers/schema/narrativeSchema.js
Normal file
9
src/reducers/schema/narrativeSchema.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import Joi from 'joi';
|
||||
|
||||
const narrativeSchema = Joi.object().keys({
|
||||
id: Joi.string().required(),
|
||||
description: Joi.string().allow('').required(),
|
||||
label: Joi.string().required()
|
||||
});
|
||||
|
||||
export default narrativeSchema;
|
||||
@@ -1,55 +1,9 @@
|
||||
import initial from '../store/initial.js';
|
||||
|
||||
import {
|
||||
TOGGLE_FETCHING_DOMAIN,
|
||||
TOGGLE_FETCHING_SOURCES,
|
||||
TOGGLE_VIEW,
|
||||
TOGGLE_TIMELINE,
|
||||
TOGGLE_INFOPOPUP,
|
||||
TOGGLE_NOTIFICATIONS
|
||||
} from '../actions'
|
||||
|
||||
function toggleFetchingDomain(uiState, action) {
|
||||
return {
|
||||
...uiState,
|
||||
flags: {
|
||||
...uiState.flags,
|
||||
isFetchingDomain: !uiState.flags.isFetchingDomain
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFetchingSources(uiState, action) {
|
||||
return {
|
||||
...uiState,
|
||||
flags: {
|
||||
...uiState.flags,
|
||||
isFetchingSources: !uiState.flags.isFetchingSources
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleInfoPopup(uiState, action) {
|
||||
return {
|
||||
...uiState,
|
||||
flags: {
|
||||
...uiState.flags,
|
||||
isInfopopup: !uiState.flags.isInfopopup
|
||||
}
|
||||
}
|
||||
}
|
||||
import {} from '../actions'
|
||||
|
||||
function ui(uiState = initial.ui, action) {
|
||||
switch (action.type) {
|
||||
case TOGGLE_FETCHING_DOMAIN:
|
||||
return toggleFetchingDomain(uiState, action)
|
||||
case TOGGLE_FETCHING_SOURCES:
|
||||
return toggleFetchingSources(uiState, action)
|
||||
case TOGGLE_INFOPOPUP:
|
||||
return toggleInfoPopup(uiState, action)
|
||||
default:
|
||||
return uiState
|
||||
}
|
||||
return uiState;
|
||||
}
|
||||
|
||||
export default ui;
|
||||
|
||||
@@ -3,6 +3,7 @@ import Joi from 'joi';
|
||||
import eventSchema from '../schema/eventSchema.js';
|
||||
import categorySchema from '../schema/categorySchema.js';
|
||||
import siteSchema from '../schema/siteSchema.js';
|
||||
import narrativeSchema from '../schema/narrativeSchema.js';
|
||||
|
||||
import { capitalize } from './helpers.js';
|
||||
|
||||
@@ -57,6 +58,7 @@ export function validateDomain (domain) {
|
||||
events: [],
|
||||
categories: [],
|
||||
sites: [],
|
||||
narratives: [],
|
||||
notifications: domain.notifications,
|
||||
tags: {}
|
||||
}
|
||||
@@ -64,7 +66,8 @@ export function validateDomain (domain) {
|
||||
const discardedDomain = {
|
||||
events: [],
|
||||
categories: [],
|
||||
sites: []
|
||||
sites: [],
|
||||
narratives: [],
|
||||
}
|
||||
|
||||
function validateItem(item, domainClass, schema) {
|
||||
@@ -89,6 +92,10 @@ export function validateDomain (domain) {
|
||||
domain.sites.forEach(site => {
|
||||
validateItem(site, 'sites', siteSchema);
|
||||
});
|
||||
domain.narratives.forEach(narrative => {
|
||||
validateItem(narrative, 'narratives', narrativeSchema);
|
||||
});
|
||||
|
||||
|
||||
// Message the number of failed items in domain
|
||||
Object.keys(discardedDomain).forEach(disc => {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
@import 'loading';
|
||||
@import 'header';
|
||||
@import 'cardstack';
|
||||
@import 'narrativecard';
|
||||
@import 'map';
|
||||
@import 'timeline';
|
||||
@import 'tag-filters';
|
||||
|
||||
63
src/scss/narrativecard.scss
Normal file
63
src/scss/narrativecard.scss
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
NARRATIVE INFO
|
||||
*/
|
||||
.narrative-info {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: 130px;
|
||||
height: auto;
|
||||
width: 270px;
|
||||
box-sizing: border-box;
|
||||
padding: 15px;
|
||||
max-height: calc(100% - 250px);
|
||||
overflow: auto;
|
||||
box-shadow: 0 19px 38px rgba($black, 0.3), 0 15px 12px rgba($black, 0.22);
|
||||
background: $black;
|
||||
border: 1px solid $midgrey;
|
||||
color: $offwhite;
|
||||
font-family: 'Merriweather', 'Georgia', serif;
|
||||
|
||||
h3, h6 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: $large;
|
||||
}
|
||||
|
||||
p {
|
||||
font-family: 'Lato', 'Helvetica', sans-serif;
|
||||
font-size: $normal;
|
||||
line-height: 1.4em;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 100%;
|
||||
.action {
|
||||
width: calc(50% - 5px);
|
||||
height: 40px;
|
||||
box-sizing: border-box;
|
||||
line-height: 40px;
|
||||
font-family: 'Lato', 'Helvetica', sans-serif;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
|
||||
&:not(.disabled) {
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
transition: 0.2s ease;
|
||||
color: $yellow;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: $midgrey;
|
||||
cursor: normal;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,7 +173,7 @@
|
||||
.axisBoundaries {
|
||||
stroke: $offwhite;
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 1px 4px;
|
||||
stroke-dasharray: 1px 2px;
|
||||
}
|
||||
|
||||
.event {
|
||||
|
||||
@@ -161,10 +161,12 @@
|
||||
}
|
||||
|
||||
.toolbar-tab {
|
||||
display: inline-block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60px;
|
||||
width: 110px;
|
||||
padding: 10px 0 5px 0;
|
||||
padding: 5px 0 5px 0;
|
||||
font-weight: 400;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -3,12 +3,13 @@ import {
|
||||
} from 'reselect'
|
||||
|
||||
// Input selectors
|
||||
export const getEvents = state => state.domain.events
|
||||
export const getLocations = state => state.domain.locations
|
||||
export const getCategories = state => state.domain.categories
|
||||
export const getEvents = state => state.domain.events;
|
||||
export const getLocations = state => state.domain.locations;
|
||||
export const getCategories = state => state.domain.categories;
|
||||
export const getNarratives = state => state.domain.narratives;
|
||||
export const getSites = (state) => {
|
||||
if (process.env.features.USE_SITES) return state.domain.sites
|
||||
return []
|
||||
if (process.env.features.USE_SITES) return state.domain.sites;
|
||||
return [];
|
||||
}
|
||||
export const getNotifications = state => state.domain.notifications;
|
||||
export const getTagTree = state => state.domain.tags;
|
||||
@@ -84,8 +85,8 @@ export const selectEvents = createSelector(
|
||||
* and if TAGS are being used, select them if their tags are enabled
|
||||
*/
|
||||
export const selectNarratives = createSelector(
|
||||
[getEvents, getTagsFilter, getTimeRange],
|
||||
(events, tagFilters, timeRange) => {
|
||||
[getEvents, getNarratives, getTagsFilter, getTimeRange],
|
||||
(events, narrativeMetadata, tagFilters, timeRange) => {
|
||||
|
||||
const narratives = {};
|
||||
events.forEach((evt) => {
|
||||
@@ -93,10 +94,11 @@ export const selectNarratives = createSelector(
|
||||
const isTimeRanged = isTimeRangedIn(evt, timeRange);
|
||||
const isInNarrative = evt.narrative;
|
||||
|
||||
if (isTimeRanged && isTagged && isInNarrative) {
|
||||
if (!narratives[evt.narrative]) {
|
||||
narratives[evt.narrative] = { key: evt.narrative, steps: [], byId: {} };
|
||||
}
|
||||
if (!narratives[evt.narrative]) {
|
||||
narratives[evt.narrative] = { id: evt.narrative, steps: [], byId: {} };
|
||||
}
|
||||
|
||||
if (/*isTimeRanged && isTagged && */isInNarrative) {
|
||||
narratives[evt.narrative].steps.push(evt);
|
||||
narratives[evt.narrative].byId[evt.id] = { next: null, prev: null };
|
||||
}
|
||||
@@ -104,13 +106,19 @@ export const selectNarratives = createSelector(
|
||||
|
||||
Object.keys(narratives).forEach((key) => {
|
||||
const steps = narratives[key].steps;
|
||||
|
||||
steps.sort((a, b) => {
|
||||
return (parseTimestamp(a.timestamp) > parseTimestamp(b.timestamp));
|
||||
});
|
||||
|
||||
steps.forEach((step, i) => {
|
||||
narratives[key].byId[step.id].next = (i < steps.length - 2) ? steps[i + 1] : null;
|
||||
narratives[key].byId[step.id].prev = (i > 0) ? steps[i - 1] : null;
|
||||
});
|
||||
|
||||
if (narrativeMetadata.find(n => n.id === key)) {
|
||||
narratives[key] = Object.assign(narrativeMetadata.find(n => n.id === key), narratives[key]);
|
||||
}
|
||||
});
|
||||
|
||||
return Object.values(narratives);
|
||||
@@ -150,26 +158,9 @@ export const selectLocations = createSelector(
|
||||
*/
|
||||
export const selectCategories = createSelector(
|
||||
[getCategories],
|
||||
(categories) => {
|
||||
return categories.map(v => v.category);
|
||||
}
|
||||
(categories) => categories
|
||||
);
|
||||
|
||||
/**
|
||||
* Return categories by group
|
||||
*/
|
||||
export const selectCategoryGroups = createSelector(
|
||||
[selectCategories],
|
||||
(categories) => {
|
||||
const groups = {};
|
||||
categories.forEach((cat) => {
|
||||
if (cat.group && !groups[cat.group]) {
|
||||
groups[cat.group] = cat.group_label;
|
||||
}
|
||||
});
|
||||
return Object.keys(groups).concat(['other']);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Given a tree of tags, return those tags as a list
|
||||
|
||||
@@ -28,6 +28,7 @@ const initial = {
|
||||
error: null,
|
||||
highlighted: null,
|
||||
selected: [],
|
||||
narrative: null,
|
||||
filters: {
|
||||
timerange: [
|
||||
d3.timeParse("%Y-%m-%dT%H:%M:%S")("2013-02-23T12:00:00"),
|
||||
@@ -84,37 +85,30 @@ const initial = {
|
||||
features: {
|
||||
USE_TAGS: process.env.features.USE_TAGS,
|
||||
USE_SEARCH: process.env.features.USE_SEARCH
|
||||
},
|
||||
flags: {
|
||||
isFetchingDomain: false,
|
||||
isFetchingSources: false,
|
||||
|
||||
isCardstack: true,
|
||||
isInfopopup: false,
|
||||
isNotification: true
|
||||
}
|
||||
},
|
||||
|
||||
/*
|
||||
* The 'ui' subtree of this state refers the state of the cosmetic
|
||||
* elements of the application, such as color palettes of groups or how some
|
||||
* of the UI tools are enabled or disabled dynamically by the user
|
||||
* elements of the application, such as color palettes of categories
|
||||
* as well as dom elements to attach SVG
|
||||
*/
|
||||
ui: {
|
||||
style: {
|
||||
|
||||
colors: {
|
||||
WHITE: "#efefef",
|
||||
YELLOW: "#ffd800",
|
||||
MIDGREY: "rgb(44, 44, 44)",
|
||||
DARKGREY: "#232323",
|
||||
PINK: "#F28B50",//rgb(232, 9, 90)",
|
||||
ORANGE: "#F25835",//rgb(232, 9, 90)",
|
||||
RED: "rgb(233, 0, 19)",
|
||||
BLUE: "#F2DE79",//"rgb(48, 103 , 217)",
|
||||
GREEN: "#4FF2F2",//"rgb(0, 158, 86)",
|
||||
},
|
||||
|
||||
palette: d3.schemeCategory10,
|
||||
|
||||
categories: {
|
||||
default: 'red',
|
||||
// Add here other categories to differentiate by color, like:
|
||||
alpha: '#00ff00',
|
||||
beta: '#ff0000',
|
||||
other: 'yellow'
|
||||
alpha: '#c73e1d',
|
||||
beta: '#f40000',
|
||||
other: '#f3de2c'
|
||||
},
|
||||
|
||||
narratives: {
|
||||
@@ -127,7 +121,7 @@ const initial = {
|
||||
narrative_1: {
|
||||
style: 'solid', // ['dotted', 'solid']
|
||||
opacity: 0.4, // range between 0 and 1
|
||||
stroke: 'red', // Any hex or rgb code
|
||||
stroke: '#f18f01', // Any hex or rgb code
|
||||
strokeWidth: 2
|
||||
}
|
||||
}
|
||||
@@ -137,18 +131,6 @@ const initial = {
|
||||
timeslider: "timeslider",
|
||||
map: "map"
|
||||
},
|
||||
flags: {
|
||||
isFetchingDomain: false,
|
||||
isFetchingSources: false,
|
||||
|
||||
isCardstack: true,
|
||||
isInfopopup: false
|
||||
},
|
||||
tools: {
|
||||
formatter: d3.timeFormat("%d %b, %H:%M"),
|
||||
formatterWithYear: d3.timeFormat("%d %b %Y, %H:%M"),
|
||||
parser: d3.timeParse("%Y-%m-%dT%H:%M:%S")
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user