diff --git a/src/actions/index.js b/src/actions/index.js index 3a5e890..7f39a6d 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -213,6 +213,20 @@ export function updateNarrative(narrative) { } } +export const INCREMENT_NARRATIVE_CURRENT = 'INCREMENT_NARRATIVE_CURRENT'; +export function incrementNarrativeCurrent() { + return { + type: INCREMENT_NARRATIVE_CURRENT + } +} + +export const DECREMENT_NARRATIVE_CURRENT = 'DECREMENT_NARRATIVE_CURRENT'; +export function decrementNarrativeCurrent() { + return { + type: DECREMENT_NARRATIVE_CURRENT + } +} + export const RESET_ALLFILTERS = 'RESET_ALLFILTERS' export function resetAllFilters() { return { diff --git a/src/components/Card.jsx b/src/components/Card.jsx index ee269d5..2e996e9 100644 --- a/src/components/Card.jsx +++ b/src/components/Card.jsx @@ -1,61 +1,56 @@ -import copy from '../js/data/copy.json'; +import copy from '../js/data/copy.json' import { isNotNullNorUndefined, parseDate, formatterWithYear -} from '../js/utilities'; -import React from 'react'; +} from '../js/utilities' +import React from 'react' -import Spinner from './presentational/Spinner'; -import CardTimestamp from './presentational/CardTimestamp'; -import CardLocation from './presentational/CardLocation'; -import CardCaret from './presentational/CardCaret'; -import CardTags from './presentational/CardTags'; -import CardSummary from './presentational/CardSummary'; -import CardSource from './presentational/CardSource'; -import CardCategory from './presentational/CardCategory'; -import CardNarrative from './presentational/CardNarrative'; +import Spinner from './presentational/Spinner' +import CardTimestamp from './presentational/CardTimestamp' +import CardLocation from './presentational/CardLocation' +import CardCaret from './presentational/CardCaret' +import CardTags from './presentational/CardTags' +import CardSummary from './presentational/CardSummary' +import CardSource from './presentational/CardSource' +import CardCategory from './presentational/CardCategory' +import CardNarrative from './presentational/CardNarrative' class Card extends React.Component { constructor(props) { - super(props); + super(props) this.state = { - isHighlighted: false - }; + isOpen: false + } } toggle() { this.setState({ - isHighlighted: !this.state.isHighlighted - }, () => { - if (!this.state.isHighlighted) { - this.props.onHighlight(this.props.event); - } else { - this.props.onHighlight(null); - } - }); + isOpen: !this.state.isOpen + }) } makeTimelabel(timestamp) { - if (timestamp === null) return null; - const parsedTimestamp = parseDate(timestamp); - const timelabel = formatterWithYear(parsedTimestamp); - return timelabel; + if (timestamp === null) return null + const parsedTimestamp = parseDate(timestamp) + const timelabel = formatterWithYear(parsedTimestamp) + return timelabel } renderCategory() { - const categoryTitle = copy[this.props.language].cardstack.category; - const categoryLabel = this.props.event.category; - const color = this.props.getCategoryColor(this.props.event.category); + const categoryTitle = copy[this.props.language].cardstack.category + const categoryLabel = this.props.event.category + const color = this.props.getCategoryColor(this.props.event.category) - return ( - - ); + return null + // return ( + // + // ) } renderSummary() { @@ -63,7 +58,7 @@ class Card extends React.Component { ) } @@ -96,7 +91,7 @@ class Card extends React.Component { const source_lang = copy[this.props.language].cardstack.sources return ( -
+

{source_lang}:

{this.props.event.sources.map(source => ( - ); + ) } renderNarrative() { - const links = this.props.getNarrativeLinks(this.props.event); + const links = this.props.getNarrativeLinks(this.props.event) if (links !== null) { @@ -136,52 +131,48 @@ class Card extends React.Component { } } - renderHeader() { + renderMain() { return ( -
-
+
+
{this.renderTimestamp()} {this.renderLocation()}
{this.renderCategory()} -
{this.renderSummary()}
- ); + ) } - renderContent() { - if (this.state.isHighlighted) { - return ( -
- {this.renderTags()} - {this.renderSources()} - {this.renderNarrative()} -
- ) - } else { - return
- } + renderExtra() { + return ( +
+ {this.renderTags()} + {this.renderSources()} + {this.renderNarrative()} +
+ ) } renderCaret() { return ( this.toggle()} - isHighlighted={this.state.isHighlighted} + isOpen={this.state.isOpen} /> ) } render() { + const { isSelected } = this.props return ( -
  • - {this.renderHeader()} - {this.renderContent()} - {this.renderCaret()} +
  • + {this.renderMain()} + {this.state.isOpen ? this.renderExtra() : null} + {isSelected ? this.renderCaret() : null}
  • - ); + ) } } -export default Card; +export default Card diff --git a/src/components/CardStack.jsx b/src/components/CardStack.jsx index 86d4b0e..4d37399 100644 --- a/src/components/CardStack.jsx +++ b/src/components/CardStack.jsx @@ -1,44 +1,56 @@ -import React from 'react'; +import React from 'react' import { connect } from 'react-redux' import * as selectors from '../selectors' -import Card from './Card.jsx'; -import copy from '../js/data/copy.json'; +import Card from './Card.jsx' +import copy from '../js/data/copy.json' import { isNotNullNorUndefined -} from '../js/utilities.js'; +} from '../js/utilities.js' class CardStack extends React.Component { + renderCards(events, selections) { + // if no selections provided, select all + if (!selections) + selections = events.map(e => true) - constructor(props) { - super(props); + return events.map((event, idx) => ( + + )) } - renderCards() { - if (this.props.selected.length > 0) { - return this.props.selected.map((event) => { - return ( - - ); - }); + renderSelectedCards() { + const { selected } = this.props + if (selected.length > 0) { + return this.renderCards(selected) } - return ''; + return null + } + + renderNarrativeCards() { + const { narrative } = this.props + const showing = narrative.steps.slice(narrative.current) + const selections = showing + .map((_, idx) => (idx === 0)) + + return this.renderCards(showing, selections) } renderCardStackHeader() { - const header_lang = copy[this.props.language].cardstack.header; + const header_lang = copy[this.props.language].cardstack.header return (
      - {this.renderCards()} + {this.renderSelectedCards()}
    - ); + ) + } + + renderNarrativeContent() { + return ( +
    +
      + {this.renderNarrativeCards()} +
    +
    + ) } render() { - if (this.props.selected.length > 0) { - return ( -
    - {this.renderCardStackHeader()} - {this.renderCardStackContent()} -
    - ); + const { isCardstack, selected, narrative } = this.props + + if (selected.length > 0) { + if (!narrative) { + return ( +
    + {this.renderCardStackHeader()} + {this.renderCardStackContent()} +
    + ) + } else { + return ( +
    + {this.renderNarrativeContent()} +
    + ) + } } - return
    ; + + return
    } } function mapStateToProps(state) { return { + narrative: selectors.selectActiveNarrative(state), selected: selectors.selectSelected(state), sourceError: state.app.errors.source, language: state.app.language, diff --git a/src/components/Dashboard.jsx b/src/components/Dashboard.jsx index 066cffd..abc68c2 100644 --- a/src/components/Dashboard.jsx +++ b/src/components/Dashboard.jsx @@ -1,32 +1,33 @@ -import React from 'react'; +import React from 'react' -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; -import * as actions from '../actions'; +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' +import * as actions from '../actions' -import SourceOverlay from './SourceOverlay.jsx'; -import LoadingOverlay from './presentational/LoadingOverlay'; -import Map from './Map.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 SourceOverlay from './SourceOverlay.jsx' +import LoadingOverlay from './presentational/LoadingOverlay' +import Map from './Map.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'; +import { parseDate } from '../js/utilities' + +import { injectNarrative } from '../js/utilities' class Dashboard extends React.Component { constructor(props) { - super(props); + super(props) this.handleViewSource = this.handleViewSource.bind(this) - this.handleHighlight = this.handleHighlight.bind(this); - this.handleSelect = this.handleSelect.bind(this); - this.handleSelectNarrative = this.handleSelectNarrative.bind(this); - this.handleTagFilter = this.handleTagFilter.bind(this); - this.updateTimerange = this.updateTimerange.bind(this); - this.getCategoryColor = this.getCategoryColor.bind(this); + this.handleHighlight = this.handleHighlight.bind(this) + this.setNarrative = this.setNarrative.bind(this) + this.moveInNarrative = this.moveInNarrative.bind(this) + this.handleSelect = this.handleSelect.bind(this) + this.getCategoryColor = this.getCategoryColor.bind(this) this.eventsById = {} } @@ -34,18 +35,18 @@ class Dashboard extends React.Component { componentDidMount() { if (!this.props.app.isMobile) { this.props.actions.fetchDomain() - .then(domain => this.props.actions.updateDomain(domain)); + .then(domain => this.props.actions.updateDomain(domain)) } } handleHighlight(highlighted) { - this.props.actions.updateHighlighted((highlighted) ? highlighted : null); + this.props.actions.updateHighlighted((highlighted) ? highlighted : null) } getEventById(eventId) { - if (this.eventsById[eventId]) return this.eventsById[eventId]; - this.eventsById[eventId] = this.props.domain.events.find(ev => ev.id === eventId); - return this.eventsById[eventId]; + if (this.eventsById[eventId]) return this.eventsById[eventId] + this.eventsById[eventId] = this.props.domain.events.find(ev => ev.id === eventId) + return this.eventsById[eventId] } handleViewSource(source) { @@ -54,104 +55,116 @@ class Dashboard extends React.Component { handleSelect(selected) { if (selected) { - let eventsToSelect = selected.map(event => this.getEventById(event.id)); + let eventsToSelect = selected.map(event => this.getEventById(event.id)) eventsToSelect = eventsToSelect.sort((a, b) => parseDate(a.timestamp) - parseDate(b.timestamp)) this.props.actions.updateSelected(eventsToSelect) } } - handleSelectNarrative(narrative) { - this.props.actions.updateNarrative(narrative); - } - - handleTagFilter(tag) { - this.props.actions.updateTagFilters(tag); - } - - updateTimerange(timeRange) { - this.props.actions.updateTimeRange(timeRange); - } - getCategoryColor(category='other') { return this.props.ui.style.categories[category] || this.props.ui.style.categories['other'] } getNarrativeLinks(event) { - const narrative = this.props.domain.narratives.find(nv => nv.id === event.narrative); - if (narrative) return narrative.byId[event.id]; - return null; + const narrative = this.props.domain.narratives.find(nv => nv.id === event.narrative) + if (narrative) return narrative.byId[event.id] + return null + } + + setNarrative(narrative) { + // only handleSelect if narrative is not null + if (!!narrative) + this.handleSelect([ narrative.steps[0] ]) + this.props.actions.updateNarrative(narrative) + } + + moveInNarrative(amt) { + const { current } = this.props.app.narrativeState + const { narrative } = this.props.app + + if (amt === 1) { + this.handleSelect([ narrative.steps[current + 1] ]) + this.props.actions.incrementNarrativeCurrent() + } + if (amt === -1) { + this.handleSelect([ narrative.steps[current - 1] ]) + this.props.actions.decrementNarrativeCurrent() + } } render() { + const { actions, app, domain, ui } = this.props return (
    this.getCategoryColor(category) }} /> - {(this.props.app.narrative !== null) - ? - : '' - } + this.moveInNarrative(1), + onPrev: () => this.moveInNarrative(-1), + onSelectNarrative: this.setNarrative + }} + /> this.props.actions.updateSelected([])} + onToggleCardstack={() => actions.updateSelected([])} getNarrativeLinks={event => this.getNarrativeLinks(event)} getCategoryColor={category => this.getCategoryColor(category)} /> this.props.actions.toggleInfoPopup()} + ui={ui} + app={app} + toggle={() => actions.toggleInfoPopup()} /> - {this.props.app.source ? ( + {app.source ? ( { - this.props.actions.updateSource(null)} + actions.updateSource(null)} } /> ) : null}
    - ); + ) } } function mapDispatchToProps(dispatch) { return { actions: bindActionCreators(actions, dispatch) - }; + } } function injectSource(id) { @@ -167,4 +180,4 @@ function injectSource(id) { export default connect( state => state, mapDispatchToProps, -)(Dashboard); +)(Dashboard) diff --git a/src/components/MapEvents.jsx b/src/components/MapEvents.jsx index 11aeb78..70cac85 100644 --- a/src/components/MapEvents.jsx +++ b/src/components/MapEvents.jsx @@ -33,8 +33,10 @@ class MapEvents extends React.Component { }) if (this.props.narrative) { - const { byId } = this.props.narrative - const eventsInNarrative = events.filter(e => byId.hasOwnProperty(e.id)) + const { steps } = this.props.narrative + const onlyIfInNarrative = e => steps.map(s => s.id).includes(e.id) + const eventsInNarrative = events.filter(onlyIfInNarrative) + if (eventsInNarrative.length <= 0) { styleProps = { ...styleProps, diff --git a/src/components/MapNarratives.jsx b/src/components/MapNarratives.jsx index 2c9d777..8b92bb8 100644 --- a/src/components/MapNarratives.jsx +++ b/src/components/MapNarratives.jsx @@ -1,42 +1,42 @@ -import React from 'react'; -import { Portal } from 'react-portal'; +import React from 'react' +import { Portal } from 'react-portal' class MapNarratives extends React.Component { projectPoint(location) { - const latLng = new L.LatLng(location[0], location[1]); + const latLng = new L.LatLng(location[0], location[1]) return { x: this.props.map.latLngToLayerPoint(latLng).x + this.props.mapTransformX, y: this.props.map.latLngToLayerPoint(latLng).y + this.props.mapTransformY - }; + } } getNarrativeStyle(narrativeId) { const styleName = (narrativeId && narrativeId in this.props.narrativeProps) ? narrativeId - : 'default'; - return this.props.narrativeProps[styleName]; + : 'default' + return this.props.narrativeProps[styleName] } getStrokeWidth(narrative, step) { - if (!step) return 0; - return this.getNarrativeStyle(narrative.id).strokeWidth; + if (!step) return 0 + return this.getNarrativeStyle(narrative.id).strokeWidth } getStrokeDashArray(narrative, step) { - if (!step) return 'none'; - return (this.getNarrativeStyle(narrative.id).style === 'dotted') ? "2px 5px" : 'none'; + if (!step) return 'none' + return (this.getNarrativeStyle(narrative.id).style === 'dotted') ? "2px 5px" : 'none' } getStroke(narrative, step) { - if (!step || this.props.narrative === null) return 'none'; - return this.getNarrativeStyle(narrative.id).stroke; + if (!step || this.props.narrative === null) return 'none' + return this.getNarrativeStyle(narrative.id).stroke } getStrokeOpacity(narrative, step) { - if (this.props.narrative === null) return 0; - if (!step || narrative.id !== this.props.narrative.id) return 0.1; - return 1; + if (this.props.narrative === null) return 0 + if (!step || narrative.id !== this.props.narrative.id) return 0.1 + return 1 } hasNoLocation(step) { @@ -44,14 +44,14 @@ class MapNarratives extends React.Component { } renderNarrativeStep(allSteps, step, idx, n) { - const { x, y } = this.projectPoint([step.latitude, step.longitude]); - const step2 = allSteps[idx + 1]; + const { x, y } = this.projectPoint([step.latitude, step.longitude]) + const step2 = allSteps[idx + 1] // don't draw if one of the steps has no location if (this.hasNoLocation(step) || this.hasNoLocation(step2)) return null - const p2 = this.projectPoint([step2.latitude, step2.longitude]); + const p2 = this.projectPoint([step2.latitude, step2.longitude]) return ( - ); + ) } renderNarrative(n) { - const steps = n.steps.slice(0, n.steps.length - 1); + const steps = n.steps.slice(0, n.steps.length - 1) return ( @@ -84,14 +84,14 @@ class MapNarratives extends React.Component { } render() { - if (this.props.narrative === null) return (
    ); + if (this.props.narrative === null) return (
    ) return ( {this.props.narratives.map(n => this.renderNarrative(n))} - ); + ) } } -export default MapNarratives; +export default MapNarratives diff --git a/src/components/NarrativeCard.js b/src/components/NarrativeCard.js index 67a6bbc..721f727 100644 --- a/src/components/NarrativeCard.js +++ b/src/components/NarrativeCard.js @@ -1,86 +1,65 @@ -import React from 'react'; +import React from 'react' import { connect } from 'react-redux' +import { selectActiveNarrative } from '../selectors' -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 }); - } - } - - componentDidMount() { - const step = this.props.narrative.steps[this.state.step]; - this.props.onSelect([step]); - } - - componentDidUpdate(prevProps, prevState) { - if (prevProps.narrative === this.props.narrative && this.state.step !== prevState.step) { - const step = this.props.narrative.steps[this.state.step]; - this.props.onSelect([step]); - } else if (prevProps.narrative !== this.props.narrative && this.props.narrative !== null) { - this.setState({ - step: 0 - }, () => { - const step = this.props.narrative.steps[this.state.step]; - this.props.onSelect([step]); - }); - } - } - - renderClose() { +function NarrativeCard ({ narrative, methods }) { + const { onSelectNarrative, onNext, onPrev } = methods + function renderClose() { return ( ) } - render() { - if (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}

    -
    - location_on - {this.state.step + 1}/{steps.length}. {step.location} -
    -
    -
    this.goToPrevKeyFrame()}>←
    -
    = this.props.narrative.steps.length - 1) ? 'disabled ' : ''} action`} onClick={() => this.goToNextKeyFrame()}>→
    -
    + function _renderActions(current, steps) { + const prevExists = current !== 0 + const nextExists = current < steps.length - 1 + return ( +
    +
    - ); - } - return (
    ); +
    → +
    +
    + ) + } + + // no display if no narrative + if (!narrative) return null + + const { steps, current } = narrative + + if (steps[current]) { + const step = steps[current] + + return ( +
    + {renderClose()} +

    {narrative.label}

    +

    {narrative.description}

    +
    + location_on + {current + 1}/{steps.length}. {step.location} +
    + {_renderActions(current, steps)} +
    + ) + } else { + return null } } function mapStateToProps(state) { return { - narrative: state.app.narrative + narrative: selectActiveNarrative(state) } } -export default connect(mapStateToProps)(NarrativeCard); +export default connect(mapStateToProps)(NarrativeCard) diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx index ae42fb2..28aba21 100644 --- a/src/components/Timeline.jsx +++ b/src/components/Timeline.jsx @@ -34,18 +34,20 @@ class Timeline extends React.Component { } render() { + const { isNarrative, app, ui } = this.props let classes = `timeline-wrapper ${(this.state.isFolded) ? ' folded' : ''}`; - classes += (this.props.app.narrative !== null) ? ' narrative-mode' : ''; + classes += (app.narrative !== null) ? ' narrative-mode' : ''; return (
    { this.onClickArrow(); }} + hideInfo={isNarrative} />
    -
    +
    ); @@ -54,6 +56,7 @@ class Timeline extends React.Component { function mapStateToProps(state) { return { + isNarrative: !!state.app.narrative, domain: { events: state.domain.events, categories: selectors.selectCategories(state), diff --git a/src/components/Toolbar.jsx b/src/components/Toolbar.jsx index 75826c5..cb3b3da 100644 --- a/src/components/Toolbar.jsx +++ b/src/components/Toolbar.jsx @@ -5,27 +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 ToolbarBottomActions from './ToolbarBottomActions.jsx'; +// import ToolbarBottomActions from './ToolbarBottomActions.jsx'; import copy from '../js/data/copy.json'; -import { isNotNullNorUndefined, trimAndEllipse } from '../js/utilities.js'; +import { trimAndEllipse } from '../js/utilities.js'; class Toolbar extends React.Component { - constructor(props) { - super(props); - - this.state = { - tabNum: -1 - }; + super(props) + this.state = { _selected: -1 } } - toggleTab(tabNum) { - this.setState({ tabNum: (this.state.tabNum === tabNum) ? -1 : tabNum }); + selectTab(selected) { + const _selected = (this.state._selected === selected) ? -1 : selected + this.setState({ _selected }); } renderClosePanel() { return ( -
    this.toggleTab(-1)}> +
    this.selectTab(-1)}>
    ); @@ -49,11 +46,8 @@ class Toolbar extends React.Component { } goToNarrative(narrative) { - this.setState({ - tabNum: -1 - }, () => { - this.props.onSelectNarrative(narrative); - }); + this.selectTab(-1) // set all unselected within this component + this.props.methods.onSelectNarrative(narrative); } renderToolbarNarrativePanel() { @@ -64,7 +58,7 @@ class Toolbar extends React.Component { {this.props.narratives.map((narr) => { return (
    - @@ -94,12 +88,12 @@ class Toolbar extends React.Component { return ''; } - renderToolbarTab(tabNum, label) { - const isActive = (this.state.tabNum === tabNum); + renderToolbarTab(_selected, label) { + const isActive = (this.state._selected === _selected); let classes = (isActive) ? 'toolbar-tab active' : 'toolbar-tab'; return ( -
    { this.toggleTab(tabNum); }}> +
    { this.selectTab(_selected); }}> timeline
    {label}
    @@ -118,20 +112,17 @@ class Toolbar extends React.Component { {this.renderToolbarTab(0, 'Focus stories')} {this.renderToolbarTab(1, 'Explore freely')}
    - + {/* */}
    ) } renderToolbarPanels() { - let classes = (this.state.tabNum !== -1) ? 'toolbar-panels' : 'toolbar-panels folded'; - + let classes = (this.state._selected >= 0) ? 'toolbar-panels' : 'toolbar-panels folded'; return (
    {this.renderClosePanel()} - + {this.renderToolbarNarrativePanel()} {this.renderToolbarTagPanel()} @@ -142,12 +133,12 @@ class Toolbar extends React.Component { renderToolbarNavs() { if (this.props.narratives) { return this.props.narratives.map((nar, idx) => { - const isActive = (idx === this.state.tab); + const isActive = (idx === this.state._selected); let classes = (isActive) ? 'toolbar-tab active' : 'toolbar-tab'; return ( -
    { this.toggleTab(idx); }}> +
    { this.selectTab(idx); }}>
    {nar.label}
    ); @@ -168,15 +159,14 @@ class Toolbar extends React.Component { {this.renderToolbarTab(0, 'Narratives')} {(isTags) ? this.renderToolbarTab(1, 'Explore by tag') : ''}
    - + {/* */}
    ) } render() { - const isNarrative = isNotNullNorUndefined(this.props.narrative); + const { isNarrative } = this.props + return (
    {this.renderToolbarTabs()} diff --git a/src/components/presentational/TimelineHeader.js b/src/components/presentational/TimelineHeader.js index d75a605..f9ece09 100644 --- a/src/components/presentational/TimelineHeader.js +++ b/src/components/presentational/TimelineHeader.js @@ -1,11 +1,11 @@ import React from 'react'; -const TimelineHeader = ({ title, date0, date1, onClick }) => ( -
    -
    onClick()}> -

    +const TimelineHeader = ({ title, date0, date1, onClick, hideInfo }) => ( +
    +
    onClick()}> +

    -
    +

    {title}

    {date0} - {date1}

    diff --git a/src/js/utilities.js b/src/js/utilities.js index 4ba3755..924e1f5 100644 --- a/src/js/utilities.js +++ b/src/js/utilities.js @@ -73,11 +73,41 @@ export function formatter(datetime) { return d3.timeFormat("%d %b, %H:%M")(datetime); } +export const parseTimestamp = ts => d3.timeParse("%Y-%m-%dT%H:%M:%S")(ts); + +export function compareTimestamp (a, b) { + return (parseTimestamp(a.timestamp) > parseTimestamp(b.timestamp)); +} + +/** + * Inset the full source represenation from 'allSources' into an event. The + * function is 'curried' to allow easy use with maps. To use for a single + * source, call with two sets of parentheses: + * const src = insetSourceFrom(sources)(anEvent) + */ +export function insetSourceFrom(allSources) { + return (event) => { + let sources + if (!event.sources) { + sources = [] + } else { + sources = event.sources.map(id => ( + allSources.hasOwnProperty(id) ? allSources[id] : null + )) + } + return { + ...event, + sources + } + } + +} + /** * Debugging function: put in place of a mapStateToProps function to * view that source modal by default */ -function injectSource(id) { +export function injectSource(id) { return state => ({ ...state, app: { @@ -86,4 +116,3 @@ function injectSource(id) { } }) } - diff --git a/src/reducers/app.js b/src/reducers/app.js index c8a8ea6..aed37e2 100644 --- a/src/reducers/app.js +++ b/src/reducers/app.js @@ -1,6 +1,6 @@ -import initial from '../store/initial.js'; +import initial from '../store/initial.js' -import { parseDate } from '../js/utilities.js'; +import { parseDate } from '../js/utilities.js' import { UPDATE_HIGHLIGHTED, @@ -8,6 +8,8 @@ import { UPDATE_TAGFILTERS, UPDATE_TIMERANGE, UPDATE_NARRATIVE, + INCREMENT_NARRATIVE_CURRENT, + DECREMENT_NARRATIVE_CURRENT, UPDATE_SOURCE, RESET_ALLFILTERS, TOGGLE_LANGUAGE, @@ -18,63 +20,69 @@ import { TOGGLE_NOTIFICATIONS, FETCH_ERROR, FETCH_SOURCE_ERROR, -} from '../actions'; +} from '../actions' function updateHighlighted(appState, action) { return Object.assign({}, appState, { highlighted: action.highlighted - }); + }) } function updateSelected(appState, action) { return Object.assign({}, appState, { selected: action.selected - }); + }) } 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 { + ...appState, + narrative: action.narrative, + narrativeState: { + current: !!action.narrative ? 0 : null + } + } +} - return Object.assign({}, appState, { - narrative: action.narrative, - filters: Object.assign({}, appState.filters, { - timerange: [new Date(minDate), new Date(maxDate)] - }), - }); +function incrementNarrativeCurrent(appState, action) { + return { + ...appState, + narrativeState: { + current: appState.narrativeState.current += 1 + } + } +} + +function decrementNarrativeCurrent(appState, action) { + return { + ...appState, + narrativeState: { + current: appState.narrativeState.current -= 1 + } } } function updateTagFilters(appState, action) { - const tagFilters = appState.filters.tags.slice(0); + const tagFilters = appState.filters.tags.slice(0) const nextActiveState = action.tag.active function traverseNode(node) { - const tagFilter = tagFilters.find(tF => tF.key === node.key); - node.active = nextActiveState; - if (!tagFilter) tagFilters.push(node); + const tagFilter = tagFilters.find(tF => tF.key === node.key) + node.active = nextActiveState + if (!tagFilter) tagFilters.push(node) if (node && Object.keys(node.children).length > 0) { - Object.values(node.children).forEach((childNode) => { traverseNode(childNode); }); + Object.values(node.children).forEach((childNode) => { traverseNode(childNode) }) } } - traverseNode(action.tag); + traverseNode(action.tag) return Object.assign({}, appState, { filters: Object.assign({}, appState.filters, { tags: tagFilters }) - }); + }) } function updateTimeRange(appState, action) { // XXX @@ -82,7 +90,7 @@ function updateTimeRange(appState, action) { // XXX filters: Object.assign({}, appState.filters, { timerange: action.timerange }), - }); + }) } function resetAllFilters(appState) { // XXX @@ -96,26 +104,26 @@ function resetAllFilters(appState) { // XXX ], }), selected: [], - }); + }) } function toggleLanguage(appState, action) { - let otherLanguage = (appState.language === 'es-MX') ? 'en-US' : 'es-MX'; + let otherLanguage = (appState.language === 'es-MX') ? 'en-US' : 'es-MX' return Object.assign({}, appState, { language: action.language || otherLanguage - }); + }) } function toggleMapView(appState, action) { - const isLayerInView = !appState.views[layer]; - const newViews = {}; - newViews[layer] = isLayerInView; - const views = Object.assign({}, appState.views, newViews); + 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 updateSource(appState, action) { @@ -138,7 +146,7 @@ function toggleFetchingDomain(appState, action) { flags: Object.assign({}, appState.flags, { isFetchingDomain: !appState.flags.isFetchingDomain }) - }); + }) } function toggleFetchingSources(appState, action) { @@ -146,7 +154,7 @@ function toggleFetchingSources(appState, action) { flags: Object.assign({}, appState.flags, { isFetchingSources: !appState.flags.isFetchingSources }) - }); + }) } function toggleInfoPopup(appState, action) { @@ -154,7 +162,7 @@ function toggleInfoPopup(appState, action) { flags: Object.assign({}, appState.flags, { isInfopopup: !appState.flags.isInfopopup }) - }); + }) } function toggleNotifications(appState, action) { @@ -162,7 +170,7 @@ function toggleNotifications(appState, action) { flags: Object.assign({}, appState.flags, { isNotification: !appState.flags.isNotification }) - }); + }) } function fetchSourceError(appState, action) { @@ -180,38 +188,42 @@ function fetchSourceError(appState, action) { function app(appState = initial.app, action) { switch (action.type) { case UPDATE_HIGHLIGHTED: - return updateHighlighted(appState, action); + return updateHighlighted(appState, action) case UPDATE_SELECTED: - return updateSelected(appState, action); + return updateSelected(appState, action) case UPDATE_TAGFILTERS: - return updateTagFilters(appState, action); + return updateTagFilters(appState, action) case UPDATE_TIMERANGE: - return updateTimeRange(appState, action); + return updateTimeRange(appState, action) case UPDATE_NARRATIVE: - return updateNarrative(appState, action); + return updateNarrative(appState, action) + case INCREMENT_NARRATIVE_CURRENT: + return incrementNarrativeCurrent(appState, action) + case DECREMENT_NARRATIVE_CURRENT: + return decrementNarrativeCurrent(appState, action) case UPDATE_SOURCE: - return updateSource(appState, action); + return updateSource(appState, action) case RESET_ALLFILTERS: - return resetAllFilters(appState, action); + return resetAllFilters(appState, action) case TOGGLE_LANGUAGE: - return toggleLanguage(appState, action); + return toggleLanguage(appState, action) case TOGGLE_MAPVIEW: - return toggleMapView(appState, action); + return toggleMapView(appState, action) case FETCH_ERROR: - return fetchError(appState, action); + return fetchError(appState, action) case TOGGLE_FETCHING_DOMAIN: - return toggleFetchingDomain(appState, action); + return toggleFetchingDomain(appState, action) case TOGGLE_FETCHING_SOURCES: - return toggleFetchingSources(appState, action); + return toggleFetchingSources(appState, action) case TOGGLE_INFOPOPUP: - return toggleInfoPopup(appState, action); + return toggleInfoPopup(appState, action) case TOGGLE_NOTIFICATIONS: - return toggleNotifications(appState, action); + return toggleNotifications(appState, action) case FETCH_SOURCE_ERROR: - return fetchSourceError(appState, action); + return fetchSourceError(appState, action) default: - return appState; + return appState } } -export default app; +export default app diff --git a/src/scss/card.scss b/src/scss/card.scss index 8979937..a801f0e 100644 --- a/src/scss/card.scss +++ b/src/scss/card.scss @@ -2,10 +2,10 @@ box-sizing: border-box; margin: 1px 0 0 0; padding: 15px; - border: 1px solid rgba(0, 0, 0, 0); - border-radius: 3px; + border: 1px solid $black; + // border-radius: 3px; transition: 0.2 ease; - background: $offwhite; + background: $darkwhite; color: $darkgrey; box-shadow: 0 19px 19px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.22); font-size: $large; @@ -39,10 +39,13 @@ .card-row, .card-col { display: flex; flex-direction: row; - border-bottom: 1px solid $lightwhite; margin: 5px 0 10px 0; padding-bottom: 10px; + &.details { + border-bottom: 1px solid $lightwhite; + } + .card-cell { flex: 1; } @@ -120,6 +123,7 @@ height: 0; overflow: hidden; } + } .card-toggle p { @@ -197,6 +201,7 @@ .summary { overflow: auto; margin-top: 0; + border-bottom: none; } .tag { @@ -204,4 +209,12 @@ margin: 0; margin-right: 5px; } + + &.selected { + background: $offwhite; + } + + .card-row { + border-color: darkgray; + } } diff --git a/src/scss/cardstack.scss b/src/scss/cardstack.scss index 1879969..0b83da3 100644 --- a/src/scss/cardstack.scss +++ b/src/scss/cardstack.scss @@ -1,7 +1,9 @@ @import 'burger'; @import 'card'; -$card-width: 500px; +$card-width: 370px; +$narrative-info-max-height: 170px; +$timeline-height: 170px; .card-stack { position: absolute; @@ -9,11 +11,17 @@ $card-width: 500px; right: 10px; max-height: calc(100% - 208px); height: auto; - overflow: auto; + overflow: hidden; box-shadow: 0 19px 38px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.22); z-index: $header; color: white; - -webkit-font-smoothing: antialiased; + + &.narrative-mode { + right: auto; + left: 10px; + top: $narrative-info-max-height + 12px; + height: calc(100% - #{$narrative-info-max-height} - #{$timeline-height} - 12px); + } &.full-height { max-height: calc(100% - 20px); diff --git a/src/scss/narrativecard.scss b/src/scss/narrativecard.scss index ace0435..36ebc63 100644 --- a/src/scss/narrativecard.scss +++ b/src/scss/narrativecard.scss @@ -1,3 +1,5 @@ +$narrative-info-width: 370px; + /* NARRATIVE INFO */ @@ -5,8 +7,9 @@ NARRATIVE INFO position: fixed; top: 10px; left: 10px; - height: auto; - width: 370px; + // height: auto; + height: 170px; + width: $narrative-info-width; box-sizing: border-box; padding: 15px; max-height: calc(100% - 250px); diff --git a/src/scss/timeline.scss b/src/scss/timeline.scss index 2205ce1..24666a4 100644 --- a/src/scss/timeline.scss +++ b/src/scss/timeline.scss @@ -1,4 +1,3 @@ - .timeline-wrapper { position: fixed; box-sizing: border-box; @@ -67,6 +66,10 @@ } .timeline-info { + &.hidden { + display: none; + } + width: calc(#{$card-width} - 20px); position: absolute; margin-top: -70px; margin-left: 10px; diff --git a/src/selectors/index.js b/src/selectors/index.js index 0ef170d..d560a00 100644 --- a/src/selectors/index.js +++ b/src/selectors/index.js @@ -1,28 +1,32 @@ import { createSelector} from 'reselect' +import { parseTimestamp, compareTimestamp, insetSourceFrom } from '../js/utilities' // 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 getNarratives = state => state.domain.narratives; -export const getSelected = state => state.app.selected; +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 getActiveNarrative = state => state.app.narrative +export const getActiveStep = state => state.app.narrativeState.current +export const getSelected = state => state.app.selected 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 getSources = state => { - if (process.env.features.USE_SOURCES) return state.domain.sources; - return []; + if (process.env.features.USE_SOURCES) return state.domain.sources + return [] } -export const getNotifications = state => state.domain.notifications; -export const getTagTree = state => state.domain.tags; -export const getTagsFilter = state => state.app.filters.tags; -export const getTimeRange = state => state.app.filters.timerange; +export const getNotifications = state => state.domain.notifications +export const getTagTree = state => state.domain.tags +export const getTagsFilter = state => state.app.filters.tags +export const getTimeRange = state => state.app.filters.timerange + + /** * Some handy helpers */ -const parseTimestamp = ts => d3.timeParse("%Y-%m-%dT%H:%M:%S")(ts); /** * Given an event and all tags, @@ -30,11 +34,11 @@ const parseTimestamp = ts => d3.timeParse("%Y-%m-%dT%H:%M:%S")(ts); */ function isTaggedIn(event, tagFilters) { if (event.tags) { - const tagsInEvent = event.tags.split(","); + const tagsInEvent = event.tags.split(",") const isTagged = tagsInEvent.some((tag) => { - return tagFilters.find(tF => (tF.key === tag && tF.active)); - }); - return isTagged; + return tagFilters.find(tF => (tF.key === tag && tF.active)) + }) + return isTagged } else { return false } @@ -48,7 +52,7 @@ function isNoTags(tagFilters) { tagFilters.length === 0 || !process.env.features.USE_TAGS || tagFilters.every(t => !t.active) - ); + ) } /** @@ -59,7 +63,7 @@ function isTimeRangedIn(event, timeRange) { return ( timeRange[0] < parseTimestamp(event.timestamp) && parseTimestamp(event.timestamp) < timeRange[1] - ); + ) } /** @@ -71,64 +75,79 @@ export const selectEvents = createSelector( (events, tagFilters, timeRange) => { return events.reduce((acc, event) => { - const isTagged = isTaggedIn(event, tagFilters) || isNoTags(tagFilters); - const isTimeRanged = isTimeRangedIn(event, timeRange); + const isTagged = isTaggedIn(event, tagFilters) || isNoTags(tagFilters) + const isTimeRanged = isTimeRangedIn(event, timeRange) if (isTimeRanged && isTagged) { - const eventClone = Object.assign({}, event); - acc[event.id] = eventClone; + const eventClone = Object.assign({}, event) + acc[event.id] = eventClone } - return acc; - }, []); -}); + return acc + }, []) +}) /** * Of all available events, selects those that fall within the time range, * and if TAGS are being used, select them if their tags are enabled */ export const selectNarratives = createSelector( - [getEvents, getNarratives, getTagsFilter, getTimeRange], - (events, narrativeMetadata, tagFilters, timeRange) => { + [getEvents, getNarratives, getTagsFilter, getTimeRange, getSources], + (events, narrativesMeta, tagFilters, timeRange, sources) => { - const narratives = {}; - events.forEach((evt) => { - const isTagged = isTaggedIn(evt, tagFilters) || isNoTags(tagFilters); - const isTimeRanged = isTimeRangedIn(evt, timeRange); - const isInNarrative = evt.narratives.length > 0; + const narratives = {} + const narrativeSkeleton = id => ({ id, steps: [] }) - evt.narratives.map(narrative => { - if (!narratives[narrative]) { - narratives[narrative] = { id: narrative, steps: [], byId: {} }; - } + /* populate narratives dict with events */ + events.forEach(evt => { + const isTagged = isTaggedIn(evt, tagFilters) || isNoTags(tagFilters) + const isTimeRanged = isTimeRangedIn(evt, timeRange) + const isInNarrative = evt.narratives.length > 0 - if (isInNarrative) { - narratives[narrative].steps.push(evt); - narratives[narrative].byId[evt.id] = { next: null, prev: null }; - } + evt.narratives.forEach(narrative => { + // initialise + if (!narratives[narrative]) + narratives[narrative] = narrativeSkeleton(narrative) + + // add evt to steps + if (isInNarrative) + // NB: insetSourceFrom is a 'curried' function to allow with maps + narratives[narrative].steps.push(insetSourceFrom(sources)(evt)) }) - }); + }) - Object.keys(narratives).forEach((key) => { - const steps = narratives[key].steps; - steps.sort((a, b) => { - return (parseTimestamp(a.timestamp) > parseTimestamp(b.timestamp)); - }); + /* sort steps by time */ + Object.keys(narratives).forEach(key => { + const steps = narratives[key].steps - 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; - }); + steps.sort(compareTimestamp) - if (narrativeMetadata.find(n => n.id === key)) { - narratives[key] = Object.assign(narrativeMetadata.find(n => n.id === key), narratives[key]); + // 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 (narrativesMeta.find(n => n.id === key)) { + narratives[key] = { + ...narrativesMeta.find(n => n.id === key), + ...narratives[key] + } } - }); + }) - return Object.values(narratives); -}); + return Object.values(narratives) +}) +/** Aggregate information about the narrative and the current step into + * a single object. If narrative is null, the whole object is null. + */ +export const selectActiveNarrative = createSelector( + [getActiveNarrative, getActiveStep], + (narrative, current) => !!narrative + ? { ...narrative, current } + : null +) /** * Of all the filtered events, group them by location and return a list of * locations with at least one event in it, based on the time range and tags @@ -137,12 +156,12 @@ export const selectLocations = createSelector( [selectEvents], (events) => { - const selectedLocations = {}; + const selectedLocations = {} events.forEach(event => { - const location = event.location; + const location = event.location if (selectedLocations[location]) { - selectedLocations[location].events.push(event); + selectedLocations[location].events.push(event) } else { selectedLocations[location] = { label: location, @@ -153,9 +172,11 @@ export const selectLocations = createSelector( } }) - return Object.values(selectedLocations); + return Object.values(selectedLocations) } -); +) + + /** * Of all the sources, select those that are relevant to the selected events. @@ -167,21 +188,7 @@ export const selectSelected = createSelector( return [] } - // NB: return source object if exists, otherwise null - const srcs = selected - .map(e => e.sources) - .map(_sources => { - if (!_sources) return []; - return _sources.map(id => ( - sources.hasOwnProperty(id) ? sources[id] : null - )) - } - ) - - return selected.map((s, idx) => ({ - ...s, - sources: srcs[idx] - })) + return selected.map(insetSourceFrom(sources)) } ) @@ -191,7 +198,7 @@ export const selectSelected = createSelector( export const selectCategories = createSelector( [getCategories], (categories) => categories -); +) /** @@ -201,23 +208,23 @@ export const selectCategories = createSelector( export const selectTagList = createSelector( [getTagTree], (tags) => { - const tagList = []; - let depth = 0; + const tagList = [] + let depth = 0 function traverseNode(node, depth) { - node.active = (!node.hasOwnProperty('active')) ? false : node.active; - node.depth = depth; + node.active = (!node.hasOwnProperty('active')) ? false : node.active + node.depth = depth if (node.active) tagList.push(node) if (Object.keys(node.children).length > 0) { Object.values(node.children).forEach((childNode) => { - traverseNode(childNode, depth + 1); - }); + traverseNode(childNode, depth + 1) + }) } } if (tags && tags !== undefined) { if (tags.key && tags.children) traverseNode(tags, depth) } - return tagList; + return tagList } ) diff --git a/src/store/initial.js b/src/store/initial.js index 1c0a624..c18baa0 100644 --- a/src/store/initial.js +++ b/src/store/initial.js @@ -33,6 +33,9 @@ const initial = { selected: [], source: null, narrative: null, + narrativeState: { + current: null + }, filters: { timerange: [ d3.timeParse("%Y-%m-%dT%H:%M:%S")("2013-02-23T12:00:00"),