mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-08 03:18:36 +03:00
Feature/ux fixes (#167)
* fix card toggle * fix bug and bar marker * reinstate timeline arrows * adjust (hard to interpret) category y calculation * shadows for markers as well * return markers when there are no categories * remove year in timeline * make notifications optional * WIP: render hovered counts * show number on hover * lint * revert to filteredLocations * linting * return mapClustersToLocations * 💄 * lint Co-authored-by: efarooqui <efarooqui@pandora.com>
This commit is contained in:
@@ -288,7 +288,7 @@ class Dashboard extends React.Component {
|
||||
<CardStack
|
||||
timelineDims={app.timeline.dimensions}
|
||||
onViewSource={this.handleViewSource}
|
||||
onSelect={app.associations.narrative ? this.selectNarrativeStep : this.handleSelect}
|
||||
onSelect={app.associations.narrative ? this.selectNarrativeStep : () => null}
|
||||
onHighlight={this.handleHighlight}
|
||||
onToggleCardstack={() => actions.updateSelected([])}
|
||||
getCategoryColor={this.getCategoryColor}
|
||||
@@ -316,11 +316,11 @@ class Dashboard extends React.Component {
|
||||
onClose: actions.toggleInfoPopup
|
||||
}}
|
||||
/>
|
||||
<Notification
|
||||
{app.debug ? <Notification
|
||||
isNotification={app.flags.isNotification}
|
||||
notifications={domain.notifications}
|
||||
onToggle={actions.markNotificationsRead}
|
||||
/>
|
||||
/> : null}
|
||||
<Search
|
||||
narrative={app.narrative}
|
||||
queryString={app.searchQuery}
|
||||
|
||||
@@ -89,11 +89,7 @@ class Map extends React.Component {
|
||||
.setMaxBounds(mapConfig.maxBounds)
|
||||
|
||||
// Initialize supercluster index
|
||||
this.superclusterIndex = new Supercluster({
|
||||
radius: clusterConfig.radius,
|
||||
maxZoom: clusterConfig.maxZoom,
|
||||
minZoom: clusterConfig.minZoom
|
||||
})
|
||||
this.superclusterIndex = new Supercluster(clusterConfig)
|
||||
|
||||
let firstLayer
|
||||
|
||||
@@ -116,6 +112,7 @@ class Map extends React.Component {
|
||||
map.zoomControl.remove()
|
||||
|
||||
map.on('moveend', () => {
|
||||
// console.log(map.getZoom())
|
||||
this.updateClusters()
|
||||
this.alignLayers()
|
||||
})
|
||||
@@ -199,11 +196,9 @@ class Map extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
onClusterSelect (e) {
|
||||
const { id } = e.target
|
||||
const { longitude, latitude } = e.target.attributes
|
||||
onClusterSelect ({ id, latitude, longitude }) {
|
||||
const expansionZoom = Math.max(this.superclusterIndex.getClusterExpansionZoom(parseInt(id)), this.superclusterIndex.options.minZoom)
|
||||
this.map.flyTo(new L.LatLng(latitude.value, longitude.value), expansionZoom)
|
||||
this.map.flyTo(new L.LatLng(latitude, longitude), expansionZoom)
|
||||
}
|
||||
|
||||
getClientDims () {
|
||||
|
||||
@@ -81,7 +81,7 @@ class Timeline extends React.Component {
|
||||
categories = categories.filter(cat => !features.GRAPH_NONLOCATED.categories.includes(cat.id))
|
||||
}
|
||||
const catHeight = trackHeight / (categories.length)
|
||||
const shiftUp = trackHeight / (categories.length) / 2
|
||||
const shiftUp = trackHeight / (categories.length) / 3
|
||||
const marginShift = marginTop === 0 ? 0 : marginTop
|
||||
const manualAdjustment = trackHeight <= 60 ? (trackHeight <= 30 ? -8 : -5) : 0
|
||||
const catsYpos = categories.map((g, i) => {
|
||||
@@ -138,7 +138,6 @@ class Timeline extends React.Component {
|
||||
* @param {String} direction: 'forward' / 'backwards'
|
||||
*/
|
||||
onMoveTime (direction) {
|
||||
this.props.methods.onSelect()
|
||||
const extent = this.getTimeScaleExtent()
|
||||
const newCentralTime = d3.timeMinute.offset(this.state.scaleX.domain()[0], extent / 2)
|
||||
|
||||
@@ -277,9 +276,10 @@ class Timeline extends React.Component {
|
||||
return this.state.dims.trackHeight / 2
|
||||
}
|
||||
|
||||
const { category, project } = event
|
||||
const { category } = event
|
||||
|
||||
if (GRAPH_NONLOCATED && GRAPH_NONLOCATED.categories.includes(category)) {
|
||||
const { project } = event
|
||||
return this.state.dims.marginTop + domain.projects[project].offset + this.props.ui.eventRadius
|
||||
}
|
||||
if (!this.state.scaleY) return 0
|
||||
@@ -359,6 +359,7 @@ class Timeline extends React.Component {
|
||||
selected={this.props.app.selected}
|
||||
getEventX={ev => this.getDatetimeX(ev.datetime)}
|
||||
getEventY={this.getY}
|
||||
categories={this.props.domain.categories}
|
||||
transitionDuration={this.state.transitionDuration}
|
||||
styles={this.props.ui.styles}
|
||||
features={this.props.features}
|
||||
|
||||
@@ -20,8 +20,8 @@ class TimelineAxis extends React.Component {
|
||||
sndFmt = ''
|
||||
// 1yr
|
||||
} else if (this.props.extent > 43200) {
|
||||
sndFmt = '%Y'
|
||||
fstFmt = '%d %b'
|
||||
sndFmt = '%d %b'
|
||||
fstFmt = ''
|
||||
} else {
|
||||
sndFmt = '%d %b'
|
||||
fstFmt = '%H:%M'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { Portal } from 'react-portal'
|
||||
import colors from '../../../common/global.js'
|
||||
import { calcClusterOpacity, calcClusterSize } from '../../../common/utilities'
|
||||
import { calcClusterOpacity, calcClusterSize, isLatitude, isLongitude } from '../../../common/utilities'
|
||||
|
||||
const DefsClusters = () => (
|
||||
<defs>
|
||||
@@ -12,97 +12,108 @@ const DefsClusters = () => (
|
||||
</defs>
|
||||
)
|
||||
|
||||
function Cluster ({ cluster, size, projectPoint, totalPoints, styles, renderHover, onClick }) {
|
||||
/**
|
||||
{
|
||||
geometry: {
|
||||
coordinates: [longitude, latitude]
|
||||
},
|
||||
properties: {
|
||||
cluster: true|false,
|
||||
cluster_id: int,
|
||||
point_count: int,
|
||||
point_count_abbreviated: int
|
||||
},
|
||||
type: "Feature"
|
||||
}
|
||||
*/
|
||||
const { cluster_id: clusterId } = cluster.properties
|
||||
const { coordinates } = cluster.geometry
|
||||
const [longitude, latitude] = coordinates
|
||||
if (!isLatitude(latitude) || !isLongitude(longitude)) return null
|
||||
const { x, y } = projectPoint([latitude, longitude])
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<g
|
||||
className={'cluster-event'}
|
||||
transform={`translate(${x}, ${y})`}
|
||||
onClick={e => onClick({ id: clusterId, latitude, longitude })}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<circle
|
||||
class='cluster-event-marker'
|
||||
id={clusterId}
|
||||
longitude={longitude}
|
||||
latitude={latitude}
|
||||
cx='0'
|
||||
cy='0'
|
||||
r={size}
|
||||
style={{
|
||||
...styles
|
||||
}}
|
||||
/>
|
||||
{hovered ? renderHover(cluster) : null}
|
||||
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
function ClusterEvents ({
|
||||
projectPoint,
|
||||
styleCluster,
|
||||
onSelect,
|
||||
isRadial,
|
||||
svg,
|
||||
clusters
|
||||
}) {
|
||||
function calculateTotalPoints () {
|
||||
return clusters.reduce((total, cl) => {
|
||||
if (cl && cl.properties) {
|
||||
total += cl.properties.point_count
|
||||
}
|
||||
return total
|
||||
}, 0)
|
||||
const totalPoints = clusters.reduce((total, cl) => {
|
||||
if (cl && cl.properties) {
|
||||
total += cl.properties.point_count
|
||||
}
|
||||
return total
|
||||
}, 0)
|
||||
|
||||
const styles = {
|
||||
fill: isRadial ? "url('#clusterGradient')" : colors.fallbackEventColor,
|
||||
stroke: colors.darkBackground,
|
||||
strokeWidth: 0
|
||||
}
|
||||
|
||||
function renderClusterBySize (cluster) {
|
||||
const { point_count: pointCount, cluster_id: clusterId } = cluster.properties
|
||||
const { coordinates } = cluster.geometry
|
||||
const [longitude, latitude] = coordinates
|
||||
|
||||
const totalPoints = calculateTotalPoints()
|
||||
|
||||
const styles = {
|
||||
fill: isRadial ? "url('#clusterGradient')" : colors.fallbackEventColor,
|
||||
stroke: colors.darkBackground,
|
||||
strokeWidth: 0,
|
||||
fillOpacity: calcClusterOpacity(pointCount, totalPoints)
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{<circle
|
||||
class='cluster-event-marker'
|
||||
id={clusterId}
|
||||
longitude={longitude}
|
||||
latitude={latitude}
|
||||
cx='0'
|
||||
cy='0'
|
||||
r={calcClusterSize(pointCount, totalPoints)}
|
||||
style={styles}
|
||||
/>}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
function renderCluster (cluster) {
|
||||
/**
|
||||
{
|
||||
geometry: {
|
||||
coordinates: [longitude, latitude]
|
||||
},
|
||||
properties: {
|
||||
cluster: true|false,
|
||||
cluster_id: int,
|
||||
point_count: int,
|
||||
point_count_abbreviated: int
|
||||
},
|
||||
type: "Feature"
|
||||
}
|
||||
*/
|
||||
const { coordinates } = cluster.geometry
|
||||
const [longitude, latitude] = coordinates
|
||||
if (!latitude || !longitude) return null
|
||||
const { x, y } = projectPoint([latitude, longitude])
|
||||
|
||||
const customStyles = styleCluster ? styleCluster(cluster) : null
|
||||
const extraRender = () => (
|
||||
<React.Fragment>
|
||||
{customStyles[1]}
|
||||
</React.Fragment>
|
||||
)
|
||||
|
||||
return (
|
||||
<g
|
||||
className={'cluster-event'}
|
||||
transform={`translate(${x}, ${y})`}
|
||||
onClick={(e) => onSelect(e)}
|
||||
>
|
||||
{renderClusterBySize(cluster)}
|
||||
{extraRender ? extraRender() : null}
|
||||
</g>
|
||||
)
|
||||
function renderHover (txt) {
|
||||
return <text text-anchor='middle' y='-3px' style={{ fontWeight: 'bold', fill: 'white' }}>{txt}</text>
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal node={svg}>
|
||||
<g className='cluster-locations'>
|
||||
{isRadial ? <DefsClusters /> : null}
|
||||
{clusters.map(renderCluster)}
|
||||
{clusters.map(c => {
|
||||
const pointCount = c.properties.point_count
|
||||
const clusterSize = calcClusterSize(pointCount, totalPoints)
|
||||
return <Cluster
|
||||
onClick={onSelect}
|
||||
cluster={c}
|
||||
size={clusterSize}
|
||||
projectPoint={projectPoint}
|
||||
totalPoints={totalPoints}
|
||||
styles={{
|
||||
...styles,
|
||||
fillOpacity: calcClusterOpacity(pointCount, totalPoints)
|
||||
}}
|
||||
renderHover={clster => <>
|
||||
<circle
|
||||
class='event-hover'
|
||||
cx='0'
|
||||
cy='0'
|
||||
r={clusterSize + 2}
|
||||
stroke={colors.primaryHighlight}
|
||||
fill-opacity='0.0'
|
||||
/>
|
||||
{renderHover(pointCount)}
|
||||
</>}
|
||||
/>
|
||||
})}
|
||||
</g>
|
||||
</Portal>
|
||||
)
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
import React from 'react'
|
||||
|
||||
const TimelineHandles = ({ dims, onMoveTime }) => {
|
||||
return <div />
|
||||
// temporarilty disabled while we get functionality working again
|
||||
// return (
|
||||
// <g className='time-controls-inline'>
|
||||
// <g
|
||||
// transform={`translate(${dims.marginLeft - 20}, ${dims.contentHeight - 10})`}
|
||||
// onClick={() => onMoveTime('backwards')}
|
||||
// >
|
||||
// <circle r='15' />
|
||||
// <path d='M0,-7.847549217020565L6.796176979388489,3.9237746085102825L-6.796176979388489,3.9237746085102825Z' transform='rotate(270)' />
|
||||
// </g>
|
||||
// <g
|
||||
// transform={`translate(${dims.width - dims.width_controls + 20}, ${dims.contentHeight - 10})`}
|
||||
// onClick={() => onMoveTime('forward')}
|
||||
// >
|
||||
// <circle r='15' />
|
||||
// <path d='M0,-7.847549217020565L6.796176979388489,3.9237746085102825L-6.796176979388489,3.9237746085102825Z' transform='rotate(90)' />
|
||||
// </g>
|
||||
// </g>
|
||||
// )
|
||||
return (
|
||||
<g className='time-controls-inline'>
|
||||
<g
|
||||
transform={`translate(${dims.marginLeft - 20}, ${dims.contentHeight - 10})`}
|
||||
onClick={() => onMoveTime('backwards')}
|
||||
>
|
||||
<circle r='15' />
|
||||
<path d='M0,-7.847549217020565L6.796176979388489,3.9237746085102825L-6.796176979388489,3.9237746085102825Z' transform='rotate(270)' />
|
||||
</g>
|
||||
<g
|
||||
transform={`translate(${dims.width - dims.width_controls + 20}, ${dims.contentHeight - 10})`}
|
||||
onClick={() => onMoveTime('forward')}
|
||||
>
|
||||
<circle r='15' />
|
||||
<path d='M0,-7.847549217020565L6.796176979388489,3.9237746085102825L-6.796176979388489,3.9237746085102825Z' transform='rotate(90)' />
|
||||
</g>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
export default TimelineHandles
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import React from 'react'
|
||||
import colors from '../../../common/global'
|
||||
import { getEventCategories } from '../../../common/utilities'
|
||||
|
||||
const TimelineMarkers = ({
|
||||
styles,
|
||||
eventRadius,
|
||||
getEventX,
|
||||
getEventY,
|
||||
categories,
|
||||
transitionDuration,
|
||||
selected,
|
||||
dims,
|
||||
features
|
||||
}) => {
|
||||
function renderMarker (event) {
|
||||
function renderCircle () {
|
||||
function renderMarker (acc, event) {
|
||||
function renderCircle (y) {
|
||||
return <circle
|
||||
className='timeline-marker'
|
||||
cx={0}
|
||||
@@ -23,10 +25,10 @@ const TimelineMarkers = ({
|
||||
stroke-linejoin='round'
|
||||
stroke-dasharray={styles ? styles['stroke-dasharray'] : '2,2'}
|
||||
style={{
|
||||
'transform': `translate(${getEventX(event)}px, ${getEventY(event)}px)`,
|
||||
'transform': `translate(${getEventX(event)}px, ${y}px)`,
|
||||
'-webkit-transition': `transform ${transitionDuration / 1000}s ease`,
|
||||
'-moz-transition': 'none',
|
||||
'opacity': 0.9
|
||||
'opacity': 1
|
||||
}}
|
||||
r={eventRadius * 2}
|
||||
/>
|
||||
@@ -35,8 +37,8 @@ const TimelineMarkers = ({
|
||||
return <rect
|
||||
className='timeline-marker'
|
||||
x={0}
|
||||
y={0}
|
||||
width={eventRadius / 2}
|
||||
y={dims.marginTop}
|
||||
width={eventRadius / 1.5}
|
||||
height={dims.contentHeight - 55}
|
||||
stroke={styles ? styles.stroke : colors.primaryHighlight}
|
||||
stroke-opacity='1'
|
||||
@@ -48,27 +50,38 @@ const TimelineMarkers = ({
|
||||
}}
|
||||
/>
|
||||
}
|
||||
const isDot = (!!event.location && !!event.longitude) || (features.GRAPH_NONLOCATED && event.projectOffset !== -1)
|
||||
|
||||
switch (event.shape) {
|
||||
case 'circle':
|
||||
return renderCircle()
|
||||
case 'bar':
|
||||
return renderBar()
|
||||
case 'diamond':
|
||||
return renderCircle()
|
||||
case 'star':
|
||||
return renderCircle()
|
||||
default:
|
||||
return isDot ? renderCircle() : renderBar()
|
||||
const isDot = (!!event.location && !!event.longitude) || (features.GRAPH_NONLOCATED && event.projectOffset !== -1)
|
||||
const evShadows = getEventCategories(event, categories).map(cat => getEventY({ ...event, category: cat.id }))
|
||||
|
||||
function renderMarkerForEvent (y) {
|
||||
switch (event.shape) {
|
||||
case 'circle':
|
||||
case 'diamond':
|
||||
case 'star':
|
||||
acc.push(renderCircle(y))
|
||||
break
|
||||
case 'bar':
|
||||
acc.push(renderBar(y))
|
||||
break
|
||||
default:
|
||||
return isDot ? acc.push(renderCircle(y)) : acc.push(renderBar(y))
|
||||
}
|
||||
}
|
||||
|
||||
if (evShadows.length > 0) {
|
||||
evShadows.forEach(renderMarkerForEvent)
|
||||
} else {
|
||||
renderMarkerForEvent(getEventY(event))
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
return (
|
||||
<g
|
||||
clipPath={'url(#clip)'}
|
||||
>
|
||||
{selected.map(event => renderMarker(event))}
|
||||
{selected.reduce(renderMarker, [])}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -210,7 +210,6 @@ $timeline-height: 170px;
|
||||
|
||||
.timeline-marker {
|
||||
fill: none;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.coevent {
|
||||
|
||||
@@ -27,6 +27,7 @@ const initial = {
|
||||
* or by the characteristics of the client, browser, etc.
|
||||
*/
|
||||
app: {
|
||||
debug: true,
|
||||
errors: {
|
||||
source: false
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user