mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-11 21:08:36 +03:00
260 lines
7.9 KiB
JavaScript
260 lines
7.9 KiB
JavaScript
import { createSelector } from 'reselect'
|
|
import { insetSourceFrom, dateMin, dateMax } from '../common/utilities'
|
|
import { isTimeRangedIn } from './helpers'
|
|
import { sizes } from '../common/global'
|
|
|
|
// 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 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 getNotifications = state => state.domain.notifications
|
|
export const getTagTree = state => state.domain.tags
|
|
export const getActiveTags = state => state.app.filters.tags
|
|
export const getActiveCategories = state => state.app.filters.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 getFeatures = state => state.features
|
|
|
|
export const selectSites = createSelector([getSites, getFeatures], (sites, features) => {
|
|
if (features.USE_SITES) {
|
|
return sites.filter(s => !!(+s.enabled))
|
|
}
|
|
return []
|
|
})
|
|
|
|
export const selectSources = createSelector([getSources, getFeatures], (sources, features) => {
|
|
if (features.USE_SOURCES) return sources
|
|
return {}
|
|
})
|
|
|
|
export const selectShapes = createSelector([getShapes, getFeatures], (shapes, features) => {
|
|
if (features.USE_SHAPES) return shapes
|
|
return []
|
|
})
|
|
|
|
/**
|
|
* Of all available events, selects those that
|
|
* 1. fall in time range
|
|
* 2. exist in an active tag
|
|
* 3. exist in an active category
|
|
*/
|
|
export const selectEvents = createSelector(
|
|
[getEvents, getActiveTags, getActiveCategories, getTimeRange, getFeatures],
|
|
(events, activeTags, activeCategories, timeRange, features) => {
|
|
return events.reduce((acc, event) => {
|
|
const isMatchingTag = (event.tags && event.tags.map(tag => activeTags.includes(tag)).some(s => s)) || activeTags.length === 0
|
|
const isActiveTag = isMatchingTag || activeTags.length === 0
|
|
const isActiveCategory = activeCategories.includes(event.category) || activeCategories.length === 0
|
|
let isActiveTime = isTimeRangedIn(event, timeRange)
|
|
isActiveTime = features.GRAPH_NONLOCATED ? ((!event.latitude && !event.longitude) || isActiveTime) : isActiveTime
|
|
|
|
if (isActiveTime && isActiveTag && isActiveCategory) {
|
|
acc[event.id] = { ...event }
|
|
}
|
|
|
|
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, getSources, getFeatures],
|
|
(events, narrativesMeta, sources, features) => {
|
|
if (!features.USE_NARRATIVES) {
|
|
return []
|
|
}
|
|
const narratives = {}
|
|
const narrativeSkeleton = id => ({ id, steps: [] })
|
|
|
|
/* populate narratives dict with events */
|
|
events.forEach(evt => {
|
|
const isInNarrative = evt.narratives.length > 0
|
|
|
|
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))
|
|
}
|
|
})
|
|
})
|
|
|
|
/* 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)) {
|
|
narratives[key] = {
|
|
...narrativesMeta.find(n => n.id === key),
|
|
...narratives[key]
|
|
}
|
|
}
|
|
})
|
|
|
|
// Return narratives in original order
|
|
// + filter those that are undefined
|
|
return narrativesMeta.map(n => narratives[n.id]).filter(d => d)
|
|
})
|
|
|
|
/** 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
|
|
)
|
|
|
|
/**
|
|
* Group events by location. Each location is an object:
|
|
{
|
|
events: [...],
|
|
label: 'Location name',
|
|
latitude: '47.7',
|
|
longitude: '32.2'
|
|
}
|
|
*/
|
|
export const selectLocations = createSelector(
|
|
[selectEvents],
|
|
(events) => {
|
|
const activeLocations = {}
|
|
events.forEach(event => {
|
|
const location = event.location
|
|
|
|
if (activeLocations[location]) {
|
|
activeLocations[location].events.push(event)
|
|
} else {
|
|
activeLocations[location] = {
|
|
label: location,
|
|
events: [event],
|
|
latitude: event.latitude,
|
|
longitude: event.longitude
|
|
}
|
|
}
|
|
})
|
|
|
|
return Object.values(activeLocations)
|
|
}
|
|
)
|
|
|
|
export const selectEventsWithProjects = createSelector(
|
|
[selectEvents, getFeatures],
|
|
(events, features) => {
|
|
if (!features.GRAPH_NONLOCATED) {
|
|
return [events, []]
|
|
}
|
|
const projSize = 2 * sizes.eventDotR
|
|
const projectIdx = features.GRAPH_NONLOCATED.projectIdx || 0
|
|
const getProject = ev => ev.tags[projectIdx]
|
|
const projects = {}
|
|
|
|
// get all projects
|
|
events = events.reduce((acc, event) => {
|
|
const project = event.tags.length >= 1 && !event.latitude && !event.longitude ? getProject(event) : null
|
|
|
|
// add project if it doesn't exist
|
|
if (project !== null) {
|
|
if (projects.hasOwnProperty(project)) {
|
|
projects[project].start = dateMin(projects[project].start, event.datetime)
|
|
projects[project].end = dateMax(projects[project].end, event.datetime)
|
|
} else {
|
|
projects[project] = {
|
|
start: event.datetime,
|
|
end: event.datetime,
|
|
key: project,
|
|
category: event.category
|
|
}
|
|
}
|
|
}
|
|
acc.push({ ...event, project })
|
|
return acc
|
|
}, [])
|
|
|
|
let projObjs = Object.values(projects)
|
|
projObjs.sort((a, b) => a.start - b.start)
|
|
|
|
// active projects is a data structure with projObjs.length empty slots
|
|
let activeProjs = Object.keys(projects).map((_, idx) => null)
|
|
|
|
const projectsWithOffset = projObjs.reduce((acc, proj, theIdx) => {
|
|
// remove any project that have ended from slots
|
|
activeProjs.forEach((theProj, theProjIdx) => {
|
|
if (theProj !== null) {
|
|
const projInSlot = projects[theProj]
|
|
if (projInSlot.end < proj.start) {
|
|
activeProjs[theProjIdx] = null
|
|
}
|
|
}
|
|
})
|
|
let i = 0
|
|
// find the first empty slot
|
|
while (activeProjs[i]) i++
|
|
// put proj in slot
|
|
activeProjs[i] = proj.key
|
|
|
|
proj.offset = i * projSize
|
|
acc[proj.key] = proj
|
|
return acc
|
|
}, {})
|
|
|
|
return [events, projectsWithOffset]
|
|
}
|
|
)
|
|
|
|
export const selectStackedEvents = createSelector(
|
|
[selectEventsWithProjects],
|
|
eventsWithProjects => {
|
|
return eventsWithProjects[0]
|
|
}
|
|
)
|
|
|
|
export const selectProjects = createSelector(
|
|
[selectEventsWithProjects, getFeatures],
|
|
(eventsWithProjects, features) => {
|
|
if (!features.GRAPH_NONLOCATED) {
|
|
return []
|
|
}
|
|
return eventsWithProjects[1]
|
|
}
|
|
)
|
|
|
|
/**
|
|
* Of all the sources, select those that are relevant to the selected events.
|
|
*/
|
|
export const selectSelected = createSelector(
|
|
[getSelected, getSources],
|
|
(selected, sources) => {
|
|
if (selected.length === 0) {
|
|
return []
|
|
}
|
|
|
|
return selected.map(insetSourceFrom(sources))
|
|
}
|
|
)
|
|
|
|
export const selectDimensions = createSelector(
|
|
[getTimelineDimensions],
|
|
(dimensions) => {
|
|
return {
|
|
...dimensions,
|
|
trackHeight: dimensions.contentHeight - 50 // height of time labels
|
|
}
|
|
}
|
|
)
|