mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-11 21:08:36 +03:00
Merge pull request #159 from forensic-architecture/feature/refactor-filters-and-narratives-to-associations
Feature/refactor filters and narratives to associations
This commit is contained in:
@@ -18,7 +18,7 @@ The URLs for these endpoints, as well as other configurable settings in your tim
|
||||
| SITES_EXT | Endpoint for sites, concatenated with SERVER_ROOT | String | Yes |
|
||||
| MAP_ANCHOR | Geographic coordinates for original map anchor | Array of numbers | No |
|
||||
| MAPBOX_TOKEN | Access token for Mapbox satellite imagery | String | No |
|
||||
| features.USE_FILTERS | Enable / Disable filters | boolean | No |
|
||||
| features.USE_ASSOCIATIONS | Enable / Disable filters | boolean | No |
|
||||
| features.USE_SEARCH | Enable / Disable search | boolean | No |
|
||||
| features.USE_SITES | Enable / Disable sites | boolean | No |
|
||||
|
||||
|
||||
@@ -4,9 +4,8 @@ module.exports = {
|
||||
SERVER_ROOT: 'http://localhost:4040',
|
||||
EVENTS_EXT: '/api/timemap_data/export_events/deeprows',
|
||||
CATEGORIES_EXT: '/api/timemap_data/export_categories/rows',
|
||||
FILTERS_EXT: '/api/timemap_data/export_filters/tree',
|
||||
ASSOCIATIONS_EXT: '/api/timemap_data/export_associations/deeprows',
|
||||
SOURCES_EXT: '/api/timemap_data/export_sources/deepids',
|
||||
NARRATIVE_EXT: '',
|
||||
SITES_EXT: '',
|
||||
SHAPES_EXT: '',
|
||||
DATE_FMT: 'MM/DD/YYYY',
|
||||
@@ -22,13 +21,11 @@ module.exports = {
|
||||
// tiles: 'your-mapbox-account-name/x5678-map-id'
|
||||
},
|
||||
features: {
|
||||
USE_CATEGORIES: false,
|
||||
CATEGORIES_AS_FILTERS: false,
|
||||
USE_FILTERS: true,
|
||||
FILTERS_AS_NARRATIVES: false,
|
||||
USE_NARRATIVES: false,
|
||||
USE_CATEGORIES: true,
|
||||
CATEGORIES_AS_FILTERS: true,
|
||||
USE_ASSOCIATIONS: true,
|
||||
USE_SOURCES: true,
|
||||
USE_COVER: false,
|
||||
USE_COVER: true,
|
||||
USE_SEARCH: false,
|
||||
USE_SITES: false,
|
||||
USE_SHAPES: false,
|
||||
|
||||
@@ -5,9 +5,8 @@ import { urlFromEnv } from '../common/utilities'
|
||||
// const CONFIG_URL = urlFromEnv('CONFIG_EXT')
|
||||
const EVENT_DATA_URL = urlFromEnv('EVENTS_EXT')
|
||||
const CATEGORY_URL = urlFromEnv('CATEGORIES_EXT')
|
||||
const FILTERS_URL = urlFromEnv('FILTERS_EXT')
|
||||
const ASSOCIATIONS_URL = urlFromEnv('ASSOCIATIONS_EXT')
|
||||
const SOURCES_URL = urlFromEnv('SOURCES_EXT')
|
||||
const NARRATIVE_URL = urlFromEnv('NARRATIVES_EXT')
|
||||
const SITES_URL = urlFromEnv('SITES_EXT')
|
||||
const SHAPES_URL = urlFromEnv('SHAPES_EXT')
|
||||
|
||||
@@ -50,28 +49,14 @@ export function fetchDomain () {
|
||||
.catch(() => handleError(domainMsg('categories')))
|
||||
}
|
||||
|
||||
let narPromise = Promise.resolve([])
|
||||
if (features.USE_NARRATIVES) {
|
||||
narPromise = fetch(NARRATIVE_URL)
|
||||
.then(response => response.json())
|
||||
.catch(() => handleError(domainMsg('narratives')))
|
||||
}
|
||||
|
||||
let sitesPromise = Promise.resolve([])
|
||||
if (features.USE_SITES) {
|
||||
sitesPromise = fetch(SITES_URL)
|
||||
.then(response => response.json())
|
||||
.catch(() => handleError(domainMsg('sites')))
|
||||
}
|
||||
|
||||
let filtersPromise = Promise.resolve([])
|
||||
if (features.USE_FILTERS) {
|
||||
if (!FILTERS_URL) {
|
||||
filtersPromise = Promise.resolve(handleError('USE_FILTERS is true, but you have not provided a FILTERS_EXT'))
|
||||
let associationsPromise = Promise.resolve([])
|
||||
if (features.USE_ASSOCIATIONS) {
|
||||
if (!ASSOCIATIONS_URL) {
|
||||
associationsPromise = Promise.resolve(handleError('USE_ASSOCIATIONS is true, but you have not provided a ASSOCIATIONS_EXT'))
|
||||
} else {
|
||||
filtersPromise = fetch(FILTERS_URL)
|
||||
associationsPromise = fetch(ASSOCIATIONS_URL)
|
||||
.then(response => response.json())
|
||||
.catch(() => handleError(domainMsg('filters')))
|
||||
.catch(() => handleError(domainMsg('associations')))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +71,13 @@ export function fetchDomain () {
|
||||
}
|
||||
}
|
||||
|
||||
let sitesPromise = Promise.resolve([])
|
||||
if (features.USE_SITES) {
|
||||
sitesPromise = fetch(SITES_URL)
|
||||
.then(response => response.json())
|
||||
.catch(() => handleError(domainMsg('sites')))
|
||||
}
|
||||
|
||||
let shapesPromise = Promise.resolve([])
|
||||
if (features.USE_SHAPES) {
|
||||
shapesPromise = fetch(SHAPES_URL)
|
||||
@@ -96,21 +88,19 @@ export function fetchDomain () {
|
||||
return Promise.all([
|
||||
eventPromise,
|
||||
catPromise,
|
||||
narPromise,
|
||||
sitesPromise,
|
||||
filtersPromise,
|
||||
associationsPromise,
|
||||
sourcesPromise,
|
||||
sitesPromise,
|
||||
shapesPromise
|
||||
])
|
||||
.then(response => {
|
||||
const result = {
|
||||
events: response[0],
|
||||
categories: response[1],
|
||||
narratives: response[2],
|
||||
sites: response[3],
|
||||
filters: response[4],
|
||||
sources: response[5],
|
||||
shapes: response[6],
|
||||
associations: response[2],
|
||||
sources: response[3],
|
||||
sites: response[4],
|
||||
shapes: response[5],
|
||||
notifications
|
||||
}
|
||||
if (Object.values(result).some(resp => resp.hasOwnProperty('error'))) {
|
||||
|
||||
2
src/common/constants.js
Normal file
2
src/common/constants.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export const FILTER_MODE = 'FILTER'
|
||||
export const NARRATIVE_MODE = 'NARRATIVE'
|
||||
@@ -74,9 +74,10 @@ export function insetSourceFrom (allSources) {
|
||||
if (!event.sources) {
|
||||
sources = []
|
||||
} else {
|
||||
sources = event.sources.map(id => (
|
||||
allSources.hasOwnProperty(id) ? allSources[id] : null
|
||||
))
|
||||
sources = event.sources.map(src => {
|
||||
const id = typeof src === 'object' ? src.id : src
|
||||
return allSources.hasOwnProperty(id) ? allSources[id] : null
|
||||
})
|
||||
}
|
||||
return {
|
||||
...event,
|
||||
@@ -199,19 +200,6 @@ export function binarySearch (ar, el, compareFn) {
|
||||
return -m - 1
|
||||
}
|
||||
|
||||
export const isFilterLeaf = node => (Object.keys(node.children).length === 0)
|
||||
export const isFilterDuplicate = (node, set) => { return (set.has(node.key)) }
|
||||
|
||||
export function findDescriptionInFilterTree (key, node) {
|
||||
if (node.key === key) return node.description
|
||||
if (isFilterLeaf(node)) return false
|
||||
const descs = Object.keys(node.children)
|
||||
.map(c => findDescriptionInFilterTree(key, node.children[c]))
|
||||
.filter(v => !!v)
|
||||
if (descs.length !== 1) return false
|
||||
return descs[0]
|
||||
}
|
||||
|
||||
export function makeNiceDate (datetime) {
|
||||
if (datetime === null) return null
|
||||
// see https://stackoverflow.com/questions/3552461/how-to-format-a-javascript-date
|
||||
|
||||
@@ -5,10 +5,8 @@ import CardCustomField from './presentational/Card/CustomField'
|
||||
import CardTime from './presentational/Card/Time'
|
||||
import CardLocation from './presentational/Card/Location'
|
||||
import CardCaret from './presentational/Card/Caret'
|
||||
import CardFilters from './presentational/Card/Filters'
|
||||
import CardSummary from './presentational/Card/Summary'
|
||||
import CardSource from './presentational/Card/Source'
|
||||
import CardNarrative from './presentational/Card/Narrative'
|
||||
import { makeNiceDate } from '../common/utilities'
|
||||
|
||||
class Card extends React.Component {
|
||||
@@ -29,6 +27,13 @@ class Card extends React.Component {
|
||||
return makeNiceDate(datetime)
|
||||
}
|
||||
|
||||
handleCardSelect (e) {
|
||||
if (!e.target.className.includes('arrow-down')) {
|
||||
const selectedEventFormat = this.props.idx > 0 ? [this.props.event] : this.props.event
|
||||
this.props.onSelect(selectedEventFormat, this.props.idx)
|
||||
}
|
||||
}
|
||||
|
||||
renderSummary () {
|
||||
return (
|
||||
<CardSummary
|
||||
@@ -39,18 +44,6 @@ class Card extends React.Component {
|
||||
)
|
||||
}
|
||||
|
||||
renderFilters () {
|
||||
if (!this.props.filters || (this.props.filters && this.props.filters.length === 0)) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<CardFilters
|
||||
filters={this.props.filters || []}
|
||||
language={this.props.language}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
renderLocation () {
|
||||
return (
|
||||
<CardLocation
|
||||
@@ -107,21 +100,6 @@ class Card extends React.Component {
|
||||
)
|
||||
}
|
||||
|
||||
renderNarrative () {
|
||||
const links = this.props.getNarrativeLinks(this.props.event)
|
||||
|
||||
if (links !== null) {
|
||||
return (
|
||||
<CardNarrative
|
||||
select={(event) => this.props.onSelect([event])}
|
||||
makeTimelabel={(timestamp) => this.makeTimelabel(timestamp)}
|
||||
next={links.next}
|
||||
prev={links.prev}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
renderCustomFields () {
|
||||
return this.props.features.CUSTOM_EVENT_FIELDS
|
||||
.map(field => {
|
||||
@@ -148,9 +126,7 @@ class Card extends React.Component {
|
||||
renderExtra () {
|
||||
return (
|
||||
<div className='card-bottomhalf'>
|
||||
{this.renderFilters()}
|
||||
{this.renderSources()}
|
||||
{this.renderNarrative()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -166,17 +142,16 @@ class Card extends React.Component {
|
||||
|
||||
render () {
|
||||
const { isSelected, idx } = this.props
|
||||
|
||||
return (
|
||||
<li
|
||||
className={`event-card ${isSelected ? 'selected' : ''}`}
|
||||
id={`event-card-${idx}`}
|
||||
ref={this.props.innerRef}
|
||||
onClick={this.props.onSelect}
|
||||
onClick={(e) => this.handleCardSelect(e)}
|
||||
>
|
||||
{this.renderMain()}
|
||||
{this.state.isOpen ? this.renderExtra() : null}
|
||||
{isSelected ? this.renderCaret() : null}
|
||||
{this.renderCaret()}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ class CardStack extends React.Component {
|
||||
return events.map((event, idx) => {
|
||||
const thisRef = React.createRef()
|
||||
this.refs[idx] = thisRef
|
||||
|
||||
return (<Card
|
||||
event={event}
|
||||
ref={thisRef}
|
||||
@@ -66,13 +67,13 @@ class CardStack extends React.Component {
|
||||
language={this.props.language}
|
||||
isLoading={this.props.isLoading}
|
||||
isSelected={selections[idx]}
|
||||
getNarrativeLinks={this.props.getNarrativeLinks}
|
||||
getCategoryGroup={this.props.getCategoryGroup}
|
||||
getCategoryColor={this.props.getCategoryColor}
|
||||
getCategoryLabel={this.props.getCategoryLabel}
|
||||
onViewSource={this.props.onViewSource}
|
||||
onHighlight={this.props.onHighlight}
|
||||
onSelect={() => this.props.onSelect(idx)}
|
||||
onSelect={this.props.onSelect}
|
||||
idx={idx}
|
||||
features={this.props.features}
|
||||
/>)
|
||||
})
|
||||
@@ -80,6 +81,7 @@ class CardStack extends React.Component {
|
||||
|
||||
renderSelectedCards () {
|
||||
const { selected } = this.props
|
||||
|
||||
if (selected.length > 0) {
|
||||
return this.renderCards(selected)
|
||||
}
|
||||
@@ -137,7 +139,6 @@ class CardStack extends React.Component {
|
||||
|
||||
render () {
|
||||
const { isCardstack, selected, narrative, timelineDims } = this.props
|
||||
|
||||
// TODO: make '237px', which is the narrative header, less hard-coded
|
||||
const height = `calc(100% - 237px - ${timelineDims.height}px)`
|
||||
if (selected.length > 0) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from 'react'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import * as actions from '../actions'
|
||||
import * as selectors from '../selectors'
|
||||
|
||||
import MediaOverlay from './Overlay/Media'
|
||||
import LoadingOverlay from './Overlay/Loading'
|
||||
@@ -19,7 +20,7 @@ import StaticPage from './StaticPage'
|
||||
import TemplateCover from './TemplateCover'
|
||||
|
||||
import colors from '../common/global'
|
||||
import { binarySearch, insetSourceFrom, findDescriptionInFilterTree } from '../common/utilities'
|
||||
import { binarySearch, insetSourceFrom } from '../common/utilities'
|
||||
import { isMobile } from 'react-device-detect'
|
||||
import Search from './Search.jsx'
|
||||
|
||||
@@ -41,10 +42,11 @@ class Dashboard extends React.Component {
|
||||
componentDidMount () {
|
||||
if (!this.props.app.isMobile) {
|
||||
this.props.actions.fetchDomain()
|
||||
.then(domain => this.props.actions.updateDomain({
|
||||
domain,
|
||||
features: this.props.features
|
||||
}))
|
||||
.then(domain =>
|
||||
this.props.actions.updateDomain({
|
||||
domain,
|
||||
features: this.props.features
|
||||
}))
|
||||
}
|
||||
// NOTE: hack to get the timeline to always show. Not entirely sure why
|
||||
// this is necessary.
|
||||
@@ -85,7 +87,9 @@ class Dashboard extends React.Component {
|
||||
ptr >= 0 &&
|
||||
(events[idx].datetime).getTime() === (events[ptr].datetime).getTime()
|
||||
) {
|
||||
matchedEvents.push(events[ptr])
|
||||
if (events[ptr].id !== selected.id) {
|
||||
matchedEvents.push(events[ptr])
|
||||
}
|
||||
ptr -= 1
|
||||
}
|
||||
// check events after
|
||||
@@ -95,15 +99,16 @@ class Dashboard extends React.Component {
|
||||
ptr < events.length &&
|
||||
(events[idx].datetime).getTime() === (events[ptr].datetime).getTime()
|
||||
) {
|
||||
matchedEvents.push(events[ptr])
|
||||
if (events[ptr].id !== selected.id) {
|
||||
matchedEvents.push(events[ptr])
|
||||
}
|
||||
ptr += 1
|
||||
}
|
||||
} else { // Map...
|
||||
} else { // Map..
|
||||
const std = { ...selected }
|
||||
delete std.sources
|
||||
Object.values(std).forEach(ev => matchedEvents.push(ev))
|
||||
}
|
||||
|
||||
this.props.actions.updateSelected(matchedEvents)
|
||||
}
|
||||
|
||||
@@ -118,15 +123,9 @@ class Dashboard extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
getNarrativeLinks (event) {
|
||||
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) {
|
||||
// only handleSelect if narrative is not null and has associated events
|
||||
if (narrative && narrative.steps.length >= 1) {
|
||||
this.handleSelect([ narrative.steps[0] ])
|
||||
}
|
||||
this.props.actions.updateNarrative(narrative)
|
||||
@@ -134,30 +133,20 @@ class Dashboard extends React.Component {
|
||||
|
||||
setNarrativeFromFilters (withSteps) {
|
||||
const { app, domain } = this.props
|
||||
let activeFilters = app.filters.filters
|
||||
let activeFilters = app.associations.filters
|
||||
|
||||
if (activeFilters.length === 0) {
|
||||
alert('No filters selected, cant narrativise')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.props.features.USE_FILTER_DESCRIPTIONS) {
|
||||
activeFilters = activeFilters.reduce((acc, vl) => {
|
||||
acc.push({
|
||||
name: vl,
|
||||
description: findDescriptionInFilterTree(vl, domain.filters)
|
||||
})
|
||||
return acc
|
||||
}, [])
|
||||
} else {
|
||||
activeFilters = activeFilters.map(f => ({ name: f }))
|
||||
}
|
||||
activeFilters = activeFilters.map(f => ({ name: f }))
|
||||
|
||||
const evs = domain.events.filter(ev => {
|
||||
let hasOne = false
|
||||
// add event if it has at least one matching filter
|
||||
for (let i = 0; i < activeFilters.length; i++) {
|
||||
if (ev.filters.includes(activeFilters[i].name)) {
|
||||
if (ev.associations.includes(activeFilters[i].name)) {
|
||||
hasOne = true
|
||||
break
|
||||
}
|
||||
@@ -166,6 +155,11 @@ class Dashboard extends React.Component {
|
||||
return false
|
||||
})
|
||||
|
||||
if (evs.length === 0) {
|
||||
alert('No associated events, cant narrativise')
|
||||
return
|
||||
}
|
||||
|
||||
const name = activeFilters.map(f => f.name).join('-')
|
||||
const desc = activeFilters.map(f => f.description).join('\n\n')
|
||||
this.setNarrative({
|
||||
@@ -182,8 +176,8 @@ class Dashboard extends React.Component {
|
||||
if (typeof idx !== 'number') {
|
||||
let e = idx[0] || idx
|
||||
|
||||
if (this.props.app.narrative) {
|
||||
const { steps } = this.props.app.narrative
|
||||
if (this.props.app.associations.narrative) {
|
||||
const { steps } = this.props.app.associations.narrative
|
||||
// choose the first event at a given location
|
||||
const locationEventId = e.id
|
||||
const narrativeIdxObj = steps.find(s => s.id === locationEventId)
|
||||
@@ -195,7 +189,7 @@ class Dashboard extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
const { narrative } = this.props.app
|
||||
const { narrative } = this.props.app.associations
|
||||
if (narrative === null) return
|
||||
|
||||
if (idx < narrative.steps.length && idx >= 0) {
|
||||
@@ -209,18 +203,19 @@ class Dashboard extends React.Component {
|
||||
onKeyDown (e) {
|
||||
const { narrative, selected } = this.props.app
|
||||
const { events } = this.props.domain
|
||||
|
||||
const prev = idx => {
|
||||
if (narrative === null) {
|
||||
this.handleSelect(events[idx - 1], 0)
|
||||
} else {
|
||||
this.selectNarrativeStep(this.props.app.narrativeState.current - 1)
|
||||
this.selectNarrativeStep(this.props.narrativeIdx - 1)
|
||||
}
|
||||
}
|
||||
const next = idx => {
|
||||
if (narrative === null) {
|
||||
this.handleSelect(events[idx + 1], 0)
|
||||
} else {
|
||||
this.selectNarrativeStep(this.props.app.narrativeState.current + 1)
|
||||
this.selectNarrativeStep(this.props.narrativeIdx + 1)
|
||||
}
|
||||
}
|
||||
if (selected.length > 0) {
|
||||
@@ -267,7 +262,7 @@ class Dashboard extends React.Component {
|
||||
return (
|
||||
<div >
|
||||
<Toolbar
|
||||
isNarrative={!!app.narrative}
|
||||
isNarrative={!!app.associations.narrative}
|
||||
methods={{
|
||||
onTitle: actions.toggleCover,
|
||||
onSelectFilter: filter => actions.toggleFilter('filters', filter),
|
||||
@@ -280,13 +275,13 @@ class Dashboard extends React.Component {
|
||||
methods={{
|
||||
onSelectNarrative: this.setNarrative,
|
||||
getCategoryColor: this.getCategoryColor,
|
||||
onSelect: app.narrative ? this.selectNarrativeStep : ev => this.handleSelect(ev, 1)
|
||||
onSelect: app.associations.narrative ? this.selectNarrativeStep : ev => this.handleSelect(ev, 1)
|
||||
}}
|
||||
/>
|
||||
<Timeline
|
||||
onKeyDown={this.onKeyDown}
|
||||
methods={{
|
||||
onSelect: app.narrative ? this.selectNarrativeStep : ev => this.handleSelect(ev, 0),
|
||||
onSelect: app.associations.narrative ? this.selectNarrativeStep : ev => this.handleSelect(ev, 0),
|
||||
onUpdateTimerange: actions.updateTimeRange,
|
||||
getCategoryColor: this.getCategoryColor
|
||||
}}
|
||||
@@ -294,25 +289,24 @@ class Dashboard extends React.Component {
|
||||
<CardStack
|
||||
timelineDims={app.timeline.dimensions}
|
||||
onViewSource={this.handleViewSource}
|
||||
onSelect={app.narrative ? this.selectNarrativeStep : this.handleSelect}
|
||||
onSelect={app.associations.narrative ? this.selectNarrativeStep : this.handleSelect}
|
||||
onHighlight={this.handleHighlight}
|
||||
onToggleCardstack={() => actions.updateSelected([])}
|
||||
getNarrativeLinks={event => this.getNarrativeLinks(event)}
|
||||
getCategoryColor={this.getCategoryColor}
|
||||
/>
|
||||
<StateOptions
|
||||
showing={features.FILTERS_AS_NARRATIVES && !app.narrative && app.filters.filters.length > 0}
|
||||
showing={this.props.narratives && this.props.narratives.length !== 0 && !app.associations.narrative && app.associations.filters.length > 0}
|
||||
timelineDims={app.timeline.dimensions}
|
||||
onClickHandler={this.setNarrativeFromFilters}
|
||||
/>
|
||||
<NarrativeControls
|
||||
narrative={app.narrative ? {
|
||||
...app.narrative,
|
||||
current: app.narrativeState.current
|
||||
narrative={app.associations.narrative ? {
|
||||
...app.associations.narrative,
|
||||
current: this.props.narrativeIdx
|
||||
} : null}
|
||||
methods={{
|
||||
onNext: () => this.selectNarrativeStep(this.props.app.narrativeState.current + 1),
|
||||
onPrev: () => this.selectNarrativeStep(this.props.app.narrativeState.current - 1),
|
||||
onNext: () => this.selectNarrativeStep(this.props.narrativeIdx + 1),
|
||||
onPrev: () => this.selectNarrativeStep(this.props.narrativeIdx - 1),
|
||||
onSelectNarrative: this.setNarrative
|
||||
}}
|
||||
/>
|
||||
@@ -367,6 +361,11 @@ function mapDispatchToProps (dispatch) {
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => state,
|
||||
state => ({
|
||||
...state,
|
||||
narrativeIdx: selectors.selectNarrativeIdx(state),
|
||||
narratives: selectors.selectNarratives(state),
|
||||
selected: selectors.selectSelected(state)
|
||||
}),
|
||||
mapDispatchToProps
|
||||
)(Dashboard)
|
||||
|
||||
@@ -276,11 +276,11 @@ function mapStateToProps (state) {
|
||||
shapes: selectors.selectShapes(state)
|
||||
},
|
||||
app: {
|
||||
views: state.app.filters.views,
|
||||
views: state.app.associations.views,
|
||||
selected: selectors.selectSelected(state),
|
||||
highlighted: state.app.highlighted,
|
||||
map: state.app.map,
|
||||
narrative: state.app.narrative,
|
||||
narrative: state.app.associations.narrative,
|
||||
flags: {
|
||||
isShowingSites: state.app.flags.isShowingSites
|
||||
}
|
||||
|
||||
@@ -398,7 +398,7 @@ class Timeline extends React.Component {
|
||||
function mapStateToProps (state) {
|
||||
return {
|
||||
dimensions: selectors.selectDimensions(state),
|
||||
isNarrative: !!state.app.narrative,
|
||||
isNarrative: !!state.app.associations.narrative,
|
||||
domain: {
|
||||
events: selectors.selectStackedEvents(state),
|
||||
projects: selectors.selectProjects(state),
|
||||
@@ -409,7 +409,7 @@ function mapStateToProps (state) {
|
||||
selected: state.app.selected,
|
||||
language: state.app.language,
|
||||
timeline: state.app.timeline,
|
||||
narrative: state.app.narrative
|
||||
narrative: state.app.associations.narrative
|
||||
},
|
||||
ui: {
|
||||
dom: state.ui.dom,
|
||||
|
||||
@@ -3,57 +3,71 @@ import Checkbox from '../presentational/Checkbox'
|
||||
import copy from '../../common/data/copy.json'
|
||||
|
||||
/** recursively get an array of node keys to toggle */
|
||||
function childrenToToggle (node, activeFilters, parentOn) {
|
||||
const isOn = activeFilters.includes(node.key)
|
||||
if (!node.children) {
|
||||
return [node.key]
|
||||
function childrenToToggle (filter, activeFilters, parentOn) {
|
||||
const [key, children] = filter
|
||||
const isOn = activeFilters.includes(key)
|
||||
if (children === {}) {
|
||||
return [key]
|
||||
}
|
||||
const childKeys = Object.values(node.children)
|
||||
.flatMap(n => childrenToToggle(n, activeFilters, isOn))
|
||||
const childKeys = Object.entries(children)
|
||||
.flatMap(filter => childrenToToggle(filter, activeFilters, isOn))
|
||||
// NB: if turning a parent off, don't toggle off children on.
|
||||
// likewise if turning a parent on, don't toggle on children off
|
||||
if (!((!parentOn && isOn) || (parentOn && !isOn))) {
|
||||
childKeys.push(node.key)
|
||||
childKeys.push(key)
|
||||
}
|
||||
return childKeys
|
||||
}
|
||||
|
||||
function aggregatePaths (filters) {
|
||||
function insertPath (children = {}, [headOfPath, ...remainder]) {
|
||||
let childKey = Object.keys(children).find(key => key === headOfPath)
|
||||
if (!childKey) children[headOfPath] = {}
|
||||
if (remainder.length > 0) insertPath(children[headOfPath], remainder)
|
||||
return children
|
||||
}
|
||||
|
||||
const allPaths = []
|
||||
filters.forEach(filterItem => allPaths.push(filterItem.filter_paths))
|
||||
|
||||
let aggregatedPaths = allPaths.reduce((children, path) => insertPath(children, path), {})
|
||||
return aggregatedPaths
|
||||
}
|
||||
|
||||
function FilterListPanel ({
|
||||
filters,
|
||||
activeFilters,
|
||||
onSelectFilter,
|
||||
language
|
||||
}) {
|
||||
function createNodeComponent (node, depth) {
|
||||
const matchingKeys = childrenToToggle(node, activeFilters, activeFilters.includes(node.key))
|
||||
const children = Object.values(node.children)
|
||||
function createNodeComponent (filter, depth) {
|
||||
const [key, children] = filter
|
||||
const matchingKeys = childrenToToggle(filter, activeFilters, activeFilters.includes(key))
|
||||
|
||||
return (
|
||||
<li
|
||||
key={node.key.replace(/ /g, '_')}
|
||||
key={key.replace(/ /g, '_')}
|
||||
className={'filter-filter'}
|
||||
style={{ marginLeft: `${depth * 20}px` }}
|
||||
>
|
||||
{/* <svg width='10' height='10'> */}
|
||||
{/* <g className='filter-inline'> */}
|
||||
{/* <path d='M0,-7.847549217020565L6.796176979388489,3.9237746085102825L-6.796176979388489,3.9237746085102825Z' transform='rotate(270)' /> */}
|
||||
{/* </g> */}
|
||||
{/* </svg> */}
|
||||
<Checkbox
|
||||
label={node.key}
|
||||
isActive={activeFilters.includes(node.key)}
|
||||
label={key}
|
||||
isActive={activeFilters.includes(key)}
|
||||
onClickCheckbox={() => onSelectFilter(matchingKeys)}
|
||||
/>
|
||||
{children.length > 0
|
||||
? children.map(filter => createNodeComponent(filter, depth + 1))
|
||||
{Object.keys(children).length > 0
|
||||
? Object.entries(children).map(filter => createNodeComponent(filter, depth + 1))
|
||||
: null}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function renderTree (children) {
|
||||
function renderTree (filters) {
|
||||
const aggregatedFilterPaths = aggregatePaths(filters)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{Object.values(children).map(filter => createNodeComponent(filter, 1))}
|
||||
{Object.entries(aggregatedFilterPaths).map(filter => createNodeComponent(filter, 1))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -62,7 +76,7 @@ function FilterListPanel ({
|
||||
<div className='react-innertabpanel'>
|
||||
<h2>{copy[language].toolbar.filters}</h2>
|
||||
<p>{copy[language].toolbar.explore_by_filter__description}</p>
|
||||
{renderTree(filters.children)}
|
||||
{renderTree(filters)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,8 +62,8 @@ class Toolbar extends React.Component {
|
||||
return (
|
||||
<div className='panel-action action'>
|
||||
<button onClick={() => { this.goToNarrative(narr) }}>
|
||||
<p>{narr.label}</p>
|
||||
<p><small>{trimAndEllipse(narr.description, 120)}</small></p>
|
||||
<p>{narr.id}</p>
|
||||
<p><small>{trimAndEllipse(narr.desc, 120)}</small></p>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@@ -113,15 +113,15 @@ class Toolbar extends React.Component {
|
||||
}
|
||||
|
||||
renderToolbarPanels () {
|
||||
const { features } = this.props
|
||||
const { features, narratives } = this.props
|
||||
let classes = (this.state._selected >= 0) ? 'toolbar-panels' : 'toolbar-panels folded'
|
||||
return (
|
||||
<div className={classes}>
|
||||
{this.renderClosePanel()}
|
||||
<Tabs selectedIndex={this.state._selected}>
|
||||
{features.USE_NARRATIVES ? this.renderToolbarNarrativePanel() : null}
|
||||
{narratives && narratives.length !== 0 ? this.renderToolbarNarrativePanel() : null}
|
||||
{features.CATEGORIES_AS_FILTERS ? this.renderToolbarCategoriesPanel() : null}
|
||||
{features.USE_FILTERS ? this.renderToolbarFilterPanel() : null}
|
||||
{features.USE_ASSOCIATIONS ? this.renderToolbarFilterPanel() : null}
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
@@ -145,7 +145,8 @@ class Toolbar extends React.Component {
|
||||
}
|
||||
|
||||
renderToolbarTabs () {
|
||||
const { features } = this.props
|
||||
const { features, narratives } = this.props
|
||||
const narrativesExist = narratives && narratives.length !== 0
|
||||
let title = copy[this.props.language].toolbar.title
|
||||
if (process.env.display_title) title = process.env.display_title
|
||||
const narrativesLabel = copy[this.props.language].toolbar.narratives_label
|
||||
@@ -153,17 +154,17 @@ class Toolbar extends React.Component {
|
||||
const categoriesLabel = 'Categories' // TODO:
|
||||
|
||||
const narrativesIdx = 0
|
||||
const categoriesIdx = features.USE_NARRATIVES ? 1 : 0
|
||||
const filtersIdx = (features.USE_NARRATIVES && features.CATEGORIES_AS_FILTERS) ? 2 : (
|
||||
features.USE_NARRATIVES || features.CATEGORIES_AS_FILTERS ? 1 : 0
|
||||
const categoriesIdx = narrativesExist ? 1 : 0
|
||||
const filtersIdx = (narrativesExist && features.CATEGORIES_AS_FILTERS) ? 2 : (
|
||||
narrativesExist || features.CATEGORIES_AS_FILTERS ? 1 : 0
|
||||
)
|
||||
return (
|
||||
<div className='toolbar'>
|
||||
<div className='toolbar-header'onClick={this.props.methods.onTitle}><p>{title}</p></div>
|
||||
<div className='toolbar-tabs'>
|
||||
{features.USE_NARRATIVES ? this.renderToolbarTab(narrativesIdx, narrativesLabel, 'timeline') : null}
|
||||
{narrativesExist ? this.renderToolbarTab(narrativesIdx, narrativesLabel, 'timeline') : null}
|
||||
{features.CATEGORIES_AS_FILTERS ? this.renderToolbarTab(categoriesIdx, categoriesLabel, 'widgets') : null}
|
||||
{features.USE_FILTERS ? this.renderToolbarTab(filtersIdx, filtersLabel, 'filter_list') : null}
|
||||
{features.USE_ASSOCIATIONS ? this.renderToolbarTab(filtersIdx, filtersLabel, 'filter_list') : null}
|
||||
</div>
|
||||
<BottomActions
|
||||
info={{
|
||||
@@ -197,14 +198,14 @@ class Toolbar extends React.Component {
|
||||
|
||||
function mapStateToProps (state) {
|
||||
return {
|
||||
filters: selectors.getFilterTree(state),
|
||||
filters: selectors.getFilters(state),
|
||||
categories: selectors.getCategories(state),
|
||||
narratives: selectors.selectNarratives(state),
|
||||
language: state.app.language,
|
||||
activeFilters: selectors.getActiveFilters(state),
|
||||
activeCategories: selectors.getActiveCategories(state),
|
||||
viewFilters: state.app.filters.views,
|
||||
narrative: state.app.narrative,
|
||||
viewFilters: state.app.associations.views,
|
||||
narrative: state.app.associations.narrative,
|
||||
sitesShowing: state.app.flags.isShowingSites,
|
||||
infoShowing: state.app.flags.isInfopopup,
|
||||
features: selectors.getFeatures(state)
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import copy from '../../../common/data/copy.json'
|
||||
|
||||
const CardFilters = ({ filters, language }) => {
|
||||
const filtersLang = copy[language].cardstack.filters
|
||||
const noFiltersLang = copy[language].cardstack.nofilters
|
||||
|
||||
if (filters.length > 0) {
|
||||
return (
|
||||
<div className='card-row card-cell filters'>
|
||||
<h4>{filtersLang}:</h4>
|
||||
<p>
|
||||
{filters.map((filter, idx) => {
|
||||
return (
|
||||
<span className='filter'>
|
||||
<small>{filter.name}</small>
|
||||
{(idx < filters.length - 1)
|
||||
? ','
|
||||
: ''}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className='card-row card-cell filters'>
|
||||
<h4>{filtersLang}</h4>
|
||||
<p><small>{noFiltersLang}</small></p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CardFilters
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import CardNarrativeLink from './NarrativeLink'
|
||||
|
||||
const CardNarrative = (props) => (
|
||||
<div className='card-row'>
|
||||
<h4>Connected events</h4>
|
||||
<div className='card-cell'>
|
||||
<p>← <CardNarrativeLink {...props} event={props.next} /></p>
|
||||
<p>→ <CardNarrativeLink {...props} event={props.prev} /></p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default CardNarrative
|
||||
@@ -1,17 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
const CardNarrativeLink = ({ event, makeTimelabel, select }) => {
|
||||
if (event !== null) {
|
||||
const timelabel = makeTimelabel(event.timestamp)
|
||||
|
||||
return (
|
||||
<a onClick={() => select(event)}>
|
||||
<small>{`${timelabel} / ${event.location}`}</small>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (<a className='disabled'><small>None</small></a>)
|
||||
}
|
||||
|
||||
export default CardNarrativeLink
|
||||
@@ -26,6 +26,8 @@ function MapNarratives ({
|
||||
return styles[styleName]
|
||||
}
|
||||
|
||||
const narrativesExist = narratives && narratives.length !== 0
|
||||
|
||||
function hasNoLocation (step) {
|
||||
return (step.latitude === '' || step.longitude === '')
|
||||
}
|
||||
@@ -141,7 +143,7 @@ function MapNarratives ({
|
||||
|
||||
let lastMarked = null
|
||||
|
||||
if (features.FILTERS_AS_NARRATIVES) {
|
||||
if (narrativesExist) {
|
||||
for (let idx = 0; idx < n.steps.length; idx += 1) {
|
||||
const step = n.steps[idx]
|
||||
if (lastMarked) {
|
||||
@@ -174,7 +176,7 @@ function MapNarratives ({
|
||||
function renderNarrative (n) {
|
||||
const narrativeId = `narrative-${n.id.replace(/ /g, '_')}`
|
||||
|
||||
const body = features.FILTERS_AS_NARRATIVES
|
||||
const body = narrativesExist
|
||||
? renderBetweenMarked(n)
|
||||
: (features.NARRATIVE_STEP_STYLES
|
||||
? renderBetweenMarked(n)
|
||||
|
||||
@@ -83,17 +83,19 @@ function updateNarrative (appState, action) {
|
||||
minTime = minTime - Math.abs((maxTime - minTime) / 10)
|
||||
maxTime = maxTime + Math.abs((maxTime - minTime) / 10)
|
||||
}
|
||||
|
||||
return {
|
||||
...appState,
|
||||
narrative: action.narrative,
|
||||
narrativeState: {
|
||||
current: action.narrative ? 0 : null
|
||||
associations: {
|
||||
...appState.associations,
|
||||
narrative: action.narrative
|
||||
},
|
||||
filters: {
|
||||
...appState.filters,
|
||||
timerange: [minTime, maxTime],
|
||||
mapBounds: (action.narrative) ? [cornerBound0, cornerBound1] : null
|
||||
map: {
|
||||
...appState.map,
|
||||
bounds: (action.narrative) ? [cornerBound0, cornerBound1] : null
|
||||
},
|
||||
timeline: {
|
||||
...appState.timeline,
|
||||
range: [minTime, maxTime]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,7 +114,7 @@ function toggleFilter (appState, action) {
|
||||
action.value = [action.value]
|
||||
}
|
||||
|
||||
let newFilters = appState.filters[action.filter].slice(0)
|
||||
let newFilters = appState.associations.filters.slice(0)
|
||||
action.value.forEach(vl => {
|
||||
if (newFilters.includes(vl)) {
|
||||
newFilters = newFilters.filter(s => s !== vl)
|
||||
@@ -123,9 +125,9 @@ function toggleFilter (appState, action) {
|
||||
|
||||
return {
|
||||
...appState,
|
||||
filters: {
|
||||
...appState.filters,
|
||||
[action.filter]: newFilters
|
||||
associations: {
|
||||
...appState.associations,
|
||||
filters: newFilters
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
src/reducers/validate/associationsSchema.js
Normal file
10
src/reducers/validate/associationsSchema.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import Joi from 'joi'
|
||||
|
||||
const associationsSchema = Joi.object().keys({
|
||||
id: Joi.string().allow('').required(),
|
||||
desc: Joi.string().allow(''),
|
||||
mode: Joi.string().allow('').required(),
|
||||
filter_paths: Joi.array()
|
||||
})
|
||||
|
||||
export default associationsSchema
|
||||
@@ -23,10 +23,8 @@ function createEventSchema (custom) {
|
||||
type: Joi.string().allow(''),
|
||||
category: Joi.string().allow(''),
|
||||
category_full: Joi.string().allow(''),
|
||||
narratives: Joi.array(),
|
||||
associations: Joi.array(),
|
||||
sources: Joi.array(),
|
||||
filters: Joi.array().allow(''),
|
||||
tags: Joi.array().allow(''),
|
||||
comments: Joi.string().allow(''),
|
||||
time_display: Joi.string().allow(''),
|
||||
// nested
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
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
|
||||
@@ -3,11 +3,11 @@ import Joi from 'joi'
|
||||
import createEventSchema from './eventSchema'
|
||||
import categorySchema from './categorySchema'
|
||||
import siteSchema from './siteSchema'
|
||||
import narrativeSchema from './narrativeSchema'
|
||||
import associationsSchema from './associationsSchema'
|
||||
import sourceSchema from './sourceSchema'
|
||||
import shapeSchema from './shapeSchema'
|
||||
|
||||
import { calcDatetime, capitalize, isFilterLeaf, isFilterDuplicate } from '../../common/utilities'
|
||||
import { calcDatetime, capitalize } from '../../common/utilities'
|
||||
|
||||
/*
|
||||
* Create an error notification object
|
||||
@@ -25,50 +25,20 @@ function isValidDate (d) {
|
||||
return d instanceof Date && !isNaN(d)
|
||||
}
|
||||
|
||||
/*
|
||||
* Traverse a filter tree and check its duplicates. Also recompose as
|
||||
* description if `features.USE_FILTER_DESCRIPTIONS` is true.
|
||||
*/
|
||||
function validateFilterTree (node, parent, set, duplicates, hasFilterDescriptions) {
|
||||
if (hasFilterDescriptions) {
|
||||
if (node.key === '_root') {
|
||||
node.isDescription = true // setting first set of nodes to values
|
||||
} else if (!parent.isDescription) {
|
||||
node.isDescription = true
|
||||
} else {
|
||||
node.isDescription = false
|
||||
}
|
||||
|
||||
if (node.isDescription && node.key !== 'root') {
|
||||
parent.description = node.key
|
||||
parent.children = node.children
|
||||
delete parent.isDescription
|
||||
}
|
||||
if (isFilterLeaf(node)) {
|
||||
delete parent.isDescription
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof (node) !== 'object' || typeof (node.children) !== 'object') {
|
||||
return
|
||||
}
|
||||
// If it's a leaf, check that it's not duplicate
|
||||
if (isFilterLeaf(node)) {
|
||||
if (isFilterDuplicate(node, set)) {
|
||||
function findDuplicateAssociations (associations) {
|
||||
const seenSet = new Set([])
|
||||
const duplicates = []
|
||||
associations.forEach(item => {
|
||||
if (seenSet.has(item.id)) {
|
||||
duplicates.push({
|
||||
id: node.key,
|
||||
error: makeError('Filters', node.key, 'filter was found more than once in hierarchy. Ignoring duplicate.')
|
||||
id: item.id,
|
||||
error: makeError('Association', item.id, 'association was found more than once. Ignoring duplicate.')
|
||||
})
|
||||
delete parent.children[node.key]
|
||||
} else {
|
||||
set.add(node.key)
|
||||
seenSet.add(item.id)
|
||||
}
|
||||
} else {
|
||||
// If it's not a leaf, simply keep going
|
||||
Object.values(node.children).forEach((childNode) => {
|
||||
validateFilterTree(childNode, node, set, duplicates, hasFilterDescriptions)
|
||||
})
|
||||
}
|
||||
})
|
||||
return duplicates
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -79,9 +49,8 @@ export function validateDomain (domain, features) {
|
||||
events: [],
|
||||
categories: [],
|
||||
sites: [],
|
||||
narratives: [],
|
||||
associations: [],
|
||||
sources: {},
|
||||
filters: {},
|
||||
shapes: [],
|
||||
notifications: domain ? domain.notifications : null
|
||||
}
|
||||
@@ -94,7 +63,7 @@ export function validateDomain (domain, features) {
|
||||
events: [],
|
||||
categories: [],
|
||||
sites: [],
|
||||
narratives: [],
|
||||
associations: [],
|
||||
sources: [],
|
||||
shapes: []
|
||||
}
|
||||
@@ -114,12 +83,6 @@ export function validateDomain (domain, features) {
|
||||
|
||||
function validateArray (items, domainKey, schema) {
|
||||
items.forEach(item => {
|
||||
// NB: backwards compatibility with 'tags' for 'filters'
|
||||
if (domainKey === 'events') {
|
||||
if (!item.filters && !!item.tags) {
|
||||
item.filters = item.tags
|
||||
}
|
||||
}
|
||||
validateArrayItem(item, domainKey, schema)
|
||||
})
|
||||
}
|
||||
@@ -149,7 +112,7 @@ export function validateDomain (domain, features) {
|
||||
validateArray(domain.events, 'events', eventSchema)
|
||||
validateArray(domain.categories, 'categories', categorySchema)
|
||||
validateArray(domain.sites, 'sites', siteSchema)
|
||||
validateArray(domain.narratives, 'narratives', narrativeSchema)
|
||||
validateArray(domain.associations, 'associations', associationsSchema)
|
||||
validateObject(domain.sources, 'sources', sourceSchema)
|
||||
validateObject(domain.shapes, 'shapes', shapeSchema)
|
||||
|
||||
@@ -162,20 +125,16 @@ export function validateDomain (domain, features) {
|
||||
})
|
||||
)
|
||||
|
||||
// Validate uniqueness of filters
|
||||
const filterSet = new Set([])
|
||||
const duplicateFilters = []
|
||||
validateFilterTree(domain.filters, {}, filterSet, duplicateFilters, features.USE_FILTER_DESCRIPTIONS)
|
||||
|
||||
// Duplicated filters
|
||||
if (duplicateFilters.length > 0) {
|
||||
const duplicateAssociations = findDuplicateAssociations(domain.associations)
|
||||
// Duplicated associations
|
||||
if (duplicateAssociations.length > 0) {
|
||||
sanitizedDomain.notifications.push({
|
||||
message: `Filters are required to be unique. Ignoring duplicates for now.`,
|
||||
items: duplicateFilters,
|
||||
message: `Associations are required to be unique. Ignoring duplicates for now.`,
|
||||
items: duplicateAssociations,
|
||||
type: 'error'
|
||||
})
|
||||
}
|
||||
sanitizedDomain.filters = domain.filters
|
||||
sanitizedDomain.associations = domain.associations
|
||||
|
||||
// append events with datetime and sort
|
||||
sanitizedDomain.events = sanitizedDomain.events.filter((event, idx) => {
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { createSelector } from 'reselect'
|
||||
import { insetSourceFrom, dateMin, dateMax } from '../common/utilities'
|
||||
import { isTimeRangedIn } from './helpers'
|
||||
import { FILTER_MODE, NARRATIVE_MODE } from '../common/constants'
|
||||
|
||||
// Input selectors
|
||||
export const getEvents = state => state.domain.events
|
||||
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 getNarratives = state => state.domain.associations.filter(item => item.mode === NARRATIVE_MODE)
|
||||
export const getActiveNarrative = state => state.app.associations.narrative
|
||||
export const getSelected = state => state.app.selected
|
||||
export const getSites = state => state.domain.sites
|
||||
export const getSources = state => state.domain.sources
|
||||
export const getShapes = state => state.domain.shapes
|
||||
export const getFilters = state => state.domain.associations.filter(item => item.mode === FILTER_MODE)
|
||||
export const getNotifications = state => state.domain.notifications
|
||||
export const getFilterTree = state => state.domain.filters
|
||||
export const getActiveFilters = state => state.app.filters.filters
|
||||
export const getActiveCategories = state => state.app.filters.categories
|
||||
export const getActiveFilters = state => state.app.associations.filters
|
||||
export const getActiveCategories = state => state.app.associations.categories
|
||||
export const getTimeRange = state => state.app.timeline.range
|
||||
export const getTimelineDimensions = state => state.app.timeline.dimensions
|
||||
export const selectNarrative = state => state.app.narrative
|
||||
export const selectNarrative = state => state.app.associations.narrative
|
||||
export const getFeatures = state => state.features
|
||||
export const getEventRadius = state => state.ui.eventRadius
|
||||
|
||||
@@ -49,9 +49,9 @@ export const selectEvents = createSelector(
|
||||
[getEvents, getActiveFilters, getActiveCategories, getTimeRange, getFeatures],
|
||||
(events, activeFilters, activeCategories, timeRange, features) => {
|
||||
return events.reduce((acc, event) => {
|
||||
const isMatchingFilter = (event.filters &&
|
||||
event.filters.map(filter =>
|
||||
activeFilters.includes(filter))
|
||||
const isMatchingFilter = (event.associations &&
|
||||
event.associations.map(association =>
|
||||
activeFilters.includes(association))
|
||||
.some(s => s)
|
||||
) || activeFilters.length === 0
|
||||
const isActiveFilter = isMatchingFilter || activeFilters.length === 0
|
||||
@@ -76,7 +76,7 @@ export const selectEvents = createSelector(
|
||||
export const selectNarratives = createSelector(
|
||||
[getEvents, getNarratives, getSources, getFeatures],
|
||||
(events, narrativesMeta, sources, features) => {
|
||||
if (!features.USE_NARRATIVES) {
|
||||
if (Array.isArray(narrativesMeta) && narrativesMeta.length === 0) {
|
||||
return []
|
||||
}
|
||||
const narratives = {}
|
||||
@@ -84,40 +84,62 @@ export const selectNarratives = createSelector(
|
||||
|
||||
/* populate narratives dict with events */
|
||||
events.forEach(evt => {
|
||||
evt.narratives.forEach(narrative => {
|
||||
// initialise
|
||||
if (!narratives[narrative]) { narratives[narrative] = narrativeSkeleton(narrative) }
|
||||
|
||||
// add evt to steps
|
||||
// NB: insetSourceFrom is a 'curried' function to allow with maps
|
||||
narratives[narrative].steps.push(insetSourceFrom(sources)(evt))
|
||||
evt.associations.forEach(association => {
|
||||
const foundNarrative = narrativesMeta.find(narr => narr.id === association)
|
||||
if (foundNarrative) {
|
||||
const { id: narrId } = foundNarrative
|
||||
// initialise
|
||||
if (!narratives[narrId]) { narratives[narrId] = narrativeSkeleton(narrId) }
|
||||
// add evt to steps
|
||||
// NB: insetSourceFrom is a 'curried' function to allow with maps
|
||||
narratives[narrId].steps.push(insetSourceFrom(sources)(evt))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/* sort steps by time */
|
||||
Object.keys(narratives).forEach(key => {
|
||||
const steps = narratives[key].steps
|
||||
|
||||
steps.sort((a, b) => a.datetime - b.datetime)
|
||||
|
||||
if (narrativesMeta.find(n => n.id === key)) {
|
||||
const existingAssociatedNarrative = narrativesMeta.find(n => n.id === key)
|
||||
|
||||
if (existingAssociatedNarrative) {
|
||||
narratives[key] = {
|
||||
...narrativesMeta.find(n => n.id === key),
|
||||
...existingAssociatedNarrative,
|
||||
...narratives[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Return narratives in original order
|
||||
// + filter those that are undefined
|
||||
return narrativesMeta.map(n => narratives[n.id]).filter(d => d)
|
||||
})
|
||||
|
||||
/** We iterate through narrative.steps and check the idx there against the selected array and we return the idx */
|
||||
export const selectNarrativeIdx = createSelector(
|
||||
[getSelected, getActiveNarrative],
|
||||
(selected, narrative) => {
|
||||
// Only one event selected in narrative mode
|
||||
if (narrative === null) return -1
|
||||
|
||||
const selectedEvent = selected[0]
|
||||
let selectedIdx
|
||||
|
||||
narrative.steps.forEach((step, idx) => {
|
||||
if (selectedEvent.id === step.id) {
|
||||
selectedIdx = idx
|
||||
}
|
||||
})
|
||||
return selectedIdx
|
||||
}
|
||||
)
|
||||
|
||||
/** 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],
|
||||
[getActiveNarrative, selectNarrativeIdx],
|
||||
(narrative, current) => narrative
|
||||
? { ...narrative, current }
|
||||
: null
|
||||
@@ -245,7 +267,6 @@ export const selectSelected = createSelector(
|
||||
if (selected.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return selected.map(insetSourceFrom(sources))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -6,17 +6,16 @@ const initial = {
|
||||
* The Domain or 'domain' of this state refers to the tree of data
|
||||
* available for render and display.
|
||||
* Selections and filters in the 'app' subtree will operate the domain
|
||||
* in mapStateToProps of the Dashboard, and deterimne which items
|
||||
* in mapStateToProps of the Dashboard, and determine which items
|
||||
* in the domain will get rendered by React
|
||||
*/
|
||||
domain: {
|
||||
events: [],
|
||||
narratives: [],
|
||||
locations: [],
|
||||
categories: [],
|
||||
associations: [],
|
||||
sources: {},
|
||||
sites: [],
|
||||
filters: {},
|
||||
notifications: []
|
||||
},
|
||||
|
||||
@@ -24,23 +23,20 @@ const initial = {
|
||||
* The 'app' subtree of this state determines the data and information to be
|
||||
* displayed.
|
||||
* It may refer to those the user interacts with, by selecting,
|
||||
* fitlering and so on, which ultimately operate on the data to be displayed.
|
||||
* filtering and so on, which ultimately operate on the data to be displayed.
|
||||
* Additionally, some of the 'app' flags are determined by the config file
|
||||
* or by the characteristics of the client, browser, etc.
|
||||
*/
|
||||
app: {
|
||||
errors: {
|
||||
source: null
|
||||
source: false
|
||||
},
|
||||
highlighted: null,
|
||||
selected: [],
|
||||
source: null,
|
||||
narrative: null,
|
||||
narrativeState: {
|
||||
current: null
|
||||
},
|
||||
filters: {
|
||||
associations: {
|
||||
filters: [],
|
||||
narrative: null,
|
||||
categories: [],
|
||||
views: {
|
||||
events: true,
|
||||
@@ -137,12 +133,11 @@ const initial = {
|
||||
|
||||
features: {
|
||||
USE_COVER: false,
|
||||
USE_FILTERS: false,
|
||||
USE_ASSOCIATIONS: false,
|
||||
USE_SEARCH: false,
|
||||
USE_SITES: false,
|
||||
USE_SOURCES: false,
|
||||
USE_SHAPES: false,
|
||||
USE_NARRATIVES: false,
|
||||
GRAPH_NONLOCATED: false,
|
||||
HIGHLIGHT_GROUPS: false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user