mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-12 13:28:36 +03:00
Added generators for card layouts. (#182)
* Added generators for card layouts. These are optionally defined in the timemap config * Removed US2020-specific layout generation - now it's being specified in the config
This commit is contained in:
31
src/common/card.js
Normal file
31
src/common/card.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// Sensible defaults for generating a basic card layout
|
||||
// based on the example Timemap datasheet.
|
||||
const basic = ({ event }) => {
|
||||
return [
|
||||
[
|
||||
{
|
||||
kind: 'date',
|
||||
title: 'Incident Date',
|
||||
value: event.datetime || event.date || ``
|
||||
},
|
||||
{
|
||||
kind: 'text',
|
||||
title: 'Location',
|
||||
value: event.location || `—`
|
||||
}
|
||||
],
|
||||
[{ kind: 'line-break', times: 0.4 }],
|
||||
[
|
||||
{
|
||||
kind: 'text',
|
||||
title: 'Summary',
|
||||
value: event.description || ``,
|
||||
scaleFont: 1.1
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
export default {
|
||||
basic
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import * as selectors from '../selectors'
|
||||
import {
|
||||
// calculateColorPercentages,
|
||||
getFilterIdxFromColorSet
|
||||
} from '../common/utilities'
|
||||
|
||||
import * as selectors from '../selectors'
|
||||
import { getFilterIdxFromColorSet } from '../common/utilities'
|
||||
// import Card from './Card.jsx'
|
||||
import { Card } from '@forensic-architecture/design-system/react'
|
||||
import copy from '../common/data/copy.json'
|
||||
@@ -29,7 +26,8 @@ class CardStack extends React.Component {
|
||||
scrollToCard () {
|
||||
const duration = 500
|
||||
const element = this.refCardStack.current
|
||||
const cardScroll = this.refs[this.props.narrative.current].current.offsetTop
|
||||
const cardScroll = this.refs[this.props.narrative.current].current
|
||||
.offsetTop
|
||||
|
||||
let start = element.scrollTop
|
||||
let change = cardScroll - start
|
||||
@@ -42,9 +40,9 @@ class CardStack extends React.Component {
|
||||
// d = duration
|
||||
Math.easeInOutQuad = function (t, b, c, d) {
|
||||
t /= d / 2
|
||||
if (t < 1) return c / 2 * t * t + b
|
||||
if (t < 1) return (c / 2) * t * t + b
|
||||
t -= 1
|
||||
return -c / 2 * (t * (t - 2) - 1) + b
|
||||
return (-c / 2) * (t * (t - 2) - 1) + b
|
||||
}
|
||||
|
||||
const animateScroll = function () {
|
||||
@@ -58,104 +56,29 @@ class CardStack extends React.Component {
|
||||
|
||||
renderCards (events, selections) {
|
||||
// if no selections provided, select all
|
||||
if (!selections) { selections = events.map(e => true) }
|
||||
if (!selections) {
|
||||
selections = events.map((e) => true)
|
||||
}
|
||||
this.refs = []
|
||||
|
||||
return events.map((event, idx) => {
|
||||
const thisRef = React.createRef()
|
||||
this.refs[idx] = thisRef
|
||||
|
||||
let precision
|
||||
switch (event.location_precision) {
|
||||
case `Generalised`:
|
||||
precision = `No location data`
|
||||
break
|
||||
case `Estimated`:
|
||||
precision = `Precise location estimated`
|
||||
break
|
||||
case `Self-reported`:
|
||||
precision = `Location reported by witness`
|
||||
break
|
||||
case `Confirmed`:
|
||||
default:
|
||||
precision = null
|
||||
break
|
||||
}
|
||||
|
||||
return (<Card
|
||||
// event={event}
|
||||
ref={thisRef}
|
||||
// sourceError={this.props.sourceError}
|
||||
content={[
|
||||
[{ kind: 'tag', align: 'end', value: `Incident #${event.incident_id}` }],
|
||||
[{ kind: 'line' }],
|
||||
[
|
||||
{ kind: 'date', title: 'Incident Date', value: event.datetime },
|
||||
{ kind: 'text', title: 'Location', hoverValue: precision, value: event.location }
|
||||
],
|
||||
[{ kind: 'line-break', times: 0.4 }],
|
||||
[
|
||||
{
|
||||
kind: 'text',
|
||||
title: 'Summary',
|
||||
value: event.description,
|
||||
scaleFont: 1.1
|
||||
}
|
||||
],
|
||||
[{ kind: 'line-break', times: 0.4 }],
|
||||
[
|
||||
{
|
||||
kind: 'button',
|
||||
title: 'Type of Violation',
|
||||
value: event.associations.slice(0, -1).map(association => ({
|
||||
text: association,
|
||||
color: getFilterIdxFromColorSet(association, this.props.coloringSet) >= 0 ? this.props.colors[getFilterIdxFromColorSet(association, this.props.coloringSet)] : null,
|
||||
normalCursor: true
|
||||
}))
|
||||
},
|
||||
{
|
||||
kind: 'button',
|
||||
title: 'Against',
|
||||
value: event.associations.slice(-1).map(category => ({
|
||||
text: category,
|
||||
color: null,
|
||||
normalCursor: true
|
||||
}))
|
||||
}
|
||||
],
|
||||
[{ kind: 'line-break', times: 0.2 }],
|
||||
[
|
||||
{
|
||||
kind: 'list',
|
||||
title: 'Law Enforcement Agencies',
|
||||
value: event.le_agencys
|
||||
}
|
||||
],
|
||||
[
|
||||
{ kind: 'text', title: 'Name of reporter(s)', value: event.journalist_name },
|
||||
{ kind: 'text', title: 'Network', value: event.news_organisation }
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: event.hide_source === 'FALSE' ? 'button' : 'markdown',
|
||||
title: 'Sources',
|
||||
value: event.hide_source === 'FALSE' ? event.links.map((href, idx) => ({ text: `Source ${idx + 1}`, href, color: null, onClick: () => window.open(href, '_blank') })) : 'Source hidden to protect the privacy and dignity of civilians. Read more [here](https://staging.forensic-architecture.org/wp-content/uploads/2020/09/2020.14.09-FA-Bcat-Mission-Statement.pdf).'
|
||||
}
|
||||
]
|
||||
// [{ kind: "text", title: "Category", value: "Press attack" }],
|
||||
]}
|
||||
language={this.props.language}
|
||||
isLoading={this.props.isLoading}
|
||||
isSelected={selections[idx]}
|
||||
// getNarrativeLinks={this.props.getNarrativeLinks}
|
||||
// getCategoryGroup={this.props.getCategoryGroup}
|
||||
// getCategoryColor={this.props.getCategoryColor}
|
||||
// getCategoryLabel={this.props.getCategoryLabel}
|
||||
// onViewSource={this.props.onViewSource}
|
||||
// onHighlight={this.props.onHighlight}
|
||||
// onSelect={this.props.onSelect}
|
||||
// features={this.props.features}
|
||||
/>)
|
||||
return (
|
||||
<Card
|
||||
ref={thisRef}
|
||||
content={this.props.cardUI.layout({
|
||||
event,
|
||||
colors: this.props.colors,
|
||||
coloringSet: this.props.coloringSet,
|
||||
getFilterIdxFromColorSet
|
||||
})}
|
||||
language={this.props.language}
|
||||
isLoading={this.props.isLoading}
|
||||
isSelected={selections[idx]}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -172,8 +95,7 @@ class CardStack extends React.Component {
|
||||
const { narrative } = this.props
|
||||
const showing = narrative.steps
|
||||
|
||||
const selections = showing
|
||||
.map((_, idx) => (idx === narrative.current))
|
||||
const selections = showing.map((_, idx) => idx === narrative.current)
|
||||
|
||||
return this.renderCards(showing, selections)
|
||||
}
|
||||
@@ -187,7 +109,9 @@ class CardStack extends React.Component {
|
||||
className='card-stack-header'
|
||||
onClick={() => this.props.onToggleCardstack()}
|
||||
>
|
||||
<button className='side-menu-burg is-active'><span /></button>
|
||||
<button className='side-menu-burg is-active'>
|
||||
<span />
|
||||
</button>
|
||||
<p className='header-copy top'>
|
||||
{`${this.props.selected.length} ${headerLang}`}
|
||||
</p>
|
||||
@@ -198,21 +122,19 @@ class CardStack extends React.Component {
|
||||
renderCardStackContent () {
|
||||
return (
|
||||
<div id='card-stack-content' className='card-stack-content'>
|
||||
<ul>
|
||||
{this.renderSelectedCards()}
|
||||
</ul>
|
||||
<ul>{this.renderSelectedCards()}</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderNarrativeContent () {
|
||||
return (
|
||||
<div id='card-stack-content' className='card-stack-content'
|
||||
<div
|
||||
id='card-stack-content'
|
||||
className='card-stack-content'
|
||||
ref={this.refCardStackContent}
|
||||
>
|
||||
<ul>
|
||||
{this.renderNarrativeCards()}
|
||||
</ul>
|
||||
<ul>{this.renderNarrativeCards()}</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -227,8 +149,7 @@ class CardStack extends React.Component {
|
||||
<div
|
||||
id='card-stack'
|
||||
className={`card-stack
|
||||
${isCardstack ? '' : ' folded'}`
|
||||
}
|
||||
${isCardstack ? '' : ' folded'}`}
|
||||
>
|
||||
{this.renderCardStackHeader()}
|
||||
{this.renderCardStackContent()}
|
||||
@@ -240,8 +161,7 @@ class CardStack extends React.Component {
|
||||
id='card-stack'
|
||||
ref={this.refCardStack}
|
||||
className={`card-stack narrative-mode
|
||||
${isCardstack ? '' : ' folded'}`
|
||||
}
|
||||
${isCardstack ? '' : ' folded'}`}
|
||||
style={{ height }}
|
||||
>
|
||||
{this.renderNarrativeContent()}
|
||||
|
||||
@@ -9,9 +9,9 @@ import shapeSchema from './shapeSchema'
|
||||
import { calcDatetime, capitalize } from '../../common/utilities'
|
||||
|
||||
/*
|
||||
* Create an error notification object
|
||||
* Types: ['error', 'warning', 'good', 'neural']
|
||||
*/
|
||||
* Create an error notification object
|
||||
* Types: ['error', 'warning', 'good', 'neural']
|
||||
*/
|
||||
function makeError (type, id, message) {
|
||||
return {
|
||||
type: 'error',
|
||||
@@ -27,11 +27,15 @@ function isValidDate (d) {
|
||||
function findDuplicateAssociations (associations) {
|
||||
const seenSet = new Set([])
|
||||
const duplicates = []
|
||||
associations.forEach(item => {
|
||||
associations.forEach((item) => {
|
||||
if (seenSet.has(item.id)) {
|
||||
duplicates.push({
|
||||
id: item.id,
|
||||
error: makeError('Association', item.id, 'association was found more than once. Ignoring duplicate.')
|
||||
error: makeError(
|
||||
'Association',
|
||||
item.id,
|
||||
'association was found more than once. Ignoring duplicate.'
|
||||
)
|
||||
})
|
||||
} else {
|
||||
seenSet.add(item.id)
|
||||
@@ -41,8 +45,8 @@ function findDuplicateAssociations (associations) {
|
||||
}
|
||||
|
||||
/*
|
||||
* Validate domain schema
|
||||
*/
|
||||
* Validate domain schema
|
||||
*/
|
||||
export function validateDomain (domain, features) {
|
||||
const sanitizedDomain = {
|
||||
events: [],
|
||||
@@ -79,13 +83,13 @@ export function validateDomain (domain, features) {
|
||||
}
|
||||
|
||||
function validateArray (items, domainKey, schema) {
|
||||
items.forEach(item => {
|
||||
items.forEach((item) => {
|
||||
validateArrayItem(item, domainKey, schema)
|
||||
})
|
||||
}
|
||||
|
||||
function validateObject (obj, domainKey, itemSchema) {
|
||||
Object.keys(obj).forEach(key => {
|
||||
Object.keys(obj).forEach((key) => {
|
||||
const vl = obj[key]
|
||||
const result = Joi.validate(vl, itemSchema)
|
||||
if (result.error !== null) {
|
||||
@@ -113,13 +117,10 @@ export function validateDomain (domain, features) {
|
||||
validateObject(domain.shapes, 'shapes', shapeSchema)
|
||||
|
||||
// NB: [lat, lon] array is best format for projecting into map
|
||||
sanitizedDomain.shapes = sanitizedDomain.shapes.map(shape => ({
|
||||
sanitizedDomain.shapes = sanitizedDomain.shapes.map((shape) => ({
|
||||
name: shape.name,
|
||||
points: shape.items.map(coords => (
|
||||
coords.replace(/\s/g, '').split(',')
|
||||
))
|
||||
})
|
||||
)
|
||||
points: shape.items.map((coords) => coords.replace(/\s/g, '').split(','))
|
||||
}))
|
||||
|
||||
const duplicateAssociations = findDuplicateAssociations(domain.associations)
|
||||
// Duplicated associations
|
||||
@@ -137,7 +138,14 @@ export function validateDomain (domain, features) {
|
||||
event.id = idx
|
||||
event.datetime = calcDatetime(event.date, event.time)
|
||||
if (!isValidDate(event.datetime)) {
|
||||
discardedDomain['events'].push({ ...event, error: makeError('events', event.id, `Invalid date. It's been dropped, as otherwise timemap won't work as expected.`) })
|
||||
discardedDomain['events'].push({
|
||||
...event,
|
||||
error: makeError(
|
||||
'events',
|
||||
event.id,
|
||||
`Invalid date. It's been dropped, as otherwise timemap won't work as expected.`
|
||||
)
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -146,7 +154,7 @@ export function validateDomain (domain, features) {
|
||||
sanitizedDomain.events.sort((a, b) => a.datetime - b.datetime)
|
||||
|
||||
// Message the number of failed items in domain
|
||||
Object.keys(discardedDomain).forEach(disc => {
|
||||
Object.keys(discardedDomain).forEach((disc) => {
|
||||
const len = discardedDomain[disc].length
|
||||
if (len) {
|
||||
sanitizedDomain.notifications.push({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mergeDeepLeft } from 'ramda'
|
||||
import global, { colors } from '../common/global'
|
||||
import generateCardLayout from '../common/card'
|
||||
|
||||
const isSmallLaptop = window.innerHeight < 800
|
||||
const initial = {
|
||||
@@ -46,7 +47,7 @@ const initial = {
|
||||
sites: true
|
||||
}
|
||||
},
|
||||
isMobile: (/Mobi/.test(navigator.userAgent)),
|
||||
isMobile: /Mobi/.test(navigator.userAgent),
|
||||
language: 'en-US',
|
||||
map: {
|
||||
anchor: [31.356397, 34.784818],
|
||||
@@ -54,7 +55,10 @@ const initial = {
|
||||
minZoom: 2,
|
||||
maxZoom: 16,
|
||||
bounds: null,
|
||||
maxBounds: [[180, -180], [-180, 180]]
|
||||
maxBounds: [
|
||||
[180, -180],
|
||||
[-180, 180]
|
||||
]
|
||||
},
|
||||
cluster: {
|
||||
radius: 30,
|
||||
@@ -71,14 +75,8 @@ const initial = {
|
||||
contentHeight: isSmallLaptop ? 160 : 200,
|
||||
width_controls: 100
|
||||
},
|
||||
range: [
|
||||
new Date(2001, 2, 23, 12),
|
||||
new Date(2021, 2, 23, 12)
|
||||
],
|
||||
rangeLimits: [
|
||||
new Date(1, 1, 1, 1),
|
||||
new Date()
|
||||
],
|
||||
range: [new Date(2001, 2, 23, 12), new Date(2021, 2, 23, 12)],
|
||||
rangeLimits: [new Date(1, 1, 1, 1), new Date()],
|
||||
zoomLevels: [
|
||||
{ label: '20 years', duration: 10512000 },
|
||||
{ label: '2 years', duration: 1051200 },
|
||||
@@ -99,7 +97,8 @@ const initial = {
|
||||
},
|
||||
cover: {
|
||||
title: 'project title',
|
||||
description: 'A description of the project goes here.\n\nThis description may contain markdown.\n\n# This is a large title, for example.\n\n## Whereas this is a slightly smaller title.\n\nCheck out docs/custom-covers.md in the [Timemap GitHub repo](https://github.com/forensic-architecture/timemap) for more information around how to specify custom covers.',
|
||||
description:
|
||||
'A description of the project goes here.\n\nThis description may contain markdown.\n\n# This is a large title, for example.\n\n## Whereas this is a slightly smaller title.\n\nCheck out docs/custom-covers.md in the [Timemap GitHub repo](https://github.com/forensic-architecture/timemap) for more information around how to specify custom covers.',
|
||||
exploreButton: 'EXPLORE'
|
||||
},
|
||||
loading: false
|
||||
@@ -135,8 +134,7 @@ const initial = {
|
||||
}
|
||||
},
|
||||
card: {
|
||||
order: [[`renderTime`, `renderLocation`], [`renderSummary`], [`renderCustomFields`]],
|
||||
extra: [[`renderSources`]]
|
||||
layout: ({ event }) => generateCardLayout['basic']({ event })
|
||||
},
|
||||
coloring: {
|
||||
maxNumOfColors: 4,
|
||||
|
||||
Reference in New Issue
Block a user