diff --git a/.gitignore b/.gitignore index 12b0846..4ce2e5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ build/ node_modules/ config.js +dev.config.js diff --git a/example.config.js b/example.config.js index 222355e..30c12c2 100644 --- a/example.config.js +++ b/example.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], diff --git a/src/actions/index.js b/src/actions/index.js index 77ead1b..0d3b0c4 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -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 { diff --git a/src/components/Card.jsx b/src/components/Card.jsx index d53bc31..9c97da6 100644 --- a/src/components/Card.jsx +++ b/src/components/Card.jsx @@ -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; } diff --git a/src/components/CardStack.jsx b/src/components/CardStack.jsx index f5bc36e..d7c5ab1 100644 --- a/src/components/CardStack.jsx +++ b/src/components/CardStack.jsx @@ -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 { 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()} /> + diff --git a/src/components/InfoPopup.jsx b/src/components/InfoPopup.jsx index 00e67b7..6560759 100644 --- a/src/components/InfoPopup.jsx +++ b/src/components/InfoPopup.jsx @@ -27,7 +27,7 @@ export default class InfoPopUp extends React.Component{ renderView2DLegend() { return ( -
+
{this.renderView2DCopy()}
diff --git a/src/components/NarrativeCard.js b/src/components/NarrativeCard.js new file mode 100644 index 0000000..1cc4730 --- /dev/null +++ b/src/components/NarrativeCard.js @@ -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 ( + + ) + } + + 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 ( +
+ {this.renderClose()} +
{this.props.narrative.label}
+

{this.props.narrative.description}

+

{this.state.step + 1}/{steps.length}. {step.location}

+
+
this.goToPrevKeyFrame()}>←
+
= this.props.narrative.steps.length - 1) ? 'disabled ' : ''} action`} onClick={() => this.goToNextKeyFrame()}>→
+
+
+ ); + } + return (
); + } +} + +function mapStateToProps(state) { + return { + narrative: state.app.narrative + } +} +export default connect(mapStateToProps)(NarrativeCard); diff --git a/src/components/Notification.jsx b/src/components/Notification.jsx index 260befa..df44225 100644 --- a/src/components/Notification.jsx +++ b/src/components/Notification.jsx @@ -28,12 +28,12 @@ export default class Notification extends React.Component{ } renderNotificationContent(notification) { - const { type, message, items } = notification; + let { type, message, items } = notification; return (
- {`${message}`} + {message}
{(items !== null) ? this.renderItems(items) : ''} @@ -48,7 +48,6 @@ export default class Notification extends React.Component{ return (
{this.props.notifications.map((notification) => { - return (
this.toggleDetails() }>
); diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx index 74e6455..273fc98 100644 --- a/src/components/Timeline.jsx +++ b/src/components/Timeline.jsx @@ -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 (
@@ -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); \ No newline at end of file +export default connect(mapStateToProps)(Timeline); diff --git a/src/components/Toolbar.jsx b/src/components/Toolbar.jsx index d2fb5f8..58a8c28 100644 --- a/src/components/Toolbar.jsx +++ b/src/components/Toolbar.jsx @@ -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 ( -
- - - -
- ); - } - - renderBottomActions() { - return ( -
- {this.renderMapActions()} -
- - - -
-
- ); - } - - - renderPanelHeader() { + renderClosePanel() { return (
this.toggleTab(-1)}>
@@ -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 ( -
{ this.toggleTab(tabNum); }}> - {/**/} -
{key}
-
- ); - } - - 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 ( -
-

{title}

-
- {/*this.renderToolbarTab(0, 'search')*/} - {this.renderToolbarTagRoot()} -
- {/* {this.renderBottomActions()} */} -
- ) - } - - 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 ( - - ); - } - renderSearch() { if (this.props.features.USE_SEARCH) { return ( - + ) } } - renderToolbarTagList() { + goToNarrative(narrative) { + this.setState({ + tabNum: -1 + }, () => { + this.props.actions.updateNarrative(narrative); + }); + } + + renderToolbarNarrativePanel() { + return ( + +

Focus stories

+

Here are some highlighted stories

+ {this.props.narratives.map((narr) => { + return ( +
+ +
+ ) + })} +
+ ); + } + + 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 ( - - {this.renderTagListPanel(root.key)} - - ) - }) + return ( + + + + ) } 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 ( +
{ this.toggleTab(tabNum); }}> +
{label}
+
+ ); + } + + renderToolbarTabs() { + const title = copy[this.props.language].toolbar.title; + const isTags = this.props.tags && (this.props.tags.children > 0); + + return ( +
+

{title}

+
+ {/*this.renderToolbarTab(0, 'search')*/} + {this.renderToolbarTab(0, 'Narratives')} + {(isTags) ? this.renderToolbarTab(1, 'Explore by tag') : ''} +
+ +
+ ) + } + + renderToolbarPanels() { + let classes = (this.state.tabNum !== -1) ? 'toolbar-panels' : 'toolbar-panels folded'; + + return ( +
+ {this.renderClosePanel()} + + {this.renderToolbarNarrativePanel()} + {this.renderToolbarTagPanel()} + +
+ ) + } + + render() { return (
{this.renderToolbarTabs()} -
- {this.renderPanelHeader()} - - {this.renderToolbarTagList()} - -
+ {this.renderToolbarPanels()}
); } @@ -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, } } diff --git a/src/components/ToolbarBottomActions.jsx b/src/components/ToolbarBottomActions.jsx new file mode 100644 index 0000000..fe173a9 --- /dev/null +++ b/src/components/ToolbarBottomActions.jsx @@ -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 ( +
+ this.toggleMapViews(view)} + isEnabled={this.props.viewFilters.routes} + /> + this.toggleMapViews(view)} + isEnabled={this.props.viewFilters.sites} + /> + this.toggleMapViews(view)} + isEnabled={this.props.viewFilters.coevents} + /> +
+ ); + } + + render() { + return ( +
+ {/*}{this.renderMapActions()} +
+ + + +
*/} +
+ ); + } +} + +export default ToolbarBottomActions; diff --git a/src/components/Viewport.jsx b/src/components/Viewport.jsx index 363a652..3bcc808 100644 --- a/src/components/Viewport.jsx +++ b/src/components/Viewport.jsx @@ -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) { diff --git a/src/components/presentational/CardLocation.js b/src/components/presentational/CardLocation.js index 7dd26c0..a798a5e 100644 --- a/src/components/presentational/CardLocation.js +++ b/src/components/presentational/CardLocation.js @@ -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 }) => { diff --git a/src/components/presentational/CardTimestamp.js b/src/components/presentational/CardTimestamp.js index d645e82..4c4d594 100644 --- a/src/components/presentational/CardTimestamp.js +++ b/src/components/presentational/CardTimestamp.js @@ -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 }) => { diff --git a/src/components/presentational/Icons/CoeventIcon.js b/src/components/presentational/Icons/CoeventIcon.js new file mode 100644 index 0000000..ffa5db7 --- /dev/null +++ b/src/components/presentational/Icons/CoeventIcon.js @@ -0,0 +1,24 @@ +import React from 'react'; + +const CoeventIcon = ({ isEnabled, toggleMapViews }) => { + + const classes = (isEnabled) ? 'action-button active disabled' : 'action-button disabled'; + + return ( + + ); +} + +export default CoeventIcon; diff --git a/src/components/presentational/Icons/RefreshIcon.js b/src/components/presentational/Icons/RefreshIcon.js new file mode 100644 index 0000000..1e1eb03 --- /dev/null +++ b/src/components/presentational/Icons/RefreshIcon.js @@ -0,0 +1,14 @@ +import React from 'react'; + +const RefreshIcon = ({ }) => { + + return ( + + + + + ); +} + +export default RefreshIcon; diff --git a/src/components/presentational/Icons/RouteIcon.js b/src/components/presentational/Icons/RouteIcon.js new file mode 100644 index 0000000..4febda9 --- /dev/null +++ b/src/components/presentational/Icons/RouteIcon.js @@ -0,0 +1,20 @@ +import React from 'react'; + +const RouteIcon = ({ isEnabled, toggleMapViews }) => { + + const classes = (isEnabled) ? 'action-button active disabled' : 'action-button disabled'; + + return ( + + ); +} + +export default RouteIcon; diff --git a/src/components/presentational/Icons/SitesIcon.js b/src/components/presentational/Icons/SitesIcon.js new file mode 100644 index 0000000..a4461eb --- /dev/null +++ b/src/components/presentational/Icons/SitesIcon.js @@ -0,0 +1,19 @@ +import React from 'react'; + +const SitesIcon = ({ isEnabled, toggleMapViews }) => { + + const classes = (isEnabled) ? 'action-button active disabled' : 'action-button disabled'; + + return ( + + ); +} + +export default SitesIcon; diff --git a/src/js/map/map.js b/src/js/map/map.js index 8d84554..3e30e93 100644 --- a/src/js/map/map.js +++ b/src/js/map/map.js @@ -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'); } diff --git a/src/js/timeline/timeline.js b/src/js/timeline/timeline.js index 27e01dc..8a009e9 100644 --- a/src/js/timeline/timeline.js +++ b/src/js/timeline/timeline.js @@ -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) { diff --git a/src/js/data/utilities.js b/src/js/utilities.js similarity index 68% rename from src/js/data/utilities.js rename to src/js/utilities.js index da8462d..c9c9309 100644 --- a/src/js/data/utilities.js +++ b/src/js/utilities.js @@ -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); +} diff --git a/src/reducers/app.js b/src/reducers/app.js index d9bd94a..c8f243b 100644 --- a/src/reducers/app.js +++ b/src/reducers/app.js @@ -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; } diff --git a/src/reducers/schema/narrativeSchema.js b/src/reducers/schema/narrativeSchema.js new file mode 100644 index 0000000..373c0ac --- /dev/null +++ b/src/reducers/schema/narrativeSchema.js @@ -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; diff --git a/src/reducers/ui.js b/src/reducers/ui.js index 5fa405f..da2e018 100644 --- a/src/reducers/ui.js +++ b/src/reducers/ui.js @@ -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; diff --git a/src/reducers/utils/validators.js b/src/reducers/utils/validators.js index da3e98d..ecc8f87 100644 --- a/src/reducers/utils/validators.js +++ b/src/reducers/utils/validators.js @@ -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 => { diff --git a/src/scss/main.scss b/src/scss/main.scss index b36fad6..866e93e 100644 --- a/src/scss/main.scss +++ b/src/scss/main.scss @@ -6,6 +6,7 @@ @import 'loading'; @import 'header'; @import 'cardstack'; +@import 'narrativecard'; @import 'map'; @import 'timeline'; @import 'tag-filters'; diff --git a/src/scss/narrativecard.scss b/src/scss/narrativecard.scss new file mode 100644 index 0000000..3f7f7a5 --- /dev/null +++ b/src/scss/narrativecard.scss @@ -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; + } + } + } +} diff --git a/src/scss/timeline.scss b/src/scss/timeline.scss index ed3ad20..2ffda13 100644 --- a/src/scss/timeline.scss +++ b/src/scss/timeline.scss @@ -173,7 +173,7 @@ .axisBoundaries { stroke: $offwhite; stroke-width: 1; - stroke-dasharray: 1px 4px; + stroke-dasharray: 1px 2px; } .event { diff --git a/src/scss/toolbar.scss b/src/scss/toolbar.scss index f1590b3..9459f1d 100644 --- a/src/scss/toolbar.scss +++ b/src/scss/toolbar.scss @@ -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; diff --git a/src/selectors/index.js b/src/selectors/index.js index bd1315f..76f8f40 100644 --- a/src/selectors/index.js +++ b/src/selectors/index.js @@ -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 diff --git a/src/store/initial.js b/src/store/initial.js index b675188..c387910 100644 --- a/src/store/initial.js +++ b/src/store/initial.js @@ -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") - } } };