diff --git a/package.json b/package.json index b58c476..3d6bd44 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/common/utilities.js b/src/common/utilities.js index 7a59a9e..cd646f6 100644 --- a/src/common/utilities.js +++ b/src/common/utilities.js @@ -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 +} diff --git a/src/components/Layout.js b/src/components/Layout.js index 73017af..7f69332 100644 --- a/src/components/Layout.js +++ b/src/components/Layout.js @@ -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) { diff --git a/src/components/Map.jsx b/src/components/Map.jsx index d0bdaca..59fcd1c 100644 --- a/src/components/Map.jsx +++ b/src/components/Map.jsx @@ -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, diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx index 8852398..be96a72 100644 --- a/src/components/Timeline.jsx +++ b/src/components/Timeline.jsx @@ -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} /> 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 }, diff --git a/src/components/presentational/Timeline/Events.js b/src/components/presentational/Timeline/Events.js index de19eda..2b740f4 100644 --- a/src/components/presentational/Timeline/Events.js +++ b/src/components/presentational/Timeline/Events.js @@ -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 = 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 diff --git a/src/components/presentational/Timeline/Markers.js b/src/components/presentational/Timeline/Markers.js index 7bb9524..67f2d28 100644 --- a/src/components/presentational/Timeline/Markers.js +++ b/src/components/presentational/Timeline/Markers.js @@ -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 } - 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() } } diff --git a/src/reducers/utils/validators.js b/src/reducers/utils/validators.js index ad00070..ade0ec6 100644 --- a/src/reducers/utils/validators.js +++ b/src/reducers/utils/validators.js @@ -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 } diff --git a/src/selectors/index.js b/src/selectors/index.js index a34eb67..d549c00 100644 --- a/src/selectors/index.js +++ b/src/selectors/index.js @@ -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 } ) diff --git a/src/store/initial.js b/src/store/initial.js index 978940a..9374837 100644 --- a/src/store/initial.js +++ b/src/store/initial.js @@ -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: [