diff --git a/src/components/Dashboard.jsx b/src/components/Dashboard.jsx index 18a77f6..077e0f2 100644 --- a/src/components/Dashboard.jsx +++ b/src/components/Dashboard.jsx @@ -103,7 +103,6 @@ class Dashboard extends React.Component { }} /> for each location. + * A location consists of an array of events (see selectors). The function + * also has full access to the domain and redux state to derive values if + * necessary. The function should return an array, where the value at the + * first index is a styles object for the SVG at the location, and the value + * at the second index is an optional function that renders additional + * components in the div. + */ + styleLocation(location) { + const noEvents = location.events.length + return [ + null, + () => noEvents > 1 ? {noEvents} : null + ] + } + renderEvents() { return ( -
- {(this.map !== null) ? this.renderSVG() : ''} +
+ {(this.map !== null) ? this.renderTiles() : ''} {(this.map !== null) ? this.renderMarkers() : ''} {(this.map !== null) && isShowingSites ? this.renderSites() : ''} {(this.map !== null) ? this.renderEvents() : ''} diff --git a/src/components/MapEvents.jsx b/src/components/MapEvents.jsx index 70cac85..68c4fe4 100644 --- a/src/components/MapEvents.jsx +++ b/src/components/MapEvents.jsx @@ -26,48 +26,53 @@ class MapEvents extends React.Component { return eventCount; } - renderCategory(events, category) { - let styleProps = ({ - fill: this.props.getCategoryColor(category), - fillOpacity: 0.8 + renderLocation(location) { + /** + { + events: [...], + label: 'Location name', + latitude: '47.7', + longitude: '32.2' + } + */ + const { x, y } = this.projectPoint([location.latitude, location.longitude]); + // const eventsByCategory = this.getLocationEventsDistribution(location); + + const locCategory = location.events.length > 0 ? location.events[0].category : 'default' + const customStyles = this.props.styleLocation ? this.props.styleLocation(location) : null + const extraStyles = customStyles[0] + const extraRender = customStyles[1] + + const styles = ({ + fill: this.props.getCategoryColor(locCategory), + fillOpacity: 1, + ...customStyles[0] }) + // in narrative mode, only render events in narrative if (this.props.narrative) { const { steps } = this.props.narrative const onlyIfInNarrative = e => steps.map(s => s.id).includes(e.id) - const eventsInNarrative = events.filter(onlyIfInNarrative) + const eventsInNarrative = location.events.filter(onlyIfInNarrative) if (eventsInNarrative.length <= 0) { - styleProps = { - ...styleProps, - fillOpacity: 0.1 - } + return null } } - return ( - 0) ? Math.sqrt(16 * events.length) + 3 : 0} - style={styleProps} - onClick={() => this.props.onSelect(events)} - > - - ); - } - - renderLocation(location) { - const { x, y } = this.projectPoint([location.latitude, location.longitude]); - const eventsByCategory = this.getLocationEventsDistribution(location); - return ( this.props.onSelect(location.events)} > - {Object.keys(eventsByCategory).map(cat => { - return this.renderCategory(eventsByCategory[cat], cat) - })} + + + {extraRender ? extraRender() : null} ) } diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx index a833272..fc3f15b 100644 --- a/src/components/Timeline.jsx +++ b/src/components/Timeline.jsx @@ -18,6 +18,8 @@ import TimelineCategories from './TimelineCategories.jsx'; class Timeline extends React.Component { constructor(props) { super(props); + this.styleDatetime = this.styleDatetime.bind(this) + this.getDatetimeX = this.getDatetimeX.bind(this) this.svgRef = React.createRef() this.state = { isFolded: false, @@ -81,22 +83,6 @@ class Timeline extends React.Component { } } - /** - * Get x position of eventPoint, considering the time scale - * @param {object} eventPoint: regular eventPoint data - */ - getEventX(eventPoint) { - return this.state.scaleX(parseDate(eventPoint.timestamp)); - } - - /** - * Get y height of eventPoint, considering the ordinal Y scale - * @param {object} eventPoint: regular eventPoint data - */ - getEventY(eventPoint) { - return this.state.scaleY(eventPoint.category); - } - /** * Returns the time scale (x) extent in minutes */ @@ -210,67 +196,33 @@ class Timeline extends React.Component { this.props.methods.onUpdateTimerange(this.state.timerange); } - renderSVG() { - const dims = this.state.dims; + getDatetimeX(dt) { + return this.state.scaleX(parseDate(dt.timestamp)) + } - return ( - - - - { this.onDragStart() }} - onDrag={() => { this.onDrag() }} - onDragEnd={() => { this.onDragEnd() }} - categories={this.props.domain.categories} - /> - { this.onMoveTime(dir) }} - /> - { this.onApplyZoom(zoom); }} - /> - - this.getEventX(e)} - getEventY={(e) => this.getEventY(e)} - transitionDuration={this.state.transitionDuration} - /> - this.getEventX(e)} - getEventY={(e) => this.getEventY(e)} - getCategoryColor={this.props.methods.getCategoryColor} - transitionDuration={this.state.transitionDuration} - onSelect={this.props.methods.onSelect} - /> - - ) + /** + * Determines additional styles on the for each timestamp. Note that + * timestamp visualisation functions slightly differently from locations, as + * a timestamp can be shown as multiple s (one per category of the + * events contained therein). Thus the function below has a category as an + * argumnent as well, in case timestamps ought to be styled per category. + * A datetime consists of an array of events (see selectors). The function + * also has full access to the domain and redux state to derive values if + * necessary. The function should return an array, where the value at the + * first index is a styles object for the SVG at the location, and the value + * at the second index is an optional function that renders additional + * components in the div. + */ + styleDatetime(timestamp, category) { + return [] } render() { const { isNarrative, app, ui } = this.props let classes = `timeline-wrapper ${(this.state.isFolded) ? ' folded' : ''}`; classes += (app.narrative !== null) ? ' narrative-mode' : ''; + const dims = this.state.dims; + return (
- {this.renderSVG()} + + + + { this.onDragStart() }} + onDrag={() => { this.onDrag() }} + onDragEnd={() => { this.onDragEnd() }} + categories={this.props.domain.categories} + /> + { this.onMoveTime(dir) }} + /> + { this.onApplyZoom(zoom); }} + /> + + + +
@@ -294,7 +296,7 @@ function mapStateToProps(state) { return { isNarrative: !!state.app.narrative, domain: { - events: state.domain.events, + datetimes: selectors.selectDatetimes(state), categories: selectors.selectCategories(state), narratives: state.domain.narratives }, diff --git a/src/components/presentational/DatetimeDot.js b/src/components/presentational/DatetimeDot.js new file mode 100644 index 0000000..8caf52a --- /dev/null +++ b/src/components/presentational/DatetimeDot.js @@ -0,0 +1,28 @@ +import React from 'react' + +export default ({ + category, + events, + x, + y, + onSelect, + styleProps, + extraRender +}) => ( + onSelect(events)} + > + + + { extraRender ? extraRender() : null } + +) + diff --git a/src/components/presentational/TimelineEvents.js b/src/components/presentational/TimelineEvents.js index 2ce4086..035ab64 100644 --- a/src/components/presentational/TimelineEvents.js +++ b/src/components/presentational/TimelineEvents.js @@ -1,55 +1,82 @@ import React from 'react'; +import DatetimeDot from './DatetimeDot' -const TimelineEvents = ({ events, narrative, getEventX, getEventY, - getCategoryColor, onSelect, transitionDuration }) => { +// 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 ] + } + } + }) - function getAllEventsAtOnce(eventPoint) { - const timestamp = eventPoint.timestamp; - const category = eventPoint.category; - return events - .filter(event => (event.timestamp === timestamp && category === event.category)) - } - - function renderEvent(event) { - let styleProps = ({ - fill: getCategoryColor(event.category), - fillOpacity: 0.8, - transform: `translate(${getEventX(event)}px, ${getEventY(event)}px)`, - transition: `transform ${transitionDuration / 1000}s ease` - }); + return Object.values(eventsByCategory) +} +const TimelineEvents = ({ + datetimes, + narrative, + getDatetimeX, + getCategoryY, + getCategoryColor, + onSelect, + transitionDuration, + styleDatetime +}) => { + function renderDatetime(datetime) { if (narrative) { const { steps } = narrative const isInNarrative = steps.map(s => s.id).includes(event.id) if (!isInNarrative) { - styleProps = { - ...styleProps, - fillOpacity: 0.1 - } + return null } } - return ( - {onSelect(getAllEventsAtOnce(event))}} - > - - ) + 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] + + const styleProps = ({ + fill: getCategoryColor(dot.category), + fillOpacity: 1, + transition: `transform ${transitionDuration / 1000}s ease`, + ...extraStyles + }) + + return ( + + ) + }) } return ( - {events.map(event => renderEvent(event))} + {datetimes.map(datetime => renderDatetime(datetime))} ); } -export default TimelineEvents; \ No newline at end of file +export default TimelineEvents; diff --git a/src/components/presentational/TimelineMarkers.js b/src/components/presentational/TimelineMarkers.js index 7126015..f780947 100644 --- a/src/components/presentational/TimelineMarkers.js +++ b/src/components/presentational/TimelineMarkers.js @@ -1,6 +1,6 @@ import React from 'react'; -const TimelineMarkers = ({ getEventX, getEventY, transitionDuration, selected }) => { +const TimelineMarkers = ({ getEventX, getCategoryY, transitionDuration, selected }) => { function renderMarker(event) { return ( @@ -28,4 +28,4 @@ const TimelineMarkers = ({ getEventX, getEventY, transitionDuration, selected }) ); } -export default TimelineMarkers; \ No newline at end of file +export default TimelineMarkers; diff --git a/src/scss/map.scss b/src/scss/map.scss index a49b34c..a1d90b2 100644 --- a/src/scss/map.scss +++ b/src/scss/map.scss @@ -165,14 +165,13 @@ * Elements */ +.location { + cursor: pointer; +} + .location-event-marker { fill: $event_default; stroke-width: 0; - cursor: pointer; - - &:hover { - fill-opacity: 1; - } } .path-polyline { @@ -180,5 +179,10 @@ stroke-width: 2px; } +.location-count { + z-index: 100; + fill: #a4a4a4; +} + diff --git a/src/selectors/index.js b/src/selectors/index.js index d560a00..4f03b5e 100644 --- a/src/selectors/index.js +++ b/src/selectors/index.js @@ -23,7 +23,6 @@ export const getTagsFilter = state => state.app.filters.tags export const getTimeRange = state => state.app.filters.timerange - /** * Some handy helpers */ @@ -148,9 +147,15 @@ export const selectActiveNarrative = createSelector( ? { ...narrative, current } : null ) + /** - * Of all the filtered events, group them by location and return a list of - * locations with at least one event in it, based on the time range and tags + * Group events by location. Each location is an object: + { + events: [...], + label: 'Location name', + latitude: '47.7', + longitude: '32.2' + } */ export const selectLocations = createSelector( [selectEvents], @@ -171,11 +176,39 @@ export const selectLocations = createSelector( } } }) - return Object.values(selectedLocations) } ) +/** + * Group events by 'datetime'. Each datetime is an object: + { + timestamp: '', + date: '8/23/2016', + time: '12:00', + events: [...] + } + */ +export const selectDatetimes = createSelector( + [selectEvents], + events => { + const datetimes = {} + events.forEach(event => { + const { timestamp } = event + if (datetimes.hasOwnProperty(timestamp)) { + datetimes[timestamp].events.push(event) + } else { + datetimes[timestamp] = { + timestamp: event.timestamp, + date: event.date, + time: event.time, + events: [event] + } + } + }) + return Object.values(datetimes) + } +) /** diff --git a/src/store/initial.js b/src/store/initial.js index ceed1e4..ba29a5d 100644 --- a/src/store/initial.js +++ b/src/store/initial.js @@ -113,7 +113,6 @@ const initial = { beta: '#ff0000', other: '#f3de2c' }, - narratives: { default: { opacity: 0.9,