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:
Lachlan Kermode
2020-09-23 17:12:34 +01:00
committed by GitHub
23 changed files with 249 additions and 372 deletions

View File

@@ -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 |

View File

@@ -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,

View File

@@ -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
View File

@@ -0,0 +1,2 @@
export const FILTER_MODE = 'FILTER'
export const NARRATIVE_MODE = 'NARRATIVE'

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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>
)
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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>&larr; <CardNarrativeLink {...props} event={props.next} /></p>
<p>&rarr; <CardNarrativeLink {...props} event={props.prev} /></p>
</div>
</div>
)
export default CardNarrative

View File

@@ -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

View File

@@ -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)

View File

@@ -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
}
}
}

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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) => {

View File

@@ -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))
}
)

View File

@@ -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
}