mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-12 13:28:36 +03:00
402 lines
11 KiB
JavaScript
402 lines
11 KiB
JavaScript
import moment from 'moment'
|
|
import hash from 'object-hash'
|
|
|
|
let { DATE_FMT, TIME_FMT } = process.env
|
|
if (!DATE_FMT) DATE_FMT = 'MM/DD/YYYY'
|
|
if (!TIME_FMT) TIME_FMT = 'HH:mm'
|
|
|
|
export function calcDatetime (date, time) {
|
|
if (!time) time = '00:00'
|
|
const dt = moment(`${date} ${time}`, `${DATE_FMT} ${TIME_FMT}`)
|
|
return dt.toDate()
|
|
}
|
|
|
|
export function getCoordinatesForPercent (radius, percent) {
|
|
const x = radius * Math.cos(2 * Math.PI * percent)
|
|
const y = radius * Math.sin(2 * Math.PI * percent)
|
|
return [x, y]
|
|
}
|
|
|
|
/**
|
|
* This function takes the array of percentages: [0.5, 0.5, ...]
|
|
* and maps it by index to the set of colors ['#fff', '#000', ...]
|
|
* If there aren't enough colors in the set, it raises an error for the user
|
|
*
|
|
* Return value:
|
|
* ex. {'#fff': 0.5, '#000': 0.5, ...} */
|
|
export function zipColorsToPercentages (colors, percentages) {
|
|
if (colors.length < percentages.length) throw new Error('You must declare an appropriate number of filter colors')
|
|
|
|
return percentages.reduce((map, percent, idx) => {
|
|
map[colors[idx]] = percent
|
|
return map
|
|
}, {})
|
|
}
|
|
|
|
/**
|
|
* Get URI params to start with predefined set of
|
|
* https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript
|
|
* @param {string} name: name of paramater to search
|
|
* @param {string} url: url passed as variable, defaults to window.location.href
|
|
*/
|
|
export function getParameterByName (name, url) {
|
|
if (!url) url = window.location.href
|
|
name = name.replace(/[[\]]/g, `\\$&`)
|
|
|
|
const regex = new RegExp(`[?&]${name}(=([^&#]*)|&|#|$)`)
|
|
const results = regex.exec(url)
|
|
|
|
if (!results) return null
|
|
if (!results[2]) return ''
|
|
|
|
return decodeURIComponent(results[2].replace(/\+/g, ' '))
|
|
}
|
|
|
|
/**
|
|
* Compare two arrays of scalars
|
|
* @param {array} arr1: array of numbers
|
|
* @param {array} arr2: array of numbers
|
|
*/
|
|
export function areEqual (arr1, arr2) {
|
|
return ((arr1.length === arr2.length) && arr1.every((element, index) => {
|
|
return element === arr2[index]
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Return whether the variable is neither null nor undefined
|
|
* @param {object} variable
|
|
*/
|
|
export function isNotNullNorUndefined (variable) {
|
|
return (typeof variable !== 'undefined' && variable !== null)
|
|
}
|
|
|
|
/*
|
|
* Taken from: https://stackoverflow.com/questions/1026069/how-do-i-make-the-first-letter-of-a-string-uppercase-in-javascript
|
|
*/
|
|
export function capitalize (string) {
|
|
return string.charAt(0).toUpperCase() + string.slice(1)
|
|
}
|
|
|
|
export function trimAndEllipse (string, stringNum) {
|
|
if (string.length > stringNum) {
|
|
return string.substring(0, 120) + '...'
|
|
}
|
|
return string
|
|
}
|
|
|
|
/**
|
|
* From the set of associations, grab a given filter's set of parents,
|
|
* ie. all the elements in the path array before the idx where the filter is located.
|
|
* If we can't find the filter by the ID, we know its a meta filter, so we look
|
|
* through every association's given path attribute to find its location.
|
|
*
|
|
* Returns the list of parents: ex. ['Chemical', 'Tear Gas', ...]
|
|
*/
|
|
export function getFilterParents (associations, filter) {
|
|
for (let a of associations) {
|
|
const { filter_paths: fp } = a
|
|
if (a.id === filter) {
|
|
return fp.slice(0, fp.length - 1)
|
|
}
|
|
const filterIndex = fp.indexOf(filter)
|
|
if (filterIndex === 0) return []
|
|
if (filterIndex > 0) return fp.slice(0, filterIndex)
|
|
}
|
|
throw new Error('Attempted to get parents of nonexistent filter')
|
|
}
|
|
|
|
/**
|
|
* Grabs the second to last element in the paths array for a given existing filter.
|
|
* This is the filter's most immediate ancestor.
|
|
*/
|
|
export function getImmediateFilterParent (associations, filter) {
|
|
const parents = getFilterParents(associations, filter)
|
|
if (parents.length === 0) return null
|
|
return parents[parents.length - 1]
|
|
}
|
|
|
|
/**
|
|
* Grab a meta filter's siblings, by way of the the `filter_path` hierarcy.
|
|
*/
|
|
export function getMetaFilterSiblings (allFilters, filterParent, filterKey) {
|
|
const idxParent = allFilters.map(f => {
|
|
return f.filter_paths.reduceRight((acc, path, idx) => {
|
|
if (path === filterParent) return f.filter_paths[idx + 1]
|
|
return acc
|
|
}, null)
|
|
})
|
|
.filter(metaFilter => !!metaFilter && metaFilter !== filterKey)
|
|
return [ ...(new Set(idxParent)) ]
|
|
}
|
|
|
|
/**
|
|
* Grabs a given filter's siblings: the set of associations that share the same immediate filter parent.
|
|
*/
|
|
export function getFilterSiblings (allFilters, filterParent, filterKey) {
|
|
const isMetaFilter = !allFilters.map(filt => filt.id).includes(filterKey)
|
|
|
|
if (isMetaFilter) {
|
|
return getMetaFilterSiblings(allFilters, filterParent, filterKey)
|
|
}
|
|
|
|
return allFilters.reduce((acc, val) => {
|
|
const valParent = getImmediateFilterParent(allFilters, val.id)
|
|
if (valParent === filterParent && val.id !== filterKey) acc.push(val.id)
|
|
return acc
|
|
}, [])
|
|
}
|
|
|
|
export function getEventCategories (event, categories) {
|
|
const matchedCategories = []
|
|
if (event.associations && event.associations.length > 0) {
|
|
event.associations.reduce((acc, val) => {
|
|
const foundCategory = categories.find(cat => cat.id === val)
|
|
if (foundCategory) acc.push(foundCategory)
|
|
return acc
|
|
}, matchedCategories)
|
|
}
|
|
return matchedCategories
|
|
}
|
|
|
|
/**
|
|
* Inset the full source represenation from 'allSources' into an event. The
|
|
* function is 'curried' to allow easy use with maps. To use for a single
|
|
* source, call with two sets of parentheses:
|
|
* const src = insetSourceFrom(sources)(anEvent)
|
|
*/
|
|
export function insetSourceFrom (allSources) {
|
|
return (event) => {
|
|
let sources
|
|
if (!event.sources) {
|
|
sources = []
|
|
} else {
|
|
sources = event.sources.map(id => {
|
|
return allSources.hasOwnProperty(id) ? allSources[id] : null
|
|
})
|
|
}
|
|
return {
|
|
...event,
|
|
sources
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Debugging function: put in place of a mapStateToProps function to
|
|
* view that source modal by default
|
|
*/
|
|
export function injectSource (id) {
|
|
return state => {
|
|
return {
|
|
...state,
|
|
app: {
|
|
...state.app,
|
|
source: state.domain.sources[id]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function urlFromEnv (ext) {
|
|
if (process.env[ext]) {
|
|
if (!Array.isArray(process.env[ext])) { return [`${process.env.SERVER_ROOT}${process.env[ext]}`] } else {
|
|
return process.env[ext].map(suffix => `${process.env.SERVER_ROOT}${suffix}`)
|
|
}
|
|
} else {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function toggleFlagAC (flag) {
|
|
return (appState) => ({
|
|
...appState,
|
|
flags: {
|
|
...appState.flags,
|
|
[flag]: !appState.flags[flag]
|
|
}
|
|
})
|
|
}
|
|
|
|
export function selectTypeFromPath (path) {
|
|
let type
|
|
switch (true) {
|
|
case /\.(png|jpg)$/.test(path):
|
|
type = 'Image'; break
|
|
case /\.(mp4)$/.test(path):
|
|
type = 'Video'; break
|
|
case /\.(md)$/.test(path):
|
|
type = 'Text'; break
|
|
default:
|
|
type = 'Unknown'; break
|
|
}
|
|
return { type, path }
|
|
}
|
|
|
|
export function typeForPath (path) {
|
|
let type
|
|
path = path.trim()
|
|
switch (true) {
|
|
case /\.((png)|(jpg)|(jpeg))$/.test(path):
|
|
type = 'Image'; break
|
|
case /\.(mp4)$/.test(path):
|
|
type = 'Video'; break
|
|
case /\.(md)$/.test(path):
|
|
type = 'Text'; break
|
|
case /\.(pdf)$/.test(path):
|
|
type = 'Document'; break
|
|
default:
|
|
type = 'Unknown'; break
|
|
}
|
|
return type
|
|
}
|
|
|
|
export function selectTypeFromPathWithPoster (path, poster) {
|
|
return { type: typeForPath(path), path, poster }
|
|
}
|
|
|
|
export function isIdentical (obj1, obj2) {
|
|
return hash(obj1) === hash(obj2)
|
|
}
|
|
|
|
export function calcOpacity (num) {
|
|
/* Events have opacity 0.5 by default, and get added to according to how many
|
|
* other events there are in the same render. The idea here is that the
|
|
* overlaying of events builds up a 'heat map' of the event space, where
|
|
* darker areas represent more events with proportion */
|
|
const base = num >= 1 ? 0.9 : 0
|
|
return base + (Math.min(0.5, 0.08 * (num - 1)))
|
|
}
|
|
|
|
export function calcClusterOpacity (pointCount, totalPoints) {
|
|
/* Clusters represent multiple events within a specific radius. The darker the cluster,
|
|
the larger the number of underlying events. We use a multiplication factor (50) here as well
|
|
to ensure that the larger clusters have an appropriately darker shading. */
|
|
return Math.min(0.85, 0.08 + (pointCount / totalPoints) * 50)
|
|
}
|
|
|
|
export function calcClusterSize (pointCount, totalPoints) {
|
|
/* The larger the cluster size, the higher the count of points that the cluster represents.
|
|
Just like with opacity, we use a multiplication factor to ensure that clusters with higher point
|
|
counts appear larger. */
|
|
const maxSize = totalPoints > 60 ? 40 : 20
|
|
return Math.min(maxSize, 10 + (pointCount / totalPoints) * 150)
|
|
}
|
|
|
|
export function isLatitude (lat) {
|
|
return !!lat && isFinite(lat) && Math.abs(lat) <= 90
|
|
}
|
|
|
|
export function isLongitude (lng) {
|
|
return !!lng && isFinite(lng) && Math.abs(lng) <= 180
|
|
}
|
|
|
|
export function mapClustersToLocations (clusters, locations) {
|
|
return clusters.reduce((acc, cl) => {
|
|
const foundLocation = locations.find(location => location.label === cl.properties.id)
|
|
if (foundLocation) acc.push(foundLocation)
|
|
return acc
|
|
}, [])
|
|
}
|
|
|
|
/**
|
|
* Loops through a set of either locations or events
|
|
* and calculates the proportionate percentage of every given association in relation to the coloring set
|
|
*/
|
|
export function calculateColorPercentages (set, coloringSet) {
|
|
if (coloringSet.length === 0) return [1]
|
|
const associationMap = {}
|
|
|
|
for (const [idx, value] of coloringSet.entries()) {
|
|
for (let filter of value) {
|
|
associationMap[filter] = idx
|
|
}
|
|
}
|
|
|
|
const associationCounts = new Array(coloringSet.length)
|
|
associationCounts.fill(0)
|
|
|
|
let totalAssociations = 0
|
|
|
|
set.forEach(item => {
|
|
let innerSet = 'events' in item ? item.events : item
|
|
|
|
if (!Array.isArray(innerSet)) innerSet = [innerSet]
|
|
|
|
innerSet.forEach(val => {
|
|
val.associations.forEach(a => {
|
|
const idx = associationMap[a]
|
|
if (!idx && idx !== 0) return
|
|
associationCounts[idx] += 1
|
|
totalAssociations += 1
|
|
})
|
|
})
|
|
})
|
|
|
|
if (totalAssociations === 0) return [1]
|
|
|
|
return associationCounts.map(count => count / totalAssociations)
|
|
}
|
|
|
|
/**
|
|
* Gets the idx of a given filter in relation to its position in the coloring set
|
|
*
|
|
* Example coloringSet = [['Chemical', 'Tear Gas'], ['Procedural', 'Destruction of property']]
|
|
*/
|
|
export function getFilterIdxFromColorSet (filter, coloringSet) {
|
|
let filterIdx = -1
|
|
coloringSet.map((set, idx) => {
|
|
const foundIdx = set.indexOf(filter)
|
|
if (foundIdx !== -1) filterIdx = idx
|
|
})
|
|
return filterIdx
|
|
}
|
|
|
|
export const dateMin = function () {
|
|
return Array.prototype.slice.call(arguments).reduce(function (a, b) {
|
|
return a < b ? a : b
|
|
})
|
|
}
|
|
|
|
export const dateMax = function () {
|
|
return Array.prototype.slice.call(arguments).reduce(function (a, b) {
|
|
return a > b ? a : b
|
|
})
|
|
}
|
|
|
|
/** Taken from
|
|
* https://stackoverflow.com/questions/22697936/binary-search-in-javascript
|
|
* **/
|
|
export function binarySearch (ar, el, compareFn) {
|
|
var m = 0
|
|
var n = ar.length - 1
|
|
while (m <= n) {
|
|
var k = (n + m) >> 1
|
|
var cmp = compareFn(el, ar[k])
|
|
if (cmp > 0) {
|
|
m = k + 1
|
|
} else if (cmp < 0) {
|
|
n = k - 1
|
|
} else {
|
|
return k
|
|
}
|
|
}
|
|
return -m - 1
|
|
}
|
|
|
|
export function makeNiceDate (datetime) {
|
|
if (datetime === null) return null
|
|
// see https://stackoverflow.com/questions/3552461/how-to-format-a-javascript-date
|
|
const dateTimeFormat = new Intl.DateTimeFormat(
|
|
'en',
|
|
{ year: 'numeric', month: 'long', day: '2-digit' }
|
|
)
|
|
const [
|
|
{ value: month },,
|
|
{ value: day },,
|
|
{ value: year }
|
|
] = dateTimeFormat.formatToParts(datetime)
|
|
|
|
return `${day} ${month}, ${year}`
|
|
}
|