mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-13 05:48:36 +03:00
remove 'timestamps' in rendering events, clean projects
This commit is contained in:
@@ -341,8 +341,8 @@ class Timeline extends React.Component {
|
||||
noCategories={this.props.domain.categories && this.props.domain.categories.length}
|
||||
/>
|
||||
<Events
|
||||
events={this.props.domain.events}
|
||||
datetimes={this.props.domain.datetimes}
|
||||
events={this.props.domain.eventsAndProjects[0]}
|
||||
projects={this.props.domain.eventsAndProjects[1]}
|
||||
styleDatetime={this.styleDatetime}
|
||||
narrative={this.props.app.narrative}
|
||||
getDatetimeX={this.getDatetimeX}
|
||||
@@ -365,8 +365,7 @@ function mapStateToProps (state) {
|
||||
dimensions: selectors.selectDimensions(state),
|
||||
isNarrative: !!state.app.narrative,
|
||||
domain: {
|
||||
events: selectors.selectEvents(state),
|
||||
datetimes: selectors.selectDatetimes(state),
|
||||
eventsAndProjects: selectors.selectEventsAndProjects(state),
|
||||
categories: selectors.getCategories(state),
|
||||
narratives: state.domain.narratives
|
||||
},
|
||||
|
||||
@@ -7,187 +7,79 @@ import Project from './Project'
|
||||
import { calcOpacity } from '../../../common/utilities'
|
||||
import { sizes } from '../../../common/global'
|
||||
|
||||
// return a list of lists, where each list corresponds to a single category
|
||||
function getDotsToRender (events) {
|
||||
// each datetime needs to render as many dots as there are distinct
|
||||
// categories in the events contained by the datetime.
|
||||
// To this end, eventsByCategory is an intermediate data structure that
|
||||
// groups a datetime's events by distinct categories
|
||||
const eventsByCategory = {}
|
||||
events.forEach(ev => {
|
||||
if (eventsByCategory[ev.category]) {
|
||||
eventsByCategory[ev.category].events.push((ev))
|
||||
} else {
|
||||
eventsByCategory[ev.category] = {
|
||||
category: ev.category,
|
||||
events: [ ev ]
|
||||
}
|
||||
}
|
||||
})
|
||||
const GRAPH_NONLOCATED = 'GRAPH_NONLOCATED' in process.env.features && process.env.features.GRAPH_NONLOCATED
|
||||
|
||||
return Object.values(eventsByCategory)
|
||||
function renderDot (event, styles, props) {
|
||||
return <DatetimeDot
|
||||
onSelect={props.onSelect}
|
||||
category={event.category}
|
||||
events={[event]}
|
||||
x={props.x}
|
||||
y={props.y}
|
||||
r={sizes.eventDotR}
|
||||
styleProps={styles}
|
||||
/>
|
||||
}
|
||||
|
||||
function renderBar (event, styles, props) {
|
||||
const fillOpacity = GRAPH_NONLOCATED
|
||||
? event.projectOffset >= 0 ? styles.opacity : 0.05
|
||||
: 0.6
|
||||
|
||||
return <DatetimeBar
|
||||
onSelect={props.onSelect}
|
||||
category={event.category}
|
||||
events={[event]}
|
||||
x={props.x}
|
||||
y={props.dims.marginTop}
|
||||
width={sizes.eventDotR / 4}
|
||||
height={props.dims.trackHeight}
|
||||
styleProps={{ ...styles, fillOpacity }}
|
||||
/>
|
||||
}
|
||||
|
||||
function renderDiamond (event, styles, props) {
|
||||
return <DatetimeSquare
|
||||
onSelect={props.onSelect}
|
||||
x={props.x}
|
||||
y={props.y}
|
||||
r={1.8 * sizes.eventDotR}
|
||||
styleProps={styles}
|
||||
/>
|
||||
}
|
||||
|
||||
function renderStar (event, styles, props) {
|
||||
return <DatetimeStar
|
||||
onSelect={props.onSelect}
|
||||
x={props.x}
|
||||
y={props.y}
|
||||
r={1.8 * sizes.eventDotR}
|
||||
styleProps={{ ...styles, fillRule: 'nonzero' }}
|
||||
transform='rotate(90)'
|
||||
/>
|
||||
}
|
||||
const HAS_PROJECTS = 'ASSOCIATIVE_EVENTS_BY_TAG' in process.env.features && process.env.features.ASSOCIATIVE_EVENTS_BY_TAG
|
||||
|
||||
const TimelineEvents = ({
|
||||
events,
|
||||
datetimes,
|
||||
projects,
|
||||
narrative,
|
||||
getDatetimeX,
|
||||
getCategoryY,
|
||||
getCategoryColor,
|
||||
onSelect,
|
||||
transitionDuration,
|
||||
styleDatetime,
|
||||
// styleDatetime,
|
||||
dims
|
||||
}) => {
|
||||
function renderDot (event, colour) {
|
||||
const props = ({
|
||||
fill: colour,
|
||||
fillOpacity: calcOpacity(1),
|
||||
transition: `transform ${transitionDuration / 1000}s ease`
|
||||
})
|
||||
return <DatetimeDot
|
||||
onSelect={() => onSelect([event])}
|
||||
category={event.category}
|
||||
events={[event]}
|
||||
x={getDatetimeX(event.timestamp)}
|
||||
y={getCategoryY(event.category)}
|
||||
r={sizes.eventDotR}
|
||||
styleProps={props}
|
||||
/>
|
||||
}
|
||||
const narIds = narrative ? narrative.steps.map(s => s.id) : []
|
||||
|
||||
function renderBar (event, colour) {
|
||||
const evOpacity = calcOpacity(1)
|
||||
const props = {
|
||||
fill: colour,
|
||||
fillOpacity: HAS_PROJECTS
|
||||
? event.projectOffset >= 0 ? evOpacity : 0.05
|
||||
: 0.6
|
||||
}
|
||||
return <DatetimeBar
|
||||
onSelect={() => onSelect([event])}
|
||||
category={event.category}
|
||||
events={[event]}
|
||||
x={getDatetimeX(event.timestamp)}
|
||||
y={dims.marginTop}
|
||||
width={sizes.eventDotR / 4}
|
||||
height={dims.trackHeight}
|
||||
styleProps={props}
|
||||
/>
|
||||
}
|
||||
|
||||
function renderDiamond (event, colour) {
|
||||
const props = ({
|
||||
fill: colour,
|
||||
fillOpacity: calcOpacity(1),
|
||||
transition: `transform ${transitionDuration / 1000}s ease`
|
||||
})
|
||||
return <DatetimeSquare
|
||||
onSelect={() => onSelect([event])}
|
||||
x={getDatetimeX(event.timestamp)}
|
||||
y={getCategoryY(event.category)}
|
||||
r={1.8 * sizes.eventDotR}
|
||||
styleProps={props}
|
||||
/>
|
||||
}
|
||||
|
||||
function renderStar (event, colour) {
|
||||
const props = ({
|
||||
fill: colour,
|
||||
fillOpacity: calcOpacity(1),
|
||||
transition: `transform ${transitionDuration / 1000}s ease`,
|
||||
fillRule: 'nonzero'
|
||||
|
||||
})
|
||||
return <DatetimeStar
|
||||
onSelect={() => onSelect([event])}
|
||||
x={getDatetimeX(event.timestamp)}
|
||||
y={getCategoryY(event.category)}
|
||||
r={1.8 * sizes.eventDotR}
|
||||
styleProps={props}
|
||||
transform='rotate(90)'
|
||||
/>
|
||||
}
|
||||
|
||||
function renderDatetime (datetime) {
|
||||
// narrative checking for non-rendering still uses datetimes as legacy TODO(lachlan)
|
||||
function renderEvent (event) {
|
||||
if (narrative) {
|
||||
const { steps } = narrative
|
||||
// check all events in the datetime before rendering in narrative
|
||||
let isInNarrative = false
|
||||
for (let i = 0; i < datetime.events.length; i++) {
|
||||
const event = datetime.events[i]
|
||||
if (steps.map(s => s.id).includes(event.id)) {
|
||||
isInNarrative = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInNarrative) {
|
||||
if (!(narIds.includes(event.id))) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/* DEFAULTS TODO(lachlan): clean up */
|
||||
const dotsToRender = getDotsToRender(datetime.events)
|
||||
|
||||
return dotsToRender.map(dot => {
|
||||
const customStyles = styleDatetime ? styleDatetime(datetime, dot.category) : null
|
||||
const extraStyles = customStyles[0]
|
||||
const extraRender = customStyles[1]
|
||||
|
||||
// default to category for colour, and located/unlocated for shape
|
||||
const locatedEvents = dot.events.filter(ev => ev.latitude && ev.longitude)
|
||||
const unlocatedEvents = dot.events.filter(ev => !ev.latitude || !ev.longitude)
|
||||
|
||||
// TODO: work out smarter way to manage opacity w.r.t. length
|
||||
// i.e. render (count - 1) extra dots with a bit of noise in position
|
||||
// and that, when clicked, all open the same events.
|
||||
|
||||
const unlocatedProps = {
|
||||
fillOpacity: HAS_PROJECTS
|
||||
? unlocatedEvents.some(ev => ev.projectOffset >= 0) ? calcOpacity(unlocatedEvents.length) : 0.05
|
||||
: calcOpacity(unlocatedEvents.length) / 4
|
||||
}
|
||||
|
||||
let bar = <DatetimeBar
|
||||
onSelect={() => onSelect(unlocatedEvents)}
|
||||
category={dot.category}
|
||||
events={unlocatedEvents}
|
||||
x={getDatetimeX(datetime.timestamp)}
|
||||
y={dims.marginTop}
|
||||
width={sizes.eventDotR}
|
||||
height={dims.trackHeight}
|
||||
styleProps={unlocatedProps}
|
||||
/>
|
||||
if (process.env.features.ASSOCIATIVE_EVENTS_BY_TAG) {
|
||||
// render all dots individually
|
||||
bar = <React.Fragment>
|
||||
{unlocatedEvents.map(ev => (<DatetimeBar
|
||||
onSelect={() => onSelect(unlocatedEvents)}
|
||||
category={dot.category}
|
||||
events={[ev]}
|
||||
x={getDatetimeX(datetime.timestamp)}
|
||||
y={ev.projectOffset >= 0 ? dims.trackHeight - ev.projectOffset : dims.marginTop}
|
||||
width={sizes.eventDotR}
|
||||
height={ev.projectOffset >= 0 ? sizes.eventDotR * 2 : dims.trackHeight}
|
||||
styleProps={unlocatedProps}
|
||||
/>))}
|
||||
</React.Fragment>
|
||||
}
|
||||
return (
|
||||
<g className='datetime'>
|
||||
{locatedEvents.length >= 1 && renderCircle()}
|
||||
{unlocatedEvents.length >= 1 && bar}
|
||||
{extraRender ? extraRender() : null}
|
||||
</g>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function renderEvent (event) {
|
||||
let renderShape = renderDot
|
||||
if (event.shape) {
|
||||
if (event.shape === 'bar') {
|
||||
@@ -200,18 +92,29 @@ const TimelineEvents = ({
|
||||
}
|
||||
|
||||
const colour = event.colour ? event.colour : getCategoryColor(event.category)
|
||||
return renderShape(event, colour)
|
||||
const styles = {
|
||||
fill: colour,
|
||||
fillOpacity: calcOpacity(1),
|
||||
transition: `transform ${transitionDuration / 1000}s ease`
|
||||
}
|
||||
return renderShape(event, styles, {
|
||||
x: getDatetimeX(event.timestamp),
|
||||
y: (GRAPH_NONLOCATED && !event.latitude && !event.longitude)
|
||||
? event.projectOffset >= 0 ? dims.trackHeight - event.projectOffset : dims.marginTop
|
||||
: getCategoryY(event.category),
|
||||
onSelect: () => onSelect([event]),
|
||||
dims
|
||||
})
|
||||
}
|
||||
|
||||
/* set `renderProjects` */
|
||||
let renderProjects = () => null
|
||||
if (process.env.features.ASSOCIATIVE_EVENTS_BY_TAG) {
|
||||
const projects = datetimes[1]
|
||||
datetimes = datetimes[0]
|
||||
if (GRAPH_NONLOCATED) {
|
||||
renderProjects = function () {
|
||||
return <React.Fragment>
|
||||
{projects.map(project => <Project
|
||||
{...project}
|
||||
onClick={() => console.log(project)}
|
||||
getX={getDatetimeX}
|
||||
dims={dims}
|
||||
colour={getCategoryColor(project.category)}
|
||||
@@ -225,7 +128,6 @@ const TimelineEvents = ({
|
||||
clipPath={'url(#clip)'}
|
||||
>
|
||||
{renderProjects()}
|
||||
{/* {datetimes.map(datetime => renderDatetime(datetime))} */}
|
||||
{events.map(event => renderEvent(event))}
|
||||
</g>
|
||||
)
|
||||
|
||||
@@ -16,9 +16,9 @@ export default ({
|
||||
onClick={onClick}
|
||||
className='project'
|
||||
x={getX(start)}
|
||||
y={dims.trackHeight - offset}
|
||||
y={dims.trackHeight - (offset + sizes.eventDotR)}
|
||||
width={length}
|
||||
style={{ fill: colour, fillOpacity: 0.1 }}
|
||||
style={{ fill: colour, fillOpacity: 0.2 }}
|
||||
height={2 * sizes.eventDotR}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createSelector } from 'reselect'
|
||||
import { compareTimestamp, insetSourceFrom, dateMin, dateMax } from '../common/utilities'
|
||||
import { isTimeRangedIn, shuffle } from './helpers'
|
||||
import { sizes } from '../common/global'
|
||||
const HAS_PROJECTS = 'ASSOCIATIVE_EVENTS_BY_TAG' in process.env.features && process.env.features.ASSOCIATIVE_EVENTS_BY_TAG
|
||||
const GRAPH_NONLOCATED = 'GRAPH_NONLOCATED' in process.env.features && process.env.features.GRAPH_NONLOCATED
|
||||
|
||||
// Input selectors
|
||||
export const getEvents = state => state.domain.events
|
||||
@@ -45,7 +45,7 @@ export const selectEvents = createSelector(
|
||||
const isActiveTag = isMatchingTag || activeTags.length === 0
|
||||
const isActiveCategory = activeCategories.includes(event.category) || activeCategories.length === 0
|
||||
let isActiveTime = isTimeRangedIn(event, timeRange)
|
||||
isActiveTime = HAS_PROJECTS ? ((!event.latitude && !event.longitude) || isActiveTime) : isActiveTime
|
||||
isActiveTime = GRAPH_NONLOCATED ? ((!event.latitude && !event.longitude) || isActiveTime) : isActiveTime
|
||||
|
||||
if (isActiveTime && isActiveTag && isActiveCategory) {
|
||||
acc[event.id] = { ...event }
|
||||
@@ -145,6 +145,13 @@ export const selectLocations = createSelector(
|
||||
}
|
||||
)
|
||||
|
||||
export const selectProjectedEvents = createSelector(
|
||||
[selectEvents],
|
||||
events => {
|
||||
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Group events by 'datetime'. Each datetime is an object:
|
||||
{
|
||||
@@ -154,116 +161,77 @@ export const selectLocations = createSelector(
|
||||
events: [...]
|
||||
}
|
||||
*/
|
||||
export const selectDatetimes = createSelector(
|
||||
export const selectEventsAndProjects = createSelector(
|
||||
[selectEvents],
|
||||
events => {
|
||||
const projects = {}
|
||||
const datetimes = {}
|
||||
events.forEach(event => {
|
||||
const { timestamp } = event
|
||||
/** Create timestamp with fresh dtKey always by default */
|
||||
let dtIdx = 1
|
||||
let dtKey = `${timestamp}_${dtIdx}`
|
||||
let tsExists = datetimes.hasOwnProperty(dtKey)
|
||||
while (tsExists) {
|
||||
dtIdx += 1
|
||||
dtKey = `${timestamp}_${dtIdx}`
|
||||
tsExists = datetimes.hasOwnProperty(dtKey)
|
||||
}
|
||||
|
||||
if (HAS_PROJECTS) {
|
||||
const project = event.tags.length >= 1 && !event.latitude && !event.longitude ? event.tags[0] : null
|
||||
event = { ...event, project }
|
||||
if (project !== null) {
|
||||
if (projects.hasOwnProperty(project)) {
|
||||
projects[project].start = dateMin(projects[project].start, event.timestamp)
|
||||
projects[project].end = dateMax(projects[project].end, event.timestamp)
|
||||
} else {
|
||||
projects[project] = { start: event.timestamp, end: event.timestamp }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** We need to work out whether we can add the event to an existing
|
||||
* timestamp, or whether we need to create a new one. What determines
|
||||
* this is whether or not ALL events in a timestamp have a matching
|
||||
* project. We not only need to check the current dtKey, but also all
|
||||
* dtKeys that have the same timestamp.
|
||||
*
|
||||
* It's a pretty whack algorithm, but I think it does what it's supposed
|
||||
* to. This is only run when projects are showing.
|
||||
* TODO: find a more module way to interface with this code.
|
||||
*/
|
||||
let shouldCreate = true
|
||||
if (HAS_PROJECTS && dtIdx >= 2 && !(!!event.latitude && !!event.longitude) && event.project !== null) {
|
||||
const allExistingIdxs = [...Array(dtIdx - 1).keys()].map(k => k + 1)
|
||||
let foundMatching = false
|
||||
allExistingIdxs.forEach(_idx => {
|
||||
const _dtKey = `${timestamp}_${_idx}`
|
||||
const isSameTimestampAndAllSameProjects = datetimes[_dtKey].events.every(ev => ev.project === event.project)
|
||||
if (isSameTimestampAndAllSameProjects) {
|
||||
dtKey = _dtKey
|
||||
foundMatching = true
|
||||
}
|
||||
})
|
||||
if (!foundMatching) {
|
||||
shouldCreate = true
|
||||
}
|
||||
}
|
||||
if (shouldCreate) {
|
||||
datetimes[dtKey] = {
|
||||
timestamp: event.timestamp,
|
||||
date: event.date,
|
||||
time: event.time,
|
||||
events: [event]
|
||||
}
|
||||
} else {
|
||||
datetimes[dtKey].events.push(event)
|
||||
}
|
||||
})
|
||||
|
||||
const output = []
|
||||
if (HAS_PROJECTS) {
|
||||
const projKeys = Object.keys(projects)
|
||||
let sortedDts = Object.keys(datetimes)
|
||||
|
||||
sortedDts.sort((a, b) => {
|
||||
const x = a.substring(0, a.length - 2)
|
||||
const y = b.substring(0, b.length - 2)
|
||||
return new Date(x) - new Date(y)
|
||||
})
|
||||
sortedDts.forEach(dt => {
|
||||
const activeProjects = []
|
||||
projKeys.forEach((k, idx) => {
|
||||
if (dt >= projects[k].start && dt <= projects[k].end) activeProjects.push(k)
|
||||
})
|
||||
output.push({
|
||||
...datetimes[dt],
|
||||
events: datetimes[dt].events.map(ev => {
|
||||
const activeIdx = activeProjects.indexOf(ev.project)
|
||||
let projectOffset = (activeIdx + 1) * (2.5 * sizes.eventDotR)
|
||||
if (activeIdx === -1) projectOffset = -1
|
||||
if (ev.project !== null && !projects[ev.project].hasOwnProperty('offset')) {
|
||||
projects[ev.project].offset = projectOffset
|
||||
projects[ev.project].category = ev.category
|
||||
} else if (ev.project !== null) {
|
||||
projectOffset = projects[ev.project].offset
|
||||
}
|
||||
return {
|
||||
...ev,
|
||||
projectOffset
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
const projectsOut = []
|
||||
Object.keys(projects).forEach(projId => {
|
||||
projectsOut.push({ ...projects[projId], id: projId })
|
||||
})
|
||||
return [output, projectsOut]
|
||||
if (!GRAPH_NONLOCATED) {
|
||||
return [events, []]
|
||||
}
|
||||
|
||||
return Object.values(datetimes)
|
||||
// NOTE: change this line if you want to extract projects from a different column
|
||||
function getProject (ev) {
|
||||
return ev.tags[0]
|
||||
}
|
||||
|
||||
events.sort((a, b) => {
|
||||
const x = a.timestamp.substring(0, a.timestamp.length - 2)
|
||||
const y = b.timestamp.substring(0, b.timestamp.length - 2)
|
||||
return new Date(x) - new Date(y)
|
||||
})
|
||||
|
||||
// reduce events to get projects
|
||||
const projects = {}
|
||||
// const activeProjects = []
|
||||
const projEvents = 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.timestamp)
|
||||
projects[project].end = dateMax(projects[project].end, event.timestamp)
|
||||
} else {
|
||||
projects[project] = { start: event.timestamp, end: event.timestamp }
|
||||
}
|
||||
}
|
||||
acc.push({ ...event, project })
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
// reduce projEvents to get _events
|
||||
const projKeys = Object.keys(projects)
|
||||
const _events = projEvents.reduce((acc, event) => {
|
||||
// infer activeProjects from timestamp
|
||||
const activeProjects = []
|
||||
projKeys.forEach((k, idx) => {
|
||||
if (event.timestamp >= projects[k].start && event.timestamp <= projects[k].end) {
|
||||
activeProjects.push(k)
|
||||
}
|
||||
})
|
||||
|
||||
// infer projectOffset using activeProjects
|
||||
// TODO(lachlan) projects get overlaid on the first layer...
|
||||
const activeIdx = activeProjects.indexOf(event.project)
|
||||
let projectOffset = (activeIdx + 3) * (2.5 * sizes.eventDotR)
|
||||
if (activeIdx === -1) {
|
||||
projectOffset = -1
|
||||
}
|
||||
if (event.project !== null && !projects[event.project].hasOwnProperty('offset')) {
|
||||
projects[event.project].offset = projectOffset
|
||||
projects[event.project].category = event.category
|
||||
} else if (event.project !== null) {
|
||||
projectOffset = projects[event.project].offset
|
||||
}
|
||||
acc.push({ ...event, projectOffset })
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
const _projects = []
|
||||
projKeys.forEach(projId => {
|
||||
_projects.push({ ...projects[projId], id: projId })
|
||||
})
|
||||
|
||||
return [_events, _projects]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user