+
{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"),