mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-08 03:18:36 +03:00
correct timeline selection
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
"react-portal": "^4.2.0",
|
||||
"react-redux": "^5.0.4",
|
||||
"react-tabs": "3.0.0",
|
||||
"react-zoom-pan-pinch": "^1.6.1",
|
||||
"redux": "^3.6.0",
|
||||
"redux-thunk": "^2.2.0",
|
||||
"reselect": "^3.0.1",
|
||||
|
||||
@@ -187,3 +187,23 @@ export function calcOpacity (num) {
|
||||
export const dateMin = function () { return Array.prototype.slice.call(arguments).reduce(function (a, b) { return a < b ? a : b }) }
|
||||
|
||||
export const dateMax = function () { return Array.prototype.slice.call(arguments).reduce(function (a, b) { return a > b ? a : b }) }
|
||||
|
||||
/** Taken from
|
||||
* https://stackoverflow.com/questions/22697936/binary-search-in-javascript
|
||||
* **/
|
||||
export function binarySearch (ar, el, compareFn) {
|
||||
var m = 0
|
||||
var n = ar.length - 1
|
||||
while (m <= n) {
|
||||
var k = (n + m) >> 1
|
||||
var cmp = compareFn(el, ar[k])
|
||||
if (cmp > 0) {
|
||||
m = k + 1
|
||||
} else if (cmp < 0) {
|
||||
n = k - 1
|
||||
} else {
|
||||
return k
|
||||
}
|
||||
}
|
||||
return -m - 1
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import Notification from './Notification.jsx'
|
||||
import StaticPage from './StaticPage'
|
||||
import TemplateCover from './TemplateCover'
|
||||
|
||||
import { parseDate } from '../common/utilities'
|
||||
import { parseDate, binarySearch } from '../common/utilities'
|
||||
import { isMobile } from 'react-device-detect'
|
||||
|
||||
class Dashboard extends React.Component {
|
||||
@@ -29,8 +29,6 @@ class Dashboard extends React.Component {
|
||||
this.moveInNarrative = this.moveInNarrative.bind(this)
|
||||
this.handleSelect = this.handleSelect.bind(this)
|
||||
this.getCategoryColor = this.getCategoryColor.bind(this)
|
||||
|
||||
this.eventsById = {}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
@@ -49,23 +47,36 @@ class Dashboard extends React.Component {
|
||||
this.props.actions.updateHighlighted((highlighted) || null)
|
||||
}
|
||||
|
||||
getEventById (eventId) {
|
||||
if (this.eventsById[eventId]) return this.eventsById[eventId]
|
||||
this.eventsById[eventId] = this.props.domain.events.find(ev => ev.id === eventId)
|
||||
return this.eventsById[eventId]
|
||||
}
|
||||
|
||||
handleViewSource (source) {
|
||||
this.props.actions.updateSource(source)
|
||||
}
|
||||
|
||||
handleSelect (selected) {
|
||||
if (selected) {
|
||||
let eventsToSelect = selected.map(event => this.getEventById(event.id))
|
||||
eventsToSelect = eventsToSelect.sort((a, b) => parseDate(a.timestamp) - parseDate(b.timestamp))
|
||||
|
||||
this.props.actions.updateSelected(eventsToSelect)
|
||||
handleSelect (selected, axis) {
|
||||
const matchedEvents = [selected]
|
||||
const TIMELINE_AXIS = 0
|
||||
if (axis === TIMELINE_AXIS) {
|
||||
// find in events
|
||||
const { events } = this.props.domain
|
||||
const idx = binarySearch(
|
||||
events,
|
||||
selected,
|
||||
(e1, e2) => new Date(e1.timestamp) - new Date(e2.timestamp)
|
||||
)
|
||||
// check events before
|
||||
let ptr = idx - 1
|
||||
while (events[idx].timestamp === events[ptr].timestamp) {
|
||||
matchedEvents.push(events[ptr])
|
||||
ptr -= 1
|
||||
}
|
||||
// check events after
|
||||
ptr = idx + 1
|
||||
while (events[idx].timestamp === events[ptr].timestamp) {
|
||||
matchedEvents.push(events[ptr])
|
||||
ptr += 1
|
||||
}
|
||||
}
|
||||
|
||||
this.props.actions.updateSelected(matchedEvents)
|
||||
}
|
||||
|
||||
getCategoryColor (category) {
|
||||
|
||||
@@ -272,7 +272,7 @@ function mapStateToProps (state) {
|
||||
},
|
||||
app: {
|
||||
views: state.app.filters.views,
|
||||
selected: state.app.selected,
|
||||
selected: selectors.selectSelected(state),
|
||||
highlighted: state.app.highlighted,
|
||||
map: state.app.map,
|
||||
narrative: state.app.narrative,
|
||||
|
||||
@@ -14,6 +14,7 @@ import ZoomControls from './presentational/Timeline/ZoomControls.js'
|
||||
import Markers from './presentational/Timeline/Markers.js'
|
||||
import Events from './presentational/Timeline/Events.js'
|
||||
import Categories from './TimelineCategories.jsx'
|
||||
const TIMELINE_AXIS = 0
|
||||
|
||||
class Timeline extends React.Component {
|
||||
constructor (props) {
|
||||
@@ -336,14 +337,14 @@ class Timeline extends React.Component {
|
||||
dims={dims}
|
||||
selected={this.props.app.selected}
|
||||
getEventX={this.getDatetimeX}
|
||||
getY={e => this.state.scaleY(e.category)}
|
||||
getCategoryY={this.state.scaleY}
|
||||
transitionDuration={this.state.transitionDuration}
|
||||
styles={this.props.ui.styles}
|
||||
noCategories={this.props.domain.categories && this.props.domain.categories.length}
|
||||
features={this.props.features}
|
||||
/>
|
||||
<Events
|
||||
events={this.props.domain.eventsAndProjects[0]}
|
||||
projects={this.props.domain.eventsAndProjects[1]}
|
||||
events={this.props.domain.events}
|
||||
projects={this.props.domain.projects}
|
||||
styleDatetime={this.styleDatetime}
|
||||
narrative={this.props.app.narrative}
|
||||
getDatetimeX={this.getDatetimeX}
|
||||
@@ -356,7 +357,7 @@ class Timeline extends React.Component {
|
||||
}}
|
||||
getCategoryColor={this.props.methods.getCategoryColor}
|
||||
transitionDuration={this.state.transitionDuration}
|
||||
onSelect={this.props.methods.onSelect}
|
||||
onSelect={ev => this.props.methods.onSelect(ev, TIMELINE_AXIS)}
|
||||
dims={dims}
|
||||
features={this.props.features}
|
||||
/>
|
||||
@@ -373,7 +374,8 @@ function mapStateToProps (state) {
|
||||
dimensions: selectors.selectDimensions(state),
|
||||
isNarrative: !!state.app.narrative,
|
||||
domain: {
|
||||
eventsAndProjects: selectors.selectEventsAndProjects(state),
|
||||
events: selectors.selectStackedEvents(state),
|
||||
projects: selectors.selectProjects(state),
|
||||
categories: selectors.getCategories(state),
|
||||
narratives: state.domain.narratives
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ function renderDot (event, styles, props) {
|
||||
|
||||
function renderBar (event, styles, props) {
|
||||
const fillOpacity = props.features.GRAPH_NONLOCATED
|
||||
? event.projectOffset >= 0 ? styles.opacity : 0.05
|
||||
? event.projectOffset >= 0 ? styles.opacity : 0.5
|
||||
: 0.6
|
||||
|
||||
return <DatetimeBar
|
||||
@@ -80,7 +80,8 @@ const TimelineEvents = ({
|
||||
}
|
||||
}
|
||||
|
||||
let renderShape = renderDot
|
||||
const isDot = (!!event.location && !!event.longitude) || (features.GRAPH_NONLOCATED && event.projectOffset !== -1)
|
||||
let renderShape = isDot ? renderDot : renderBar
|
||||
if (event.shape) {
|
||||
if (event.shape === 'bar') {
|
||||
renderShape = renderBar
|
||||
@@ -103,7 +104,7 @@ const TimelineEvents = ({
|
||||
y: (features.GRAPH_NONLOCATED && !event.latitude && !event.longitude)
|
||||
? event.projectOffset >= 0 ? dims.trackHeight - event.projectOffset : dims.marginTop
|
||||
: getCategoryY ? getCategoryY(event.category) : () => null,
|
||||
onSelect: () => onSelect([event]),
|
||||
onSelect: () => onSelect(event),
|
||||
dims,
|
||||
highlights: features.HIGHLIGHT_GROUPS ? getHighlights(event.tags[features.HIGHLIGHT_GROUPS.tagIndexIndicatingGroup]) : [],
|
||||
features
|
||||
|
||||
@@ -4,14 +4,18 @@ import colors, { sizes } from '../../../common/global'
|
||||
const TimelineMarkers = ({
|
||||
styles,
|
||||
getEventX,
|
||||
getY,
|
||||
getCategoryY,
|
||||
transitionDuration,
|
||||
selected,
|
||||
dims,
|
||||
noCategories
|
||||
features
|
||||
}) => {
|
||||
function renderMarker (event) {
|
||||
function renderCircle () {
|
||||
const yVal = (features.GRAPH_NONLOCATED && !event.latitude && !event.longitude)
|
||||
? event.projectOffset >= 0 ? dims.trackHeight - event.projectOffset : dims.marginTop
|
||||
: getCategoryY ? getCategoryY(event.category) : () => null
|
||||
|
||||
return <circle
|
||||
className='timeline-marker'
|
||||
cx={0}
|
||||
@@ -22,7 +26,7 @@ const TimelineMarkers = ({
|
||||
stroke-linejoin='round'
|
||||
stroke-dasharray={styles ? styles['stroke-dasharray'] : '2,2'}
|
||||
style={{
|
||||
'transform': `translate(${getEventX(event.timestamp)}px, ${getY(event)}px)`,
|
||||
'transform': `translate(${getEventX(event.timestamp)}px, ${yVal}px)`,
|
||||
'-webkit-transition': `transform ${transitionDuration / 1000}s ease`,
|
||||
'-moz-transition': 'none',
|
||||
'opacity': 0.9
|
||||
@@ -47,7 +51,7 @@ const TimelineMarkers = ({
|
||||
}}
|
||||
/>
|
||||
}
|
||||
const isLocated = !!event.latitude && !!event.longitude
|
||||
const isDot = (!features.GRAPH_NONLOCATED && !!event.latitude && !!event.longitude) || (features.GRAPH_NONLOCATED && (event.projectOffset !== -1 || (!!event.latitude && !!event.longitude)))
|
||||
switch (event.shape) {
|
||||
case 'circle':
|
||||
return renderCircle()
|
||||
@@ -58,7 +62,7 @@ const TimelineMarkers = ({
|
||||
case 'star':
|
||||
return renderCircle()
|
||||
default:
|
||||
return isLocated ? renderBar() : renderCircle()
|
||||
return isDot ? renderCircle() : renderBar()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -153,5 +153,8 @@ export function validateDomain (domain) {
|
||||
}
|
||||
sanitizedDomain.tags = domain.tags
|
||||
|
||||
// sort events by timestamp
|
||||
sanitizedDomain.events.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
|
||||
|
||||
return sanitizedDomain
|
||||
}
|
||||
|
||||
@@ -153,44 +153,17 @@ export const selectLocations = createSelector(
|
||||
}
|
||||
)
|
||||
|
||||
export const selectProjectedEvents = createSelector(
|
||||
[selectEvents],
|
||||
events => {
|
||||
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Group events by 'datetime'. Each datetime is an object:
|
||||
{
|
||||
timestamp: '',
|
||||
date: '8/23/2016',
|
||||
time: '12:00',
|
||||
events: [...]
|
||||
}
|
||||
*/
|
||||
export const selectEventsAndProjects = createSelector(
|
||||
export const selectEventsWithProjects = createSelector(
|
||||
[selectEvents, getFeatures],
|
||||
(events, features) => {
|
||||
if (!features.GRAPH_NONLOCATED) {
|
||||
return [events, []]
|
||||
}
|
||||
|
||||
// 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 projectIdx = features.GRAPH_NONLOCATED.projectIdx || 0
|
||||
const getProject = ev => ev.tags[projectIdx]
|
||||
const projects = {}
|
||||
// const activeProjects = []
|
||||
const projEvents = events.reduce((acc, event) => {
|
||||
|
||||
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
|
||||
@@ -206,9 +179,8 @@ export const selectEventsAndProjects = createSelector(
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
// reduce projEvents to get _events
|
||||
const projKeys = Object.keys(projects)
|
||||
const _events = projEvents.reduce((acc, event) => {
|
||||
events = events.reduce((acc, event) => {
|
||||
// infer activeProjects from timestamp
|
||||
const activeProjects = []
|
||||
projKeys.forEach((k, idx) => {
|
||||
@@ -218,7 +190,7 @@ export const selectEventsAndProjects = createSelector(
|
||||
})
|
||||
|
||||
// infer projectOffset using activeProjects
|
||||
// TODO(lachlan) projects get overlaid on the first layer...
|
||||
// TODO(lachlan) projects get overlaid if they start at the same time...
|
||||
const activeIdx = activeProjects.indexOf(event.project)
|
||||
let projectOffset = (activeIdx + 3) * (2.5 * sizes.eventDotR)
|
||||
if (activeIdx === -1) {
|
||||
@@ -234,12 +206,32 @@ export const selectEventsAndProjects = createSelector(
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
const _projects = []
|
||||
return [events, projects]
|
||||
}
|
||||
)
|
||||
|
||||
export const selectStackedEvents = createSelector(
|
||||
[selectEventsWithProjects],
|
||||
eventsWithProjects => {
|
||||
return eventsWithProjects[0]
|
||||
}
|
||||
)
|
||||
|
||||
export const selectProjects = createSelector(
|
||||
[selectEventsWithProjects, getFeatures],
|
||||
(eventsWithProjects, features) => {
|
||||
if (!features.GRAPH_NONLOCATED) {
|
||||
return []
|
||||
}
|
||||
// reduce projEvents to get _events
|
||||
const projects = []
|
||||
const projKeys = Object.keys(eventsWithProjects[1])
|
||||
|
||||
projKeys.forEach(projId => {
|
||||
_projects.push({ ...projects[projId], id: projId })
|
||||
projects.push({ ...eventsWithProjects[1][projId], id: projId })
|
||||
})
|
||||
|
||||
return [_events, _projects]
|
||||
return projects
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -60,12 +60,12 @@ const initial = {
|
||||
},
|
||||
timeline: {
|
||||
dimensions: {
|
||||
height: 250,
|
||||
height: 1250,
|
||||
width: 0,
|
||||
marginLeft: 100,
|
||||
marginTop: 15,
|
||||
marginBottom: 60,
|
||||
contentHeight: 200,
|
||||
contentHeight: 800,
|
||||
width_controls: 100
|
||||
},
|
||||
range: [
|
||||
|
||||
Reference in New Issue
Block a user