mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-08 03:18:36 +03:00
Merge pull request #78 from forensic-architecture/topic/refactor-event-styles
Topic/refactor event styles
This commit is contained in:
@@ -103,7 +103,6 @@ class Dashboard extends React.Component {
|
||||
}}
|
||||
/>
|
||||
<Map
|
||||
mapId='map'
|
||||
methods={{
|
||||
onSelect: this.handleSelect,
|
||||
onSelectNarrative: this.setNarrative,
|
||||
|
||||
@@ -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() : ''}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
28
src/components/presentational/DatetimeDot.js
Normal file
28
src/components/presentational/DatetimeDot.js
Normal 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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
/**
|
||||
|
||||
@@ -113,7 +113,6 @@ const initial = {
|
||||
beta: '#ff0000',
|
||||
other: '#f3de2c'
|
||||
},
|
||||
|
||||
narratives: {
|
||||
default: {
|
||||
opacity: 0.9,
|
||||
|
||||
Reference in New Issue
Block a user