Merge pull request #78 from forensic-architecture/topic/refactor-event-styles

Topic/refactor event styles
This commit is contained in:
Lachlan Kermode
2019-01-11 08:03:01 +00:00
committed by GitHub
10 changed files with 269 additions and 153 deletions

View File

@@ -103,7 +103,6 @@ class Dashboard extends React.Component {
}}
/>
<Map
mapId='map'
methods={{
onSelect: this.handleSelect,
onSelectNarrative: this.setNarrative,

View File

@@ -25,6 +25,7 @@ class Map extends React.Component {
mapTransformX: 0,
mapTransformY: 0
}
this.styleLocation = this.styleLocation.bind(this)
}
componentDidMount(){
@@ -50,9 +51,9 @@ class Map extends React.Component {
* Creates a Leaflet map and a tilelayer for the map background
*/
const map =
L.map(this.props.mapId)
L.map(this.props.ui.dom.map)
.setView(this.props.app.mapAnchor, 14)
.setMinZoom(10)
.setMinZoom(7)
.setMaxZoom(18)
.setMaxBounds([[180, -180], [-180, 180]])
@@ -103,7 +104,7 @@ class Map extends React.Component {
}
getClientDims() {
const boundingClient = document.querySelector(`#${this.props.mapId}`).getBoundingClientRect();
const boundingClient = document.querySelector(`#${this.props.ui.dom.map}`).getBoundingClientRect();
return {
width: boundingClient.width,
@@ -111,7 +112,7 @@ class Map extends React.Component {
}
}
renderSVG() {
renderTiles() {
const pane = this.map.getPanes().overlayPane;
const { width, height } = this.getClientDims();
@@ -157,11 +158,29 @@ class Map extends React.Component {
);
}
/**
* Determines additional styles on the <circle> 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 <g/> div.
*/
styleLocation(location) {
const noEvents = location.events.length
return [
null,
() => noEvents > 1 ? <text className='location-count' dx='-3' dy='4'>{noEvents}</text> : null
]
}
renderEvents() {
return (
<MapEvents
svg={this.svgRef.current}
locations={this.props.domain.locations}
styleLocation={this.styleLocation}
categories={this.props.domain.categories}
map={this.map}
mapTransformX={this.state.mapTransformX}
@@ -202,8 +221,8 @@ class Map extends React.Component {
return (
<div className={classes}>
<div id={this.props.mapId} />
{(this.map !== null) ? this.renderSVG() : ''}
<div id={this.props.ui.dom.map} />
{(this.map !== null) ? this.renderTiles() : ''}
{(this.map !== null) ? this.renderMarkers() : ''}
{(this.map !== null) && isShowingSites ? this.renderSites() : ''}
{(this.map !== null) ? this.renderEvents() : ''}

View File

@@ -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 (
<circle
className="location-event-marker"
r={(events.length > 0) ? Math.sqrt(16 * events.length) + 3 : 0}
style={styleProps}
onClick={() => this.props.onSelect(events)}
>
</circle>
);
}
renderLocation(location) {
const { x, y } = this.projectPoint([location.latitude, location.longitude]);
const eventsByCategory = this.getLocationEventsDistribution(location);
return (
<g
className="location"
transform={`translate(${x}, ${y})`}
onClick={() => this.props.onSelect(location.events)}
>
{Object.keys(eventsByCategory).map(cat => {
return this.renderCategory(eventsByCategory[cat], cat)
})}
<circle
className="location-event-marker"
r={7}
style={styles}
>
</circle>
{extraRender ? extraRender() : null}
</g>
)
}

View File

@@ -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 (
<svg
ref={this.svgRef}
width={dims.width}
height={dims.height}
>
<TimelineClip
dims={dims}
/>
<TimelineAxis
dims={dims}
timerange={this.props.app.timerange}
transitionDuration={this.state.transitionDuration}
scaleX={this.state.scaleX}
/>
<TimelineCategories
dims={dims}
onDragStart={() => { this.onDragStart() }}
onDrag={() => { this.onDrag() }}
onDragEnd={() => { this.onDragEnd() }}
categories={this.props.domain.categories}
/>
<TimelineHandles
dims={dims}
onMoveTime={(dir) => { this.onMoveTime(dir) }}
/>
<TimelineZoomControls
zoomLevels={this.props.app.zoomLevels}
dims={dims}
onApplyZoom={(zoom) => { this.onApplyZoom(zoom); }}
/>
<TimelineLabels
dims={dims}
timelabels={this.state.timerange}
/>
<TimelineMarkers
selected={this.props.app.selected}
getEventX={(e) => this.getEventX(e)}
getEventY={(e) => this.getEventY(e)}
transitionDuration={this.state.transitionDuration}
/>
<TimelineEvents
events={this.props.domain.events}
narrative={this.props.app.narrative}
getEventX={(e) => this.getEventX(e)}
getEventY={(e) => this.getEventY(e)}
getCategoryColor={this.props.methods.getCategoryColor}
transitionDuration={this.state.transitionDuration}
onSelect={this.props.methods.onSelect}
/>
</svg>
)
/**
* Determines additional styles on the <circle> for each timestamp. Note that
* timestamp visualisation functions slightly differently from locations, as
* a timestamp can be shown as multiple <circle>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 <g/> 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 (
<div className={classes}>
<TimelineHeader
@@ -282,7 +234,57 @@ class Timeline extends React.Component {
/>
<div className="timeline-content">
<div id={this.props.ui.dom.timeline} className="timeline">
{this.renderSVG()}
<svg
ref={this.svgRef}
width={dims.width}
height={dims.height}
>
<TimelineClip
dims={dims}
/>
<TimelineAxis
dims={dims}
timerange={this.props.app.timerange}
transitionDuration={this.state.transitionDuration}
scaleX={this.state.scaleX}
/>
<TimelineCategories
dims={dims}
onDragStart={() => { this.onDragStart() }}
onDrag={() => { this.onDrag() }}
onDragEnd={() => { this.onDragEnd() }}
categories={this.props.domain.categories}
/>
<TimelineHandles
dims={dims}
onMoveTime={(dir) => { this.onMoveTime(dir) }}
/>
<TimelineZoomControls
zoomLevels={this.props.app.zoomLevels}
dims={dims}
onApplyZoom={(zoom) => { this.onApplyZoom(zoom); }}
/>
<TimelineLabels
dims={dims}
timelabels={this.state.timerange}
/>
<TimelineMarkers
selected={this.props.app.selected}
getEventX={this.getDatetimeX}
getCategoryY={this.state.scaleY}
transitionDuration={this.state.transitionDuration}
/>
<TimelineEvents
datetimes={this.props.domain.datetimes}
styleDatetime={this.styleDatetime}
narrative={this.props.app.narrative}
getDatetimeX={this.getDatetimeX}
getCategoryY={this.state.scaleY}
getCategoryColor={this.props.methods.getCategoryColor}
transitionDuration={this.state.transitionDuration}
onSelect={this.props.methods.onSelect}
/>
</svg>
</div>
</div>
</div>
@@ -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
},

View File

@@ -0,0 +1,28 @@
import React from 'react'
export default ({
category,
events,
x,
y,
onSelect,
styleProps,
extraRender
}) => (
<g
className='datetime'
transform={`translate(${x}, ${y})`}
onClick={() => onSelect(events)}
>
<circle
className="event"
cx={0}
cy={0}
style={styleProps}
r={5}
>
</circle>
{ extraRender ? extraRender() : null }
</g>
)

View File

@@ -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 (
<circle
className="event"
cx={0}
cy={0}
style={styleProps}
r={5}
onClick={() => {onSelect(getAllEventsAtOnce(event))}}
>
</circle>
)
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 (
<DatetimeDot
onSelect={onSelect}
category={dot.category}
events={dot.events}
x={getDatetimeX(datetime)}
y={getCategoryY(dot.category)}
styleProps={styleProps}
extraRender={extraRender}
/>
)
})
}
return (
<g
clipPath={"url(#clip)"}
>
{events.map(event => renderEvent(event))}
{datetimes.map(datetime => renderDatetime(datetime))}
</g>
);
}
export default TimelineEvents;
export default TimelineEvents;

View File

@@ -1,6 +1,6 @@
import React from 'react';
const TimelineMarkers = ({ getEventX, getEventY, transitionDuration, selected }) => {
const TimelineMarkers = ({ getEventX, getCategoryY, transitionDuration, selected }) => {
function renderMarker(event) {
return (
<circle
@@ -8,11 +8,11 @@ const TimelineMarkers = ({ getEventX, getEventY, transitionDuration, selected })
cx={0}
cy={0}
style={{
'transform': `translate(${getEventX(event)}px, ${getEventY(event)}px)`,
'transform': `translate(${getEventX(event)}px, ${getCategoryY(event.category)}px)`,
'-webkit-transition': `transform ${transitionDuration / 1000}s ease`,
'-moz-transition': 'none',
'opacity': 0.9
}}
}}
r="10"
>
</circle>
@@ -28,4 +28,4 @@ const TimelineMarkers = ({ getEventX, getEventY, transitionDuration, selected })
);
}
export default TimelineMarkers;
export default TimelineMarkers;

View File

@@ -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;
}

View File

@@ -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)
}
)
/**

View File

@@ -113,7 +113,6 @@ const initial = {
beta: '#ff0000',
other: '#f3de2c'
},
narratives: {
default: {
opacity: 0.9,