mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-11 04:48:36 +03:00
284 lines
9.0 KiB
JavaScript
284 lines
9.0 KiB
JavaScript
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.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 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.associations.narrative
|
|
export const getFeatures = state => state.features
|
|
export const getEventRadius = state => state.ui.eventRadius
|
|
|
|
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 filter
|
|
* 3. exist in an active category
|
|
*/
|
|
export const selectEvents = createSelector(
|
|
[getEvents, getActiveFilters, getActiveCategories, getTimeRange, getFeatures],
|
|
(events, activeFilters, activeCategories, timeRange, features) => {
|
|
return events.reduce((acc, event) => {
|
|
const isMatchingFilter = (event.associations &&
|
|
event.associations.map(association =>
|
|
activeFilters.includes(association))
|
|
.some(s => s)
|
|
) || activeFilters.length === 0
|
|
const isActiveFilter = isMatchingFilter || activeFilters.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 && isActiveFilter && isActiveCategory) {
|
|
acc[event.id] = { ...event }
|
|
}
|
|
|
|
return acc
|
|
}, [])
|
|
})
|
|
|
|
/**
|
|
* Of all available events, selects those that fall within the time range,
|
|
* and if filters are being used, select them if their filters are enabled
|
|
*/
|
|
export const selectNarratives = createSelector(
|
|
[getEvents, getNarratives, getSources, getFeatures],
|
|
(events, narrativesMeta, sources, features) => {
|
|
if (Array.isArray(narrativesMeta) && narrativesMeta.length === 0) {
|
|
return []
|
|
}
|
|
const narratives = {}
|
|
const narrativeSkeleton = id => ({ id, steps: [] })
|
|
|
|
/* populate narratives dict with events */
|
|
events.forEach(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)
|
|
|
|
const existingAssociatedNarrative = narrativesMeta.find(n => n.id === key)
|
|
|
|
if (existingAssociatedNarrative) {
|
|
narratives[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, selectNarrativeIdx],
|
|
(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}$_${event.latitude}_${event.longitude}`
|
|
|
|
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, getEventRadius],
|
|
(events, features, eventRadius) => {
|
|
if (!features.GRAPH_NONLOCATED) {
|
|
return [events, []]
|
|
}
|
|
const projSize = 2 * eventRadius
|
|
const projectIdx = features.GRAPH_NONLOCATED.projectIdx || 0
|
|
const getProject = ev => ev.filters[projectIdx]
|
|
const projects = {}
|
|
|
|
// get all projects
|
|
events = events.reduce((acc, event) => {
|
|
const project = event.filters.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
|
|
}
|
|
}
|
|
)
|