mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-11 21:08:36 +03:00
Co-authored-by: Juan Camilo González <j.gonzalezj@uniandes.edu.co> Co-authored-by: msramalho <19508417+msramalho@users.noreply.github.com>
599 lines
17 KiB
JavaScript
599 lines
17 KiB
JavaScript
import customParseFormat from "dayjs/plugin/customParseFormat";
|
|
import dayjs from "dayjs";
|
|
import hash from "object-hash";
|
|
import { timeFormatDefaultLocale } from "d3";
|
|
|
|
import { ASSOCIATION_MODES, POLYGON_CLIP_PATH } from "./constants";
|
|
|
|
dayjs.extend(customParseFormat);
|
|
|
|
let { DATE_FMT, TIME_FMT } = process.env;
|
|
if (!DATE_FMT) DATE_FMT = "MM/DD/YYYY";
|
|
if (!TIME_FMT) TIME_FMT = "HH:mm";
|
|
|
|
export const language = process.env.store.app.language || "en-US";
|
|
|
|
export function getPathLeaf(path) {
|
|
const splitPath = path.split("/");
|
|
return splitPath[splitPath.length - 1];
|
|
}
|
|
|
|
export function calcDatetime(date, time) {
|
|
if (!time) time = "00:00";
|
|
const dt = dayjs(`${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;
|
|
}
|
|
|
|
/**
|
|
* Takes the complete set of filters, and according to their paths places them at the correct node
|
|
*
|
|
* Returns a nested object where:
|
|
* Key: filter_path_0/filter_path_1/filter_path_2...
|
|
* Value: {...nested children}
|
|
*/
|
|
export function aggregateFilterPaths(filters) {
|
|
function insertPath(
|
|
children = {},
|
|
[headOfPath, ...remainder],
|
|
accumulatedPath
|
|
) {
|
|
const childKey = Object.keys(children).find((path) => {
|
|
const pathLeaf = getPathLeaf(path);
|
|
return pathLeaf === headOfPath;
|
|
});
|
|
accumulatedPath.push(headOfPath);
|
|
const accumulatedPlusHead = accumulatedPath.join("/");
|
|
if (!childKey) children[accumulatedPlusHead] = {};
|
|
if (remainder.length > 0)
|
|
insertPath(children[accumulatedPlusHead], remainder, accumulatedPath);
|
|
return children;
|
|
}
|
|
|
|
const allPaths = [];
|
|
filters.forEach((filterItem) => allPaths.push(filterItem.filter_paths));
|
|
const aggregatedPaths = allPaths.reduce(
|
|
(children, path) => insertPath(children, path, []),
|
|
{}
|
|
);
|
|
return aggregatedPaths;
|
|
}
|
|
|
|
/**
|
|
* 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 getFilterAncestors(filter) {
|
|
const splitFilter = filter.split("/");
|
|
const ancestors = [];
|
|
splitFilter.forEach((f, index) => {
|
|
const accumulatedPath = splitFilter.slice(0, index + 1).join("/");
|
|
ancestors.push(accumulatedPath);
|
|
});
|
|
// The last element here will be the leaf node aka the filter passed in
|
|
ancestors.pop();
|
|
return ancestors;
|
|
}
|
|
|
|
/**
|
|
* 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(filter) {
|
|
const ancestors = getFilterAncestors(filter);
|
|
return ancestors[ancestors.length - 1];
|
|
}
|
|
|
|
/**
|
|
* Grabs a given filter's siblings: the set of associations that share the same immediate filter parent.
|
|
*/
|
|
export function getFilterSiblings(allFilters, filterParent, filterKey) {
|
|
function findSiblings(filterPathObj, ancestors) {
|
|
if (ancestors.length === 0 || filterPathObj === {}) return {};
|
|
const nextAncestor = ancestors.shift();
|
|
if (Object.keys(filterPathObj).includes(nextAncestor)) {
|
|
const nextObjToSearch = filterPathObj[nextAncestor];
|
|
if (ancestors.length === 0) {
|
|
return nextObjToSearch;
|
|
} else {
|
|
return findSiblings(nextObjToSearch, ancestors);
|
|
}
|
|
}
|
|
}
|
|
const aggregatedFilters = aggregateFilterPaths(allFilters);
|
|
const ancestors = getFilterAncestors(filterKey);
|
|
const siblings = findSiblings(aggregatedFilters, ancestors);
|
|
return Object.keys(siblings).filter((sib) => sib !== filterKey);
|
|
}
|
|
|
|
/**
|
|
* Looks at the current coloring set (ie. a map between sets of filters and colors) and configures where to add next set
|
|
*/
|
|
export function addToColoringSet(coloringSet, filters) {
|
|
const flattenedColoringSet = coloringSet.flatMap((f) => f);
|
|
const newColoringSet = filters.filter(
|
|
(k) => flattenedColoringSet.indexOf(k) === -1
|
|
);
|
|
return [...coloringSet, newColoringSet];
|
|
}
|
|
|
|
/**
|
|
* Looks at the current coloring set (ie. a map between sets of filters and colors) and configures new sets based off of existing filters
|
|
*/
|
|
export function removeFromColoringSet(coloringSet, filters) {
|
|
const newColoringSets = coloringSet.map((set) =>
|
|
set.filter((s) => {
|
|
return !filters.includes(s);
|
|
})
|
|
);
|
|
return newColoringSets.filter((item) => item.length !== 0);
|
|
}
|
|
|
|
export function getEventCategories(event, activeCategories) {
|
|
const eventCats = event.associations.filter(
|
|
(a) => a.mode === ASSOCIATION_MODES.CATEGORY
|
|
);
|
|
return eventCats.reduce((acc, val) => {
|
|
const activeCatTitle = activeCategories.find((cat) => cat === val.title);
|
|
if (activeCatTitle) acc.push(activeCatTitle);
|
|
return acc;
|
|
}, []);
|
|
}
|
|
|
|
/**
|
|
* Takes a filter's path and concatenates it like so: Parent 1/Parent 2/Child
|
|
*/
|
|
export function createFilterPathString(filter) {
|
|
return filter.filter_paths.join("/");
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
case /.+(twitter\.com).+/.test(path):
|
|
type = "Tweet";
|
|
break;
|
|
case /.+(t\.me).+/.test(path):
|
|
type = "Telegram";
|
|
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. */
|
|
//TO-DO: Convert maxSize into a config var
|
|
const maxSize = totalPoints > 60 ? 60 : 35;
|
|
return Math.min(maxSize, 10 + (pointCount / totalPoints) * 100);
|
|
}
|
|
|
|
export function calculateTotalClusterPoints(clusters) {
|
|
return clusters.reduce((total, cl) => {
|
|
if (cl && cl.properties && cl.properties.cluster) {
|
|
total += cl.properties.point_count;
|
|
}
|
|
return total;
|
|
}, 0);
|
|
}
|
|
|
|
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 (const 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[createFilterPathString(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 null;
|
|
});
|
|
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) {
|
|
let m = 0;
|
|
let n = ar.length - 1;
|
|
while (m <= n) {
|
|
const k = (n + m) >> 1;
|
|
const 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(language, {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "2-digit",
|
|
});
|
|
const [{ value: month }, , { value: day }, , { value: year }] =
|
|
dateTimeFormat.formatToParts(datetime);
|
|
|
|
return `${day} ${month}, ${year}`;
|
|
}
|
|
|
|
/**
|
|
* Sets the default locale for d3 to format dates in each available language.
|
|
*/
|
|
export function setD3Locale() {
|
|
const languages = {
|
|
"es-MX": require("./data/es-MX.json"),
|
|
};
|
|
|
|
if (language !== "es-US" && languages[language]) {
|
|
timeFormatDefaultLocale(languages[language]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the set of associated styles for a given shape type from the entire set of shapes
|
|
* @param list shapes - The aggregated set of shapes
|
|
* @param list activeShapes - The set of active shapes in the app
|
|
*/
|
|
export function mapStyleByShape(shapes, activeShapes) {
|
|
const styledShapes = shapes.map((s) => {
|
|
const { colour, shape, id } = s;
|
|
const style = {
|
|
checkboxStyles: {
|
|
background: activeShapes.includes(id) ? colour : "black",
|
|
border: "none",
|
|
clipPath: POLYGON_CLIP_PATH[shape],
|
|
},
|
|
containerStyles: {
|
|
background: colour,
|
|
clipPath: POLYGON_CLIP_PATH[shape],
|
|
},
|
|
};
|
|
s.styles = style;
|
|
return s;
|
|
});
|
|
return styledShapes;
|
|
}
|
|
|
|
export function mapCategoriesToPaths(categories, panelCategories) {
|
|
const mappedCats = categories.reduce((acc, cat) => {
|
|
const type = cat.filter_paths[0];
|
|
if (!(type in acc)) {
|
|
acc[type] = [];
|
|
}
|
|
acc[type].push(cat);
|
|
return acc;
|
|
}, {});
|
|
|
|
const categoryMap =
|
|
panelCategories.length > 1 ? mappedCats : { default: categories };
|
|
return categoryMap;
|
|
}
|
|
|
|
export function getCategoryIdxs(panelCategories, startingIdx) {
|
|
let idxCounter = startingIdx;
|
|
// If there are specified categories from the config, filter out the default value; else, leave the default value
|
|
const catTypes =
|
|
panelCategories.length > 1
|
|
? panelCategories.filter((val) => val !== "default")
|
|
: panelCategories;
|
|
return catTypes.reduce((set, val) => {
|
|
set[val] = idxCounter;
|
|
idxCounter += 1;
|
|
return set;
|
|
}, {});
|
|
}
|
|
|
|
export function getFilterIdx(
|
|
narrativesExist,
|
|
categoriesExist,
|
|
numCategoryPanels
|
|
) {
|
|
if (narrativesExist && !categoriesExist) return 1;
|
|
else if (!narrativesExist && categoriesExist) return numCategoryPanels;
|
|
else if (narrativesExist && categoriesExist) return numCategoryPanels + 1;
|
|
else return 0;
|
|
}
|
|
|
|
export function downloadAsFile(filename, content) {
|
|
let element = document.createElement("a");
|
|
element.setAttribute(
|
|
"href",
|
|
`data:application/octet-stream;charset=utf-8,${encodeURIComponent(content)}`
|
|
);
|
|
element.setAttribute("download", filename);
|
|
|
|
element.style.display = "none";
|
|
document.body.appendChild(element);
|
|
|
|
element.click();
|
|
|
|
document.body.removeChild(element);
|
|
}
|
|
|
|
export const isEmptyString = (s) => s.length === 0;
|