Merge pull request #188 from forensic-architecture/feature/build-system-cra-ejected

New build system with ejected Create React App
This commit is contained in:
Zac Ioannidis
2020-12-23 17:12:11 +02:00
committed by GitHub
132 changed files with 6414 additions and 12547 deletions

View File

@@ -1,3 +0,0 @@
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}

1
.env Normal file
View File

@@ -0,0 +1 @@
FAST_REFRESH=true

7
.gitignore vendored
View File

@@ -1,12 +1,19 @@
.idea/
build/
node_modules/
*config.js
dev.config.js
!config/webpack*.config.js
!config/getHttpsConfig.js
tags
tags.lock
tags.temp
.eslintcache
src/\.DS_Store
src/assets/fonts

4
.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
# Ignore Create React App-related scaffolding
config/
scripts/
test/server_process.js

125
config/env.js Normal file
View File

@@ -0,0 +1,125 @@
"use strict";
const fs = require("fs");
const path = require("path");
const paths = require("./paths");
// Make sure that including paths.js after env.js will read .env variables.
delete require.cache[require.resolve("./paths")];
// START - injecting env variables from config.js
// This section is responsible for require-ing the provided
// command-line argument configuration file and serializing it.
// It will later be available inside our app under process.env,
// through webpack.definePlugin
const CONFIG = process.env.CONFIG || "config.js";
const envConfig = require("../" + CONFIG);
const userConfig = {};
for (const k in envConfig) {
userConfig[k] = JSON.stringify(envConfig[k]);
}
// END - injecting env variables from config.js
const NODE_ENV = process.env.NODE_ENV;
if (!NODE_ENV) {
throw new Error(
"The NODE_ENV environment variable is required but was not specified."
);
}
// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use
const dotenvFiles = [
`${paths.dotenv}.${NODE_ENV}.local`,
// Don't include `.env.local` for `test` environment
// since normally you expect tests to produce the same
// results for everyone
NODE_ENV !== "test" && `${paths.dotenv}.local`,
`${paths.dotenv}.${NODE_ENV}`,
paths.dotenv,
].filter(Boolean);
// Load environment variables from .env* files. Suppress warnings using silent
// if this file is missing. dotenv will never modify any environment variables
// that have already been set. Variable expansion is supported in .env files.
// https://github.com/motdotla/dotenv
// https://github.com/motdotla/dotenv-expand
dotenvFiles.forEach((dotenvFile) => {
if (fs.existsSync(dotenvFile)) {
require("dotenv-expand")(
require("dotenv").config({
path: dotenvFile,
})
);
}
});
// We support resolving modules according to `NODE_PATH`.
// This lets you use absolute paths in imports inside large monorepos:
// https://github.com/facebook/create-react-app/issues/253.
// It works similar to `NODE_PATH` in Node itself:
// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders
// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored.
// Otherwise, we risk importing Node.js core modules into an app instead of webpack shims.
// https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421
// We also resolve them to make sure all tools using them work consistently.
const appDirectory = fs.realpathSync(process.cwd());
process.env.NODE_PATH = (process.env.NODE_PATH || "")
.split(path.delimiter)
.filter((folder) => folder && !path.isAbsolute(folder))
.map((folder) => path.resolve(appDirectory, folder))
.join(path.delimiter);
// Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be
// injected into the application via DefinePlugin in webpack configuration.
const REACT_APP = /^REACT_APP_/i;
function getClientEnvironment(publicUrl) {
const raw = Object.keys(process.env)
.filter((key) => REACT_APP.test(key))
.reduce(
(env, key) => {
env[key] = process.env[key];
return env;
},
{
// Useful for determining whether were running in production mode.
// Most importantly, it switches React into the correct mode.
NODE_ENV: process.env.NODE_ENV || "development",
// Useful for resolving the correct path to static assets in `public`.
// For example, <img src={process.env.PUBLIC_URL + '/img/logo.png'} />.
// This should only be used as an escape hatch. Normally you would put
// images into the `src` and `import` them in code to get their paths.
PUBLIC_URL: publicUrl,
// We support configuring the sockjs pathname during development.
// These settings let a developer run multiple simultaneous projects.
// They are used as the connection `hostname`, `pathname` and `port`
// in webpackHotDevClient. They are used as the `sockHost`, `sockPath`
// and `sockPort` options in webpack-dev-server.
WDS_SOCKET_HOST: process.env.WDS_SOCKET_HOST,
WDS_SOCKET_PATH: process.env.WDS_SOCKET_PATH,
WDS_SOCKET_PORT: process.env.WDS_SOCKET_PORT,
// Whether or not react-refresh is enabled.
// react-refresh is not 100% stable at this time,
// which is why it's disabled by default.
// It is defined here so it is available in the webpackHotDevClient.
FAST_REFRESH: process.env.FAST_REFRESH !== "false",
}
);
// Stringify all values so we can feed into webpack DefinePlugin
const stringified = {
"process.env": Object.keys(raw).reduce(
(env, key) => {
env[key] = JSON.stringify(raw[key]);
return env;
},
{
...userConfig,
}
),
};
return { raw, stringified };
}
module.exports = getClientEnvironment;

66
config/getHttpsConfig.js Normal file
View File

@@ -0,0 +1,66 @@
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const chalk = require('react-dev-utils/chalk');
const paths = require('./paths');
// Ensure the certificate and key provided are valid and if not
// throw an easy to debug error
function validateKeyAndCerts({ cert, key, keyFile, crtFile }) {
let encrypted;
try {
// publicEncrypt will throw an error with an invalid cert
encrypted = crypto.publicEncrypt(cert, Buffer.from('test'));
} catch (err) {
throw new Error(
`The certificate "${chalk.yellow(crtFile)}" is invalid.\n${err.message}`
);
}
try {
// privateDecrypt will throw an error with an invalid key
crypto.privateDecrypt(key, encrypted);
} catch (err) {
throw new Error(
`The certificate key "${chalk.yellow(keyFile)}" is invalid.\n${
err.message
}`
);
}
}
// Read file and throw an error if it doesn't exist
function readEnvFile(file, type) {
if (!fs.existsSync(file)) {
throw new Error(
`You specified ${chalk.cyan(
type
)} in your env, but the file "${chalk.yellow(file)}" can't be found.`
);
}
return fs.readFileSync(file);
}
// Get the https config
// Return cert files if provided in env, otherwise just true or false
function getHttpsConfig() {
const { SSL_CRT_FILE, SSL_KEY_FILE, HTTPS } = process.env;
const isHttps = HTTPS === 'true';
if (isHttps && SSL_CRT_FILE && SSL_KEY_FILE) {
const crtFile = path.resolve(paths.appPath, SSL_CRT_FILE);
const keyFile = path.resolve(paths.appPath, SSL_KEY_FILE);
const config = {
cert: readEnvFile(crtFile, 'SSL_CRT_FILE'),
key: readEnvFile(keyFile, 'SSL_KEY_FILE'),
};
validateKeyAndCerts({ ...config, keyFile, crtFile });
return config;
}
return isHttps;
}
module.exports = getHttpsConfig;

View File

@@ -0,0 +1,14 @@
"use strict";
// This is a custom Jest transformer turning style imports into empty objects.
// http://facebook.github.io/jest/docs/en/webpack.html
module.exports = {
process() {
return "module.exports = {};";
},
getCacheKey() {
// The output is always the same.
return "cssTransform";
},
};

View File

@@ -0,0 +1,40 @@
"use strict";
const path = require("path");
const camelcase = require("camelcase");
// This is a custom Jest transformer turning file imports into filenames.
// http://facebook.github.io/jest/docs/en/webpack.html
module.exports = {
process(src, filename) {
const assetFilename = JSON.stringify(path.basename(filename));
if (filename.match(/\.svg$/)) {
// Based on how SVGR generates a component name:
// https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6
const pascalCaseFilename = camelcase(path.parse(filename).name, {
pascalCase: true,
});
const componentName = `Svg${pascalCaseFilename}`;
return `const React = require('react');
module.exports = {
__esModule: true,
default: ${assetFilename},
ReactComponent: React.forwardRef(function ${componentName}(props, ref) {
return {
$$typeof: Symbol.for('react.element'),
type: 'svg',
ref: ref,
key: null,
props: Object.assign({}, props, {
children: ${assetFilename}
})
};
}),
};`;
}
return `module.exports = ${assetFilename};`;
},
};

View File

@@ -0,0 +1,2 @@
const envConfig = require("../../" + (process.env.CONFIG || 'config.js'));
process.env = { ...process.env, ...envConfig };

134
config/modules.js Normal file
View File

@@ -0,0 +1,134 @@
'use strict';
const fs = require('fs');
const path = require('path');
const paths = require('./paths');
const chalk = require('react-dev-utils/chalk');
const resolve = require('resolve');
/**
* Get additional module paths based on the baseUrl of a compilerOptions object.
*
* @param {Object} options
*/
function getAdditionalModulePaths(options = {}) {
const baseUrl = options.baseUrl;
if (!baseUrl) {
return '';
}
const baseUrlResolved = path.resolve(paths.appPath, baseUrl);
// We don't need to do anything if `baseUrl` is set to `node_modules`. This is
// the default behavior.
if (path.relative(paths.appNodeModules, baseUrlResolved) === '') {
return null;
}
// Allow the user set the `baseUrl` to `appSrc`.
if (path.relative(paths.appSrc, baseUrlResolved) === '') {
return [paths.appSrc];
}
// If the path is equal to the root directory we ignore it here.
// We don't want to allow importing from the root directly as source files are
// not transpiled outside of `src`. We do allow importing them with the
// absolute path (e.g. `src/Components/Button.js`) but we set that up with
// an alias.
if (path.relative(paths.appPath, baseUrlResolved) === '') {
return null;
}
// Otherwise, throw an error.
throw new Error(
chalk.red.bold(
"Your project's `baseUrl` can only be set to `src` or `node_modules`." +
' Create React App does not support other values at this time.'
)
);
}
/**
* Get webpack aliases based on the baseUrl of a compilerOptions object.
*
* @param {*} options
*/
function getWebpackAliases(options = {}) {
const baseUrl = options.baseUrl;
if (!baseUrl) {
return {};
}
const baseUrlResolved = path.resolve(paths.appPath, baseUrl);
if (path.relative(paths.appPath, baseUrlResolved) === '') {
return {
src: paths.appSrc,
};
}
}
/**
* Get jest aliases based on the baseUrl of a compilerOptions object.
*
* @param {*} options
*/
function getJestAliases(options = {}) {
const baseUrl = options.baseUrl;
if (!baseUrl) {
return {};
}
const baseUrlResolved = path.resolve(paths.appPath, baseUrl);
if (path.relative(paths.appPath, baseUrlResolved) === '') {
return {
'^src/(.*)$': '<rootDir>/src/$1',
};
}
}
function getModules() {
// Check if TypeScript is setup
const hasTsConfig = fs.existsSync(paths.appTsConfig);
const hasJsConfig = fs.existsSync(paths.appJsConfig);
if (hasTsConfig && hasJsConfig) {
throw new Error(
'You have both a tsconfig.json and a jsconfig.json. If you are using TypeScript please remove your jsconfig.json file.'
);
}
let config;
// If there's a tsconfig.json we assume it's a
// TypeScript project and set up the config
// based on tsconfig.json
if (hasTsConfig) {
const ts = require(resolve.sync('typescript', {
basedir: paths.appNodeModules,
}));
config = ts.readConfigFile(paths.appTsConfig, ts.sys.readFile).config;
// Otherwise we'll check if there is jsconfig.json
// for non TS projects.
} else if (hasJsConfig) {
config = require(paths.appJsConfig);
}
config = config || {};
const options = config.compilerOptions || {};
const additionalModulePaths = getAdditionalModulePaths(options);
return {
additionalModulePaths: additionalModulePaths,
webpackAliases: getWebpackAliases(options),
jestAliases: getJestAliases(options),
hasTsConfig,
};
}
module.exports = getModules();

73
config/paths.js Normal file
View File

@@ -0,0 +1,73 @@
'use strict';
const path = require('path');
const fs = require('fs');
const getPublicUrlOrPath = require('react-dev-utils/getPublicUrlOrPath');
// Make sure any symlinks in the project folder are resolved:
// https://github.com/facebook/create-react-app/issues/637
const appDirectory = fs.realpathSync(process.cwd());
const resolveApp = relativePath => path.resolve(appDirectory, relativePath);
// We use `PUBLIC_URL` environment variable or "homepage" field to infer
// "public path" at which the app is served.
// webpack needs to know it to put the right <script> hrefs into HTML even in
// single-page apps that may serve index.html for nested URLs like /todos/42.
// We can't use a relative path in HTML because we don't want to load something
// like /todos/42/static/js/bundle.7289d.js. We have to know the root.
const publicUrlOrPath = getPublicUrlOrPath(
process.env.NODE_ENV === 'development',
require(resolveApp('package.json')).homepage,
process.env.PUBLIC_URL
);
const moduleFileExtensions = [
'web.mjs',
'mjs',
'web.js',
'js',
'web.ts',
'ts',
'web.tsx',
'tsx',
'json',
'web.jsx',
'jsx',
];
// Resolve file paths in the same order as webpack
const resolveModule = (resolveFn, filePath) => {
const extension = moduleFileExtensions.find(extension =>
fs.existsSync(resolveFn(`${filePath}.${extension}`))
);
if (extension) {
return resolveFn(`${filePath}.${extension}`);
}
return resolveFn(`${filePath}.js`);
};
// config after eject: we're in ./config/
module.exports = {
dotenv: resolveApp('.env'),
appPath: resolveApp('.'),
appBuild: resolveApp('build'),
appPublic: resolveApp('public'),
appHtml: resolveApp('public/index.html'),
appIndexJs: resolveModule(resolveApp, 'src/index'),
appPackageJson: resolveApp('package.json'),
appSrc: resolveApp('src'),
appTsConfig: resolveApp('tsconfig.json'),
appJsConfig: resolveApp('jsconfig.json'),
yarnLockFile: resolveApp('yarn.lock'),
testsSetup: resolveModule(resolveApp, 'src/setupTests'),
proxySetup: resolveApp('src/setupProxy.js'),
appNodeModules: resolveApp('node_modules'),
swSrc: resolveModule(resolveApp, 'src/service-worker'),
publicUrlOrPath,
};
module.exports.moduleFileExtensions = moduleFileExtensions;

35
config/pnpTs.js Normal file
View File

@@ -0,0 +1,35 @@
'use strict';
const { resolveModuleName } = require('ts-pnp');
exports.resolveModuleName = (
typescript,
moduleName,
containingFile,
compilerOptions,
resolutionHost
) => {
return resolveModuleName(
moduleName,
containingFile,
compilerOptions,
resolutionHost,
typescript.resolveModuleName
);
};
exports.resolveTypeReferenceDirective = (
typescript,
moduleName,
containingFile,
compilerOptions,
resolutionHost
) => {
return resolveModuleName(
moduleName,
containingFile,
compilerOptions,
resolutionHost,
typescript.resolveTypeReferenceDirective
);
};

748
config/webpack.config.js Normal file
View File

@@ -0,0 +1,748 @@
'use strict';
const fs = require('fs');
const path = require('path');
const webpack = require('webpack');
const resolve = require('resolve');
const PnpWebpackPlugin = require('pnp-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin');
const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin');
const TerserPlugin = require('terser-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const safePostCssParser = require('postcss-safe-parser');
const ManifestPlugin = require('webpack-manifest-plugin');
const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
const WorkboxWebpackPlugin = require('workbox-webpack-plugin');
const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
const ESLintPlugin = require('eslint-webpack-plugin');
const paths = require('./paths');
const modules = require('./modules');
const getClientEnvironment = require('./env');
const ModuleNotFoundPlugin = require('react-dev-utils/ModuleNotFoundPlugin');
const ForkTsCheckerWebpackPlugin = require('react-dev-utils/ForkTsCheckerWebpackPlugin');
const typescriptFormatter = require('react-dev-utils/typescriptFormatter');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const postcssNormalize = require('postcss-normalize');
const appPackageJson = require(paths.appPackageJson);
// Source maps are resource heavy and can cause out of memory issue for large source files.
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false';
const webpackDevClientEntry = require.resolve(
'react-dev-utils/webpackHotDevClient'
);
const reactRefreshOverlayEntry = require.resolve(
'react-dev-utils/refreshOverlayInterop'
);
// Some apps do not need the benefits of saving a web request, so not inlining the chunk
// makes for a smoother build process.
const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== 'false';
const imageInlineSizeLimit = parseInt(
process.env.IMAGE_INLINE_SIZE_LIMIT || '10000'
);
// Check if TypeScript is setup
const useTypeScript = fs.existsSync(paths.appTsConfig);
// Get the path to the uncompiled service worker (if it exists).
const swSrc = paths.swSrc;
// style files regexes
const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;
const hasJsxRuntime = (() => {
if (process.env.DISABLE_NEW_JSX_TRANSFORM === 'true') {
return false;
}
try {
require.resolve('react/jsx-runtime');
return true;
} catch (e) {
return false;
}
})();
// This is the production and development configuration.
// It is focused on developer experience, fast rebuilds, and a minimal bundle.
module.exports = function (webpackEnv) {
const isEnvDevelopment = webpackEnv === 'development';
const isEnvProduction = webpackEnv === 'production';
// Variable used for enabling profiling in Production
// passed into alias object. Uses a flag if passed into the build command
const isEnvProductionProfile =
isEnvProduction && process.argv.includes('--profile');
// We will provide `paths.publicUrlOrPath` to our app
// as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript.
// Omit trailing slash as %PUBLIC_URL%/xyz looks better than %PUBLIC_URL%xyz.
// Get environment variables to inject into our app.
const env = getClientEnvironment(paths.publicUrlOrPath.slice(0, -1));
const shouldUseReactRefresh = env.raw.FAST_REFRESH;
// common function to get style loaders
const getStyleLoaders = (cssOptions, preProcessor) => {
const loaders = [
isEnvDevelopment && require.resolve('style-loader'),
isEnvProduction && {
loader: MiniCssExtractPlugin.loader,
// css is located in `static/css`, use '../../' to locate index.html folder
// in production `paths.publicUrlOrPath` can be a relative path
options: paths.publicUrlOrPath.startsWith('.')
? { publicPath: '../../' }
: {},
},
{
loader: require.resolve('css-loader'),
options: cssOptions,
},
{
// Options for PostCSS as we reference these options twice
// Adds vendor prefixing based on your specified browser support in
// package.json
loader: require.resolve('postcss-loader'),
options: {
// Necessary for external CSS imports to work
// https://github.com/facebook/create-react-app/issues/2677
ident: 'postcss',
plugins: () => [
require('postcss-flexbugs-fixes'),
require('postcss-preset-env')({
autoprefixer: {
flexbox: 'no-2009',
},
stage: 3,
}),
// Adds PostCSS Normalize as the reset css with default options,
// so that it honors browserslist config in package.json
// which in turn let's users customize the target behavior as per their needs.
postcssNormalize(),
],
sourceMap: isEnvProduction ? shouldUseSourceMap : isEnvDevelopment,
},
},
].filter(Boolean);
if (preProcessor) {
loaders.push(
{
loader: require.resolve('resolve-url-loader'),
options: {
sourceMap: isEnvProduction ? shouldUseSourceMap : isEnvDevelopment,
root: paths.appSrc,
},
},
{
loader: require.resolve(preProcessor),
options: {
sourceMap: true,
},
}
);
}
return loaders;
};
return {
mode: isEnvProduction ? 'production' : isEnvDevelopment && 'development',
// Stop compilation early in production
bail: isEnvProduction,
devtool: isEnvProduction
? shouldUseSourceMap
? 'source-map'
: false
: isEnvDevelopment && 'cheap-module-source-map',
// These are the "entry points" to our application.
// This means they will be the "root" imports that are included in JS bundle.
entry:
isEnvDevelopment && !shouldUseReactRefresh
? [
// Include an alternative client for WebpackDevServer. A client's job is to
// connect to WebpackDevServer by a socket and get notified about changes.
// When you save a file, the client will either apply hot updates (in case
// of CSS changes), or refresh the page (in case of JS changes). When you
// make a syntax error, this client will display a syntax error overlay.
// Note: instead of the default WebpackDevServer client, we use a custom one
// to bring better experience for Create React App users. You can replace
// the line below with these two lines if you prefer the stock client:
//
// require.resolve('webpack-dev-server/client') + '?/',
// require.resolve('webpack/hot/dev-server'),
//
// When using the experimental react-refresh integration,
// the webpack plugin takes care of injecting the dev client for us.
webpackDevClientEntry,
// Finally, this is your app's code:
paths.appIndexJs,
// We include the app code last so that if there is a runtime error during
// initialization, it doesn't blow up the WebpackDevServer client, and
// changing JS code would still trigger a refresh.
]
: paths.appIndexJs,
output: {
// The build folder.
path: isEnvProduction ? paths.appBuild : undefined,
// Add /* filename */ comments to generated require()s in the output.
pathinfo: isEnvDevelopment,
// There will be one main bundle, and one file per asynchronous chunk.
// In development, it does not produce real files.
filename: isEnvProduction
? 'static/js/[name].[contenthash:8].js'
: isEnvDevelopment && 'static/js/bundle.js',
// TODO: remove this when upgrading to webpack 5
futureEmitAssets: true,
// There are also additional JS chunk files if you use code splitting.
chunkFilename: isEnvProduction
? 'static/js/[name].[contenthash:8].chunk.js'
: isEnvDevelopment && 'static/js/[name].chunk.js',
// webpack uses `publicPath` to determine where the app is being served from.
// It requires a trailing slash, or the file assets will get an incorrect path.
// We inferred the "public path" (such as / or /my-project) from homepage.
publicPath: paths.publicUrlOrPath,
// Point sourcemap entries to original disk location (format as URL on Windows)
devtoolModuleFilenameTemplate: isEnvProduction
? info =>
path
.relative(paths.appSrc, info.absoluteResourcePath)
.replace(/\\/g, '/')
: isEnvDevelopment &&
(info => path.resolve(info.absoluteResourcePath).replace(/\\/g, '/')),
// Prevents conflicts when multiple webpack runtimes (from different apps)
// are used on the same page.
jsonpFunction: `webpackJsonp${appPackageJson.name}`,
// this defaults to 'window', but by setting it to 'this' then
// module chunks which are built will work in web workers as well.
globalObject: 'this',
},
optimization: {
minimize: isEnvProduction,
minimizer: [
// This is only used in production mode
new TerserPlugin({
terserOptions: {
parse: {
// We want terser to parse ecma 8 code. However, we don't want it
// to apply any minification steps that turns valid ecma 5 code
// into invalid ecma 5 code. This is why the 'compress' and 'output'
// sections only apply transformations that are ecma 5 safe
// https://github.com/facebook/create-react-app/pull/4234
ecma: 8,
},
compress: {
ecma: 5,
warnings: false,
// Disabled because of an issue with Uglify breaking seemingly valid code:
// https://github.com/facebook/create-react-app/issues/2376
// Pending further investigation:
// https://github.com/mishoo/UglifyJS2/issues/2011
comparisons: false,
// Disabled because of an issue with Terser breaking valid code:
// https://github.com/facebook/create-react-app/issues/5250
// Pending further investigation:
// https://github.com/terser-js/terser/issues/120
inline: 2,
},
mangle: {
safari10: true,
},
// Added for profiling in devtools
keep_classnames: isEnvProductionProfile,
keep_fnames: isEnvProductionProfile,
output: {
ecma: 5,
comments: false,
// Turned on because emoji and regex is not minified properly using default
// https://github.com/facebook/create-react-app/issues/2488
ascii_only: true,
},
},
sourceMap: shouldUseSourceMap,
}),
// This is only used in production mode
new OptimizeCSSAssetsPlugin({
cssProcessorOptions: {
parser: safePostCssParser,
map: shouldUseSourceMap
? {
// `inline: false` forces the sourcemap to be output into a
// separate file
inline: false,
// `annotation: true` appends the sourceMappingURL to the end of
// the css file, helping the browser find the sourcemap
annotation: true,
}
: false,
},
cssProcessorPluginOptions: {
preset: ['default', { minifyFontValues: { removeQuotes: false } }],
},
}),
],
// Automatically split vendor and commons
// https://twitter.com/wSokra/status/969633336732905474
// https://medium.com/webpack/webpack-4-code-splitting-chunk-graph-and-the-splitchunks-optimization-be739a861366
splitChunks: {
chunks: 'all',
name: false,
},
// Keep the runtime chunk separated to enable long term caching
// https://twitter.com/wSokra/status/969679223278505985
// https://github.com/facebook/create-react-app/issues/5358
runtimeChunk: {
name: entrypoint => `runtime-${entrypoint.name}`,
},
},
resolve: {
// This allows you to set a fallback for where webpack should look for modules.
// We placed these paths second because we want `node_modules` to "win"
// if there are any conflicts. This matches Node resolution mechanism.
// https://github.com/facebook/create-react-app/issues/253
modules: ['node_modules', paths.appNodeModules].concat(
modules.additionalModulePaths || []
),
// These are the reasonable defaults supported by the Node ecosystem.
// We also include JSX as a common component filename extension to support
// some tools, although we do not recommend using it, see:
// https://github.com/facebook/create-react-app/issues/290
// `web` extension prefixes have been added for better support
// for React Native Web.
extensions: paths.moduleFileExtensions
.map(ext => `.${ext}`)
.filter(ext => useTypeScript || !ext.includes('ts')),
alias: {
// Support React Native Web
// https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
'react-native': 'react-native-web',
// Allows for better profiling with ReactDevTools
...(isEnvProductionProfile && {
'react-dom$': 'react-dom/profiling',
'scheduler/tracing': 'scheduler/tracing-profiling',
}),
...(modules.webpackAliases || {}),
},
plugins: [
// Adds support for installing with Plug'n'Play, leading to faster installs and adding
// guards against forgotten dependencies and such.
PnpWebpackPlugin,
// Prevents users from importing files from outside of src/ (or node_modules/).
// This often causes confusion because we only process files within src/ with babel.
// To fix this, we prevent you from importing files out of src/ -- if you'd like to,
// please link the files into your node_modules/ and let module-resolution kick in.
// Make sure your source files are compiled, as they will not be processed in any way.
new ModuleScopePlugin(paths.appSrc, [
paths.appPackageJson,
reactRefreshOverlayEntry,
]),
],
},
resolveLoader: {
plugins: [
// Also related to Plug'n'Play, but this time it tells webpack to load its loaders
// from the current package.
PnpWebpackPlugin.moduleLoader(module),
],
},
module: {
strictExportPresence: true,
rules: [
// Disable require.ensure as it's not a standard language feature.
{ parser: { requireEnsure: false } },
{
// "oneOf" will traverse all following loaders until one will
// match the requirements. When no loader matches it will fall
// back to the "file" loader at the end of the loader list.
oneOf: [
// TODO: Merge this config once `image/avif` is in the mime-db
// https://github.com/jshttp/mime-db
{
test: [/\.avif$/],
loader: require.resolve('url-loader'),
options: {
limit: imageInlineSizeLimit,
mimetype: 'image/avif',
name: 'static/media/[name].[hash:8].[ext]',
},
},
// "url" loader works like "file" loader except that it embeds assets
// smaller than specified limit in bytes as data URLs to avoid requests.
// A missing `test` is equivalent to a match.
{
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
loader: require.resolve('url-loader'),
options: {
limit: imageInlineSizeLimit,
name: 'static/media/[name].[hash:8].[ext]',
},
},
// Process application JS with Babel.
// The preset includes JSX, Flow, TypeScript, and some ESnext features.
{
test: /\.(js|mjs|jsx|ts|tsx)$/,
include: paths.appSrc,
loader: require.resolve('babel-loader'),
options: {
customize: require.resolve(
'babel-preset-react-app/webpack-overrides'
),
presets: [
[
require.resolve('babel-preset-react-app'),
{
runtime: hasJsxRuntime ? 'automatic' : 'classic',
},
],
],
plugins: [
[
require.resolve('babel-plugin-named-asset-import'),
{
loaderMap: {
svg: {
ReactComponent:
'@svgr/webpack?-svgo,+titleProp,+ref![path]',
},
},
},
],
isEnvDevelopment &&
shouldUseReactRefresh &&
require.resolve('react-refresh/babel'),
].filter(Boolean),
// This is a feature of `babel-loader` for webpack (not Babel itself).
// It enables caching results in ./node_modules/.cache/babel-loader/
// directory for faster rebuilds.
cacheDirectory: true,
// See #6846 for context on why cacheCompression is disabled
cacheCompression: false,
compact: isEnvProduction,
},
},
// Process any JS outside of the app with Babel.
// Unlike the application JS, we only compile the standard ES features.
{
test: /\.(js|mjs)$/,
exclude: /@babel(?:\/|\\{1,2})runtime/,
loader: require.resolve('babel-loader'),
options: {
babelrc: false,
configFile: false,
compact: false,
presets: [
[
require.resolve('babel-preset-react-app/dependencies'),
{ helpers: true },
],
],
cacheDirectory: true,
// See #6846 for context on why cacheCompression is disabled
cacheCompression: false,
// Babel sourcemaps are needed for debugging into node_modules
// code. Without the options below, debuggers like VSCode
// show incorrect code and set breakpoints on the wrong lines.
sourceMaps: shouldUseSourceMap,
inputSourceMap: shouldUseSourceMap,
},
},
// "postcss" loader applies autoprefixer to our CSS.
// "css" loader resolves paths in CSS and adds assets as dependencies.
// "style" loader turns CSS into JS modules that inject <style> tags.
// In production, we use MiniCSSExtractPlugin to extract that CSS
// to a file, but in development "style" loader enables hot editing
// of CSS.
// By default we support CSS Modules with the extension .module.css
{
test: cssRegex,
exclude: cssModuleRegex,
use: getStyleLoaders({
importLoaders: 1,
sourceMap: isEnvProduction
? shouldUseSourceMap
: isEnvDevelopment,
}),
// Don't consider CSS imports dead code even if the
// containing package claims to have no side effects.
// Remove this when webpack adds a warning or an error for this.
// See https://github.com/webpack/webpack/issues/6571
sideEffects: true,
},
// Adds support for CSS Modules (https://github.com/css-modules/css-modules)
// using the extension .module.css
{
test: cssModuleRegex,
use: getStyleLoaders({
importLoaders: 1,
sourceMap: isEnvProduction
? shouldUseSourceMap
: isEnvDevelopment,
modules: {
getLocalIdent: getCSSModuleLocalIdent,
},
}),
},
// Opt-in support for SASS (using .scss or .sass extensions).
// By default we support SASS Modules with the
// extensions .module.scss or .module.sass
{
test: sassRegex,
exclude: sassModuleRegex,
use: getStyleLoaders(
{
importLoaders: 3,
sourceMap: isEnvProduction
? shouldUseSourceMap
: isEnvDevelopment,
},
'sass-loader'
),
// Don't consider CSS imports dead code even if the
// containing package claims to have no side effects.
// Remove this when webpack adds a warning or an error for this.
// See https://github.com/webpack/webpack/issues/6571
sideEffects: true,
},
// Adds support for CSS Modules, but using SASS
// using the extension .module.scss or .module.sass
{
test: sassModuleRegex,
use: getStyleLoaders(
{
importLoaders: 3,
sourceMap: isEnvProduction
? shouldUseSourceMap
: isEnvDevelopment,
modules: {
getLocalIdent: getCSSModuleLocalIdent,
},
},
'sass-loader'
),
},
// "file" loader makes sure those assets get served by WebpackDevServer.
// When you `import` an asset, you get its (virtual) filename.
// In production, they would get copied to the `build` folder.
// This loader doesn't use a "test" so it will catch all modules
// that fall through the other loaders.
{
loader: require.resolve('file-loader'),
// Exclude `js` files to keep "css" loader working as it injects
// its runtime that would otherwise be processed through "file" loader.
// Also exclude `html` and `json` extensions so they get processed
// by webpacks internal loaders.
exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
options: {
name: 'static/media/[name].[hash:8].[ext]',
},
},
// ** STOP ** Are you adding a new loader?
// Make sure to add the new loader(s) before the "file" loader.
],
},
],
},
plugins: [
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin(
Object.assign(
{},
{
inject: true,
template: paths.appHtml,
},
isEnvProduction
? {
minify: {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
},
}
: undefined
)
),
// Inlines the webpack runtime script. This script is too small to warrant
// a network request.
// https://github.com/facebook/create-react-app/issues/5358
isEnvProduction &&
shouldInlineRuntimeChunk &&
new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime-.+[.]js/]),
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
// <link rel="icon" href="%PUBLIC_URL%/favicon.ico">
// It will be an empty string unless you specify "homepage"
// in `package.json`, in which case it will be the pathname of that URL.
new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw),
// This gives some necessary context to module not found errors, such as
// the requesting resource.
new ModuleNotFoundPlugin(paths.appPath),
// Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`.
// It is absolutely essential that NODE_ENV is set to production
// during a production build.
// Otherwise React will be compiled in the very slow development mode.
new webpack.DefinePlugin(env.stringified),
// This is necessary to emit hot updates (CSS and Fast Refresh):
isEnvDevelopment && new webpack.HotModuleReplacementPlugin(),
// Experimental hot reloading for React .
// https://github.com/facebook/react/tree/master/packages/react-refresh
isEnvDevelopment &&
shouldUseReactRefresh &&
new ReactRefreshWebpackPlugin({
overlay: {
entry: webpackDevClientEntry,
// The expected exports are slightly different from what the overlay exports,
// so an interop is included here to enable feedback on module-level errors.
module: reactRefreshOverlayEntry,
// Since we ship a custom dev client and overlay integration,
// the bundled socket handling logic can be eliminated.
sockIntegration: false,
},
}),
// Watcher doesn't work well if you mistype casing in a path so we use
// a plugin that prints an error when you attempt to do this.
// See https://github.com/facebook/create-react-app/issues/240
isEnvDevelopment && new CaseSensitivePathsPlugin(),
// If you require a missing module and then `npm install` it, you still have
// to restart the development server for webpack to discover it. This plugin
// makes the discovery automatic so you don't have to restart.
// See https://github.com/facebook/create-react-app/issues/186
isEnvDevelopment &&
new WatchMissingNodeModulesPlugin(paths.appNodeModules),
isEnvProduction &&
new MiniCssExtractPlugin({
// Options similar to the same options in webpackOptions.output
// both options are optional
filename: 'static/css/[name].[contenthash:8].css',
chunkFilename: 'static/css/[name].[contenthash:8].chunk.css',
}),
// Generate an asset manifest file with the following content:
// - "files" key: Mapping of all asset filenames to their corresponding
// output file so that tools can pick it up without having to parse
// `index.html`
// - "entrypoints" key: Array of files which are included in `index.html`,
// can be used to reconstruct the HTML if necessary
new ManifestPlugin({
fileName: 'asset-manifest.json',
publicPath: paths.publicUrlOrPath,
generate: (seed, files, entrypoints) => {
const manifestFiles = files.reduce((manifest, file) => {
manifest[file.name] = file.path;
return manifest;
}, seed);
const entrypointFiles = entrypoints.main.filter(
fileName => !fileName.endsWith('.map')
);
return {
files: manifestFiles,
entrypoints: entrypointFiles,
};
},
}),
// Moment.js is an extremely popular library that bundles large locale files
// by default due to how webpack interprets its code. This is a practical
// solution that requires the user to opt into importing specific locales.
// https://github.com/jmblog/how-to-optimize-momentjs-with-webpack
// You can remove this if you don't use Moment.js:
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
// Generate a service worker script that will precache, and keep up to date,
// the HTML & assets that are part of the webpack build.
isEnvProduction &&
fs.existsSync(swSrc) &&
new WorkboxWebpackPlugin.InjectManifest({
swSrc,
dontCacheBustURLsMatching: /\.[0-9a-f]{8}\./,
exclude: [/\.map$/, /asset-manifest\.json$/, /LICENSE/],
// Bump up the default maximum size (2mb) that's precached,
// to make lazy-loading failure scenarios less likely.
// See https://github.com/cra-template/pwa/issues/13#issuecomment-722667270
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
}),
// TypeScript type checking
useTypeScript &&
new ForkTsCheckerWebpackPlugin({
typescript: resolve.sync('typescript', {
basedir: paths.appNodeModules,
}),
async: isEnvDevelopment,
checkSyntacticErrors: true,
resolveModuleNameModule: process.versions.pnp
? `${__dirname}/pnpTs.js`
: undefined,
resolveTypeReferenceDirectiveModule: process.versions.pnp
? `${__dirname}/pnpTs.js`
: undefined,
tsconfig: paths.appTsConfig,
reportFiles: [
// This one is specifically to match during CI tests,
// as micromatch doesn't match
// '../cra-template-typescript/template/src/App.tsx'
// otherwise.
'../**/src/**/*.{ts,tsx}',
'**/src/**/*.{ts,tsx}',
'!**/src/**/__tests__/**',
'!**/src/**/?(*.)(spec|test).*',
'!**/src/setupProxy.*',
'!**/src/setupTests.*',
],
silent: true,
// The formatter is invoked directly in WebpackDevServerUtils during development
formatter: isEnvProduction ? typescriptFormatter : undefined,
}),
new ESLintPlugin({
// Plugin options
extensions: ['js', 'mjs', 'jsx', 'ts', 'tsx'],
formatter: require.resolve('react-dev-utils/eslintFormatter'),
eslintPath: require.resolve('eslint'),
context: paths.appSrc,
cache: true,
// ESLint class options
cwd: paths.appPath,
resolvePluginsRelativeTo: __dirname,
baseConfig: {
extends: [require.resolve('eslint-config-react-app/base')],
rules: {
...(!hasJsxRuntime && {
'react/react-in-jsx-scope': 'error',
}),
},
},
}),
].filter(Boolean),
// Some libraries import Node modules but don't use them in the browser.
// Tell webpack to provide empty mocks for them so importing them works.
node: {
module: 'empty',
dgram: 'empty',
dns: 'mock',
fs: 'empty',
http2: 'empty',
net: 'empty',
tls: 'empty',
child_process: 'empty',
},
// Turn off performance processing because we utilize
// our own hints via the FileSizeReporter
performance: false,
};
};

View File

@@ -0,0 +1,130 @@
'use strict';
const fs = require('fs');
const errorOverlayMiddleware = require('react-dev-utils/errorOverlayMiddleware');
const evalSourceMapMiddleware = require('react-dev-utils/evalSourceMapMiddleware');
const noopServiceWorkerMiddleware = require('react-dev-utils/noopServiceWorkerMiddleware');
const ignoredFiles = require('react-dev-utils/ignoredFiles');
const redirectServedPath = require('react-dev-utils/redirectServedPathMiddleware');
const paths = require('./paths');
const getHttpsConfig = require('./getHttpsConfig');
const host = process.env.HOST || '0.0.0.0';
const sockHost = process.env.WDS_SOCKET_HOST;
const sockPath = process.env.WDS_SOCKET_PATH; // default: '/sockjs-node'
const sockPort = process.env.WDS_SOCKET_PORT;
module.exports = function (proxy, allowedHost) {
return {
// WebpackDevServer 2.4.3 introduced a security fix that prevents remote
// websites from potentially accessing local content through DNS rebinding:
// https://github.com/webpack/webpack-dev-server/issues/887
// https://medium.com/webpack/webpack-dev-server-middleware-security-issues-1489d950874a
// However, it made several existing use cases such as development in cloud
// environment or subdomains in development significantly more complicated:
// https://github.com/facebook/create-react-app/issues/2271
// https://github.com/facebook/create-react-app/issues/2233
// While we're investigating better solutions, for now we will take a
// compromise. Since our WDS configuration only serves files in the `public`
// folder we won't consider accessing them a vulnerability. However, if you
// use the `proxy` feature, it gets more dangerous because it can expose
// remote code execution vulnerabilities in backends like Django and Rails.
// So we will disable the host check normally, but enable it if you have
// specified the `proxy` setting. Finally, we let you override it if you
// really know what you're doing with a special environment variable.
disableHostCheck:
!proxy || process.env.DANGEROUSLY_DISABLE_HOST_CHECK === 'true',
// Enable gzip compression of generated files.
compress: true,
// Silence WebpackDevServer's own logs since they're generally not useful.
// It will still show compile warnings and errors with this setting.
clientLogLevel: 'none',
// By default WebpackDevServer serves physical files from current directory
// in addition to all the virtual build products that it serves from memory.
// This is confusing because those files wont automatically be available in
// production build folder unless we copy them. However, copying the whole
// project directory is dangerous because we may expose sensitive files.
// Instead, we establish a convention that only files in `public` directory
// get served. Our build script will copy `public` into the `build` folder.
// In `index.html`, you can get URL of `public` folder with %PUBLIC_URL%:
// <link rel="icon" href="%PUBLIC_URL%/favicon.ico">
// In JavaScript code, you can access it with `process.env.PUBLIC_URL`.
// Note that we only recommend to use `public` folder as an escape hatch
// for files like `favicon.ico`, `manifest.json`, and libraries that are
// for some reason broken when imported through webpack. If you just want to
// use an image, put it in `src` and `import` it from JavaScript instead.
contentBase: paths.appPublic,
contentBasePublicPath: paths.publicUrlOrPath,
// By default files from `contentBase` will not trigger a page reload.
watchContentBase: true,
// Enable hot reloading server. It will provide WDS_SOCKET_PATH endpoint
// for the WebpackDevServer client so it can learn when the files were
// updated. The WebpackDevServer client is included as an entry point
// in the webpack development configuration. Note that only changes
// to CSS are currently hot reloaded. JS changes will refresh the browser.
hot: true,
// Use 'ws' instead of 'sockjs-node' on server since we're using native
// websockets in `webpackHotDevClient`.
transportMode: 'ws',
// Prevent a WS client from getting injected as we're already including
// `webpackHotDevClient`.
injectClient: false,
// Enable custom sockjs pathname for websocket connection to hot reloading server.
// Enable custom sockjs hostname, pathname and port for websocket connection
// to hot reloading server.
sockHost,
sockPath,
sockPort,
// It is important to tell WebpackDevServer to use the same "publicPath" path as
// we specified in the webpack config. When homepage is '.', default to serving
// from the root.
// remove last slash so user can land on `/test` instead of `/test/`
publicPath: paths.publicUrlOrPath.slice(0, -1),
// WebpackDevServer is noisy by default so we emit custom message instead
// by listening to the compiler events with `compiler.hooks[...].tap` calls above.
quiet: true,
// Reportedly, this avoids CPU overload on some systems.
// https://github.com/facebook/create-react-app/issues/293
// src/node_modules is not ignored to support absolute imports
// https://github.com/facebook/create-react-app/issues/1065
watchOptions: {
ignored: ignoredFiles(paths.appSrc),
},
https: getHttpsConfig(),
host,
overlay: false,
historyApiFallback: {
// Paths with dots should still use the history fallback.
// See https://github.com/facebook/create-react-app/issues/387.
disableDotRule: true,
index: paths.publicUrlOrPath,
},
public: allowedHost,
// `proxy` is run between `before` and `after` `webpack-dev-server` hooks
proxy,
before(app, server) {
// Keep `evalSourceMapMiddleware` and `errorOverlayMiddleware`
// middlewares before `redirectServedPath` otherwise will not have any effect
// This lets us fetch source contents from webpack for the error overlay
app.use(evalSourceMapMiddleware(server));
// This lets us open files from the runtime error overlay.
app.use(errorOverlayMiddleware());
if (fs.existsSync(paths.proxySetup)) {
// This registers user provided middleware for proxy reasons
require(paths.proxySetup)(app);
}
},
after(app) {
// Redirect to `PUBLIC_URL` or `homepage` from `package.json` if url not match
app.use(redirectServedPath(paths.publicUrlOrPath));
// This service worker file is effectively a 'no-op' that will reset any
// previous service worker registered for the same host:port combination.
// We do this in development to avoid hitting the production cache if
// it used the same host and port.
// https://github.com/facebook/create-react-app/issues/2272#issuecomment-302832432
app.use(noopServiceWorkerMiddleware(paths.publicUrlOrPath));
},
};
};

View File

@@ -1,29 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>TimeMap - Forensic Architecture</title>
<link rel="stylesheet" href="https://api.mapbox.com/mapbox.js/v3.1.1/mapbox.css">
<link href="https://fonts.googleapis.com/css?family=Lato" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body>
<style>
@media (hover: none) {
#id { display: none; }
#nodisplay { display: block; }
}
@media (hover: hover) {
#nodisplay {display: none; }
}
</style>
<div class="page">
<div class="page">
<div id="explore-app"></div>
</div>
<div id="nodisplay">
This platform is unsuitable for mobile. Please revisit on a desktop.
</div>
</div>
</body>
</html>

View File

@@ -5,59 +5,195 @@
"homepage": "",
"private": true,
"scripts": {
"dev": "webpack-dev-server --content-base static --mode development",
"dev:wsl": "npm run dev -- --host 0.0.0.0",
"build": "NODE_ENV=production webpack --mode production",
"test": "ava --verbose",
"dev": "node scripts/start.js",
"build": "NODE_ENV=production node scripts/build.js",
"dev:wsl": "HOST=0.0.0.0 npm run dev",
"test": "node scripts/test.js --silent",
"test:ava": "NODE_ENV=production ava --verbose",
"test-watch": "ava --watch",
"lint": "standard \"src/**/*.js\" \"src/**/*.jsx\" \"test/**/*.js\"",
"lint:fix": "npm run lint -- --fix"
"lint": "echo 'This is a placeholder for Travis'",
"lint:fix": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\""
},
"dependencies": {
"@forensic-architecture/design-system": "0.6.1",
"@babel/core": "7.12.3",
"@forensic-architecture/design-system": "0.6.2",
"@pmmmwh/react-refresh-webpack-plugin": "0.4.2",
"@svgr/webpack": "5.4.0",
"@testing-library/jest-dom": "^5.11.6",
"@testing-library/react": "^11.2.2",
"@typescript-eslint/eslint-plugin": "^4.5.0",
"@typescript-eslint/parser": "^4.5.0",
"babel-eslint": "^10.1.0",
"babel-jest": "^26.6.0",
"babel-loader": "8.1.0",
"babel-plugin-named-asset-import": "^0.3.7",
"babel-preset-react-app": "^10.0.0",
"bfj": "^7.0.2",
"camelcase": "^6.1.0",
"case-sensitive-paths-webpack-plugin": "2.3.0",
"css-loader": "4.3.0",
"d3": "^5.7.0",
"dotenv": "8.2.0",
"dotenv-expand": "5.1.0",
"eslint": "^7.11.0",
"eslint-config-react-app": "^6.0.0",
"eslint-plugin-flowtype": "^5.2.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jest": "^24.1.0",
"eslint-plugin-jsx-a11y": "^6.3.1",
"eslint-plugin-react": "^7.21.5",
"eslint-plugin-react-hooks": "^4.2.0",
"eslint-plugin-testing-library": "^3.9.2",
"eslint-webpack-plugin": "^2.1.0",
"file-loader": "6.1.1",
"fs-extra": "^9.0.1",
"html-webpack-plugin": "4.5.0",
"husky": "^4.3.5",
"identity-obj-proxy": "3.0.0",
"jest": "26.6.0",
"jest-circus": "26.6.0",
"jest-resolve": "26.6.0",
"jest-watch-typeahead": "0.6.1",
"joi": "^14.0.1",
"leaflet": "^1.0.3",
"lint-staged": "^10.5.3",
"marked": "^0.7.0",
"mini-css-extract-plugin": "0.11.3",
"moment": "^2.26.0",
"object-hash": "^1.3.0",
"optimize-css-assets-webpack-plugin": "5.0.4",
"pnp-webpack-plugin": "1.6.4",
"postcss-flexbugs-fixes": "4.2.1",
"postcss-loader": "3.0.0",
"postcss-normalize": "8.0.1",
"postcss-preset-env": "6.7.0",
"postcss-safe-parser": "5.0.2",
"prettier": "^2.2.1",
"prompts": "2.4.0",
"ramda": "^0.26.1",
"react": "^16.13.1",
"react-app-polyfill": "^2.0.0",
"react-dev-utils": "^11.0.1",
"react-device-detect": "^1.6.2",
"react-dom": "^16.13.1",
"react-image": "^1.5.1",
"react-portal": "^4.2.0",
"react-redux": "^5.0.4",
"react-refresh": "^0.8.3",
"react-tabs": "3.0.0",
"redux": "^3.6.0",
"redux-thunk": "^2.2.0",
"reselect": "^3.0.1",
"resolve": "1.18.1",
"resolve-url-loader": "^3.1.2",
"sass-loader": "8.0.2",
"semver": "7.3.2",
"style-loader": "1.3.0",
"supercluster": "^7.1.0",
"video-react": "^0.13.1"
"terser-webpack-plugin": "4.2.3",
"ts-pnp": "1.2.0",
"url-loader": "4.1.1",
"video-react": "^0.13.1",
"webpack": "4.44.2",
"webpack-dev-server": "3.11.0",
"webpack-manifest-plugin": "2.2.0",
"workbox-webpack-plugin": "5.1.4"
},
"devDependencies": {
"@babel/core": "^7.1.2",
"@babel/preset-env": "^7.1.0",
"@babel/preset-react": "^7.0.0",
"ava": "1.0.0-beta.8",
"babel-loader": "^8.0.4",
"css-loader": "^1.0.0",
"file-loader": "^2.0.0",
"html-webpack-plugin": "^3.2.0",
"mini-css-extract-plugin": "^0.4.4",
"mocha": "^5.2.0",
"node-sass": "4.13.1",
"redux-devtools": "^3.4.0",
"sass-loader": "^7.1.0",
"standard": "^12.0.1",
"style-loader": "^0.23.1",
"webpack": "^4.20.2",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.11"
"redux-devtools": "^3.4.0"
},
"lint-staged": {
"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}": [
"prettier --write"
],
"test/**/*.{js,jsx,ts,tsx,json,css,scss,md}": [
"prettier --write"
]
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"ava": {
"files": [
"test/**/*.js"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"jest": {
"roots": [
"<rootDir>/src"
],
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}",
"!src/**/*.d.ts"
],
"setupFiles": [
"react-app-polyfill/jsdom"
],
"setupFilesAfterEnv": [
"<rootDir>/src/setupTests.js",
"<rootDir>/config/jest/setEnvVars.js"
],
"testMatch": [
"<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}",
"<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}"
],
"testEnvironment": "jsdom",
"testRunner": "<rootDir>/node_modules/jest-circus/runner.js",
"transform": {
"^.+\\.(js|jsx|mjs|cjs|ts|tsx)$": "<rootDir>/node_modules/babel-jest",
"^.+\\.css$": "<rootDir>/config/jest/cssTransform.js",
"^(?!.*\\.(js|jsx|mjs|cjs|ts|tsx|css|json)$)": "<rootDir>/config/jest/fileTransform.js"
},
"transformIgnorePatterns": [
"[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|cjs|ts|tsx)$",
"^.+\\.module\\.(css|sass|scss)$"
],
"modulePaths": [],
"moduleNameMapper": {
"^react-native$": "react-native-web",
"^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy"
},
"moduleFileExtensions": [
"web.js",
"js",
"web.ts",
"ts",
"web.tsx",
"tsx",
"json",
"web.jsx",
"jsx",
"node"
],
"watchPlugins": [
"jest-watch-typeahead/filename",
"jest-watch-typeahead/testname"
],
"resetMocks": true
},
"babel": {
"presets": [
"react-app"
]
},
"eslintConfig": {
"extends": "react-app"
}
}

44
public/index.html Normal file
View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>TimeMap - Forensic Architecture</title>
<link
rel="stylesheet"
href="https://api.mapbox.com/mapbox.js/v3.1.1/mapbox.css"
/>
<link
href="https://fonts.googleapis.com/css?family=Lato"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
</head>
<body>
<style>
@media (hover: none) {
#id {
display: none;
}
#nodisplay {
display: block;
}
}
@media (hover: hover) {
#nodisplay {
display: none;
}
}
</style>
<div class="page">
<div class="page">
<div id="explore-app"></div>
</div>
<div id="nodisplay">
This platform is unsuitable for mobile. Please revisit on a desktop.
</div>
</div>
</body>
</html>

212
scripts/build.js Normal file
View File

@@ -0,0 +1,212 @@
'use strict';
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'production';
process.env.NODE_ENV = 'production';
// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.
process.on('unhandledRejection', err => {
throw err;
});
// Ensure environment variables are read.
require('../config/env');
const path = require('path');
const chalk = require('react-dev-utils/chalk');
const fs = require('fs-extra');
const bfj = require('bfj');
const webpack = require('webpack');
const configFactory = require('../config/webpack.config');
const paths = require('../config/paths');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
const printHostingInstructions = require('react-dev-utils/printHostingInstructions');
const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
const printBuildError = require('react-dev-utils/printBuildError');
const measureFileSizesBeforeBuild =
FileSizeReporter.measureFileSizesBeforeBuild;
const printFileSizesAfterBuild = FileSizeReporter.printFileSizesAfterBuild;
const useYarn = fs.existsSync(paths.yarnLockFile);
// These sizes are pretty large. We'll warn for bundles exceeding them.
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024;
const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024;
const isInteractive = process.stdout.isTTY;
// Warn and crash if required files are missing
if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
process.exit(1);
}
const argv = process.argv.slice(2);
const writeStatsJson = argv.indexOf('--stats') !== -1;
// Generate configuration
const config = configFactory('production');
// We require that you explicitly set browsers and do not fall back to
// browserslist defaults.
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
checkBrowsers(paths.appPath, isInteractive)
.then(() => {
// First, read the current file sizes in build directory.
// This lets us display how much they changed later.
return measureFileSizesBeforeBuild(paths.appBuild);
})
.then(previousFileSizes => {
// Remove all content but keep the directory so that
// if you're in it, you don't end up in Trash
fs.emptyDirSync(paths.appBuild);
// Merge with the public folder
copyPublicFolder();
// Start the webpack build
return build(previousFileSizes);
})
.then(
({ stats, previousFileSizes, warnings }) => {
if (warnings.length) {
console.log(chalk.yellow('Compiled with warnings.\n'));
console.log(warnings.join('\n\n'));
console.log(
'\nSearch for the ' +
chalk.underline(chalk.yellow('keywords')) +
' to learn more about each warning.'
);
console.log(
'To ignore, add ' +
chalk.cyan('// eslint-disable-next-line') +
' to the line before.\n'
);
} else {
console.log(chalk.green('Compiled successfully.\n'));
}
console.log('File sizes after gzip:\n');
printFileSizesAfterBuild(
stats,
previousFileSizes,
paths.appBuild,
WARN_AFTER_BUNDLE_GZIP_SIZE,
WARN_AFTER_CHUNK_GZIP_SIZE
);
console.log();
const appPackage = require(paths.appPackageJson);
const publicUrl = paths.publicUrlOrPath;
const publicPath = config.output.publicPath;
const buildFolder = path.relative(process.cwd(), paths.appBuild);
printHostingInstructions(
appPackage,
publicUrl,
publicPath,
buildFolder,
useYarn
);
},
err => {
const tscCompileOnError = process.env.TSC_COMPILE_ON_ERROR === 'true';
if (tscCompileOnError) {
console.log(
chalk.yellow(
'Compiled with the following type errors (you may want to check these before deploying your app):\n'
)
);
printBuildError(err);
} else {
console.log(chalk.red('Failed to compile.\n'));
printBuildError(err);
process.exit(1);
}
}
)
.catch(err => {
if (err && err.message) {
console.log(err.message);
}
process.exit(1);
});
// Create the production build and print the deployment instructions.
function build(previousFileSizes) {
console.log('Creating an optimized production build...');
const compiler = webpack(config);
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
let messages;
if (err) {
if (!err.message) {
return reject(err);
}
let errMessage = err.message;
// Add additional information for postcss errors
if (Object.prototype.hasOwnProperty.call(err, 'postcssNode')) {
errMessage +=
'\nCompileError: Begins at CSS selector ' +
err['postcssNode'].selector;
}
messages = formatWebpackMessages({
errors: [errMessage],
warnings: [],
});
} else {
messages = formatWebpackMessages(
stats.toJson({ all: false, warnings: true, errors: true })
);
}
if (messages.errors.length) {
// Only keep the first error. Others are often indicative
// of the same problem, but confuse the reader with noise.
if (messages.errors.length > 1) {
messages.errors.length = 1;
}
return reject(new Error(messages.errors.join('\n\n')));
}
if (
process.env.CI &&
(typeof process.env.CI !== 'string' ||
process.env.CI.toLowerCase() !== 'false') &&
messages.warnings.length
) {
console.log(
chalk.yellow(
'\nTreating warnings as errors because process.env.CI = true.\n' +
'Most CI servers set it automatically.\n'
)
);
return reject(new Error(messages.warnings.join('\n\n')));
}
const resolveArgs = {
stats,
previousFileSizes,
warnings: messages.warnings,
};
if (writeStatsJson) {
return bfj
.write(paths.appBuild + '/bundle-stats.json', stats.toJson())
.then(() => resolve(resolveArgs))
.catch(error => reject(new Error(error)));
}
return resolve(resolveArgs);
});
});
}
function copyPublicFolder() {
fs.copySync(paths.appPublic, paths.appBuild, {
dereference: true,
filter: file => file !== paths.appHtml,
});
}

166
scripts/start.js Normal file
View File

@@ -0,0 +1,166 @@
'use strict';
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'development';
process.env.NODE_ENV = 'development';
// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.
process.on('unhandledRejection', err => {
throw err;
});
// Ensure environment variables are read.
require('../config/env');
const fs = require('fs');
const chalk = require('react-dev-utils/chalk');
const webpack = require('webpack');
const WebpackDevServer = require('webpack-dev-server');
const clearConsole = require('react-dev-utils/clearConsole');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const {
choosePort,
createCompiler,
prepareProxy,
prepareUrls,
} = require('react-dev-utils/WebpackDevServerUtils');
const openBrowser = require('react-dev-utils/openBrowser');
const semver = require('semver');
const paths = require('../config/paths');
const configFactory = require('../config/webpack.config');
const createDevServerConfig = require('../config/webpackDevServer.config');
const getClientEnvironment = require('../config/env');
const react = require(require.resolve('react', { paths: [paths.appPath] }));
const env = getClientEnvironment(paths.publicUrlOrPath.slice(0, -1));
const useYarn = fs.existsSync(paths.yarnLockFile);
const isInteractive = process.stdout.isTTY;
// Warn and crash if required files are missing
if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
process.exit(1);
}
// Tools like Cloud9 rely on this.
const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 8080;
const HOST = process.env.HOST || '0.0.0.0';
if (process.env.HOST) {
console.log(
chalk.cyan(
`Attempting to bind to HOST environment variable: ${chalk.yellow(
chalk.bold(process.env.HOST)
)}`
)
);
console.log(
`If this was unintentional, check that you haven't mistakenly set it in your shell.`
);
console.log(
`Learn more here: ${chalk.yellow('https://cra.link/advanced-config')}`
);
console.log();
}
// We require that you explicitly set browsers and do not fall back to
// browserslist defaults.
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
checkBrowsers(paths.appPath, isInteractive)
.then(() => {
// We attempt to use the default port but if it is busy, we offer the user to
// run on a different port. `choosePort()` Promise resolves to the next free port.
return choosePort(HOST, DEFAULT_PORT);
})
.then(port => {
if (port == null) {
// We have not found a port.
return;
}
const config = configFactory('development');
const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
const appName = require(paths.appPackageJson).name;
const useTypeScript = fs.existsSync(paths.appTsConfig);
const tscCompileOnError = process.env.TSC_COMPILE_ON_ERROR === 'true';
const urls = prepareUrls(
protocol,
HOST,
port,
paths.publicUrlOrPath.slice(0, -1)
);
const devSocket = {
warnings: warnings =>
devServer.sockWrite(devServer.sockets, 'warnings', warnings),
errors: errors =>
devServer.sockWrite(devServer.sockets, 'errors', errors),
};
// Create a webpack compiler that is configured with custom messages.
const compiler = createCompiler({
appName,
config,
devSocket,
urls,
useYarn,
useTypeScript,
tscCompileOnError,
webpack,
});
// Load proxy config
const proxySetting = require(paths.appPackageJson).proxy;
const proxyConfig = prepareProxy(
proxySetting,
paths.appPublic,
paths.publicUrlOrPath
);
// Serve webpack assets generated by the compiler over a web server.
const serverConfig = createDevServerConfig(
proxyConfig,
urls.lanUrlForConfig
);
const devServer = new WebpackDevServer(compiler, serverConfig);
// Launch WebpackDevServer.
devServer.listen(port, HOST, err => {
if (err) {
return console.log(err);
}
if (isInteractive) {
clearConsole();
}
if (env.raw.FAST_REFRESH && semver.lt(react.version, '16.10.0')) {
console.log(
chalk.yellow(
`Fast Refresh requires React 16.10 or higher. You are using React ${react.version}.`
)
);
}
console.log(chalk.cyan('Starting the development server...\n'));
openBrowser(urls.localUrlForBrowser);
});
['SIGINT', 'SIGTERM'].forEach(function (sig) {
process.on(sig, function () {
devServer.close();
process.exit();
});
});
if (process.env.CI !== 'true') {
// Gracefully exit when stdin ends
process.stdin.on('end', function () {
devServer.close();
process.exit();
});
}
})
.catch(err => {
if (err && err.message) {
console.log(err.message);
}
process.exit(1);
});

53
scripts/test.js Normal file
View File

@@ -0,0 +1,53 @@
'use strict';
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'test';
process.env.NODE_ENV = 'test';
process.env.PUBLIC_URL = '';
// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.
process.on('unhandledRejection', err => {
throw err;
});
// Ensure environment variables are read.
require('../config/env');
const jest = require('jest');
const execSync = require('child_process').execSync;
let argv = process.argv.slice(2);
function isInGitRepository() {
try {
execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' });
return true;
} catch (e) {
return false;
}
}
function isInMercurialRepository() {
try {
execSync('hg --cwd . root', { stdio: 'ignore' });
return true;
} catch (e) {
return false;
}
}
// Watch unless on CI or explicitly running all tests
if (
!process.env.CI &&
argv.indexOf('--watchAll') === -1 &&
argv.indexOf('--watchAll=false') === -1
) {
// https://github.com/facebook/create-react-app/issues/5210
const hasSourceControl = isInGitRepository() || isInMercurialRepository();
argv.push(hasSourceControl ? '--watch' : '--watchAll');
}
jest.run(argv);

View File

@@ -1,30 +1,31 @@
/* global fetch, alert */
import { urlFromEnv } from '../common/utilities'
import { urlFromEnv } from "../common/utilities";
// TODO: relegate these URLs entirely to environment variables
// const CONFIG_URL = urlFromEnv('CONFIG_EXT')
const EVENT_DATA_URL = urlFromEnv('EVENTS_EXT')
const ASSOCIATIONS_URL = urlFromEnv('ASSOCIATIONS_EXT')
const SOURCES_URL = urlFromEnv('SOURCES_EXT')
const SITES_URL = urlFromEnv('SITES_EXT')
const SHAPES_URL = urlFromEnv('SHAPES_EXT')
const EVENT_DATA_URL = urlFromEnv("EVENTS_EXT");
const ASSOCIATIONS_URL = urlFromEnv("ASSOCIATIONS_EXT");
const SOURCES_URL = urlFromEnv("SOURCES_EXT");
const SITES_URL = urlFromEnv("SITES_EXT");
const SHAPES_URL = urlFromEnv("SHAPES_EXT");
const domainMsg = (domainType) => `Something went wrong fetching ${domainType}. Check the URL or try disabling them in the config file.`
const domainMsg = (domainType) =>
`Something went wrong fetching ${domainType}. Check the URL or try disabling them in the config file.`;
export function fetchDomain () {
let notifications = []
export function fetchDomain() {
const notifications = [];
function handleError (message) {
function handleError(message) {
notifications.push({
message,
type: 'error'
})
return []
type: "error",
});
return [];
}
return (dispatch, getState) => {
const features = getState().features
dispatch(toggleFetchingDomain())
const features = getState().features;
dispatch(toggleFetchingDomain());
// let configPromise = Promise.resolve([])
// if (features.USE_REMOTE_CONFIG) {
@@ -35,46 +36,55 @@ export function fetchDomain () {
// NB: EVENT_DATA_URL is a list, and so results are aggregated
const eventPromise = Promise.all(
EVENT_DATA_URL.map(url => fetch(url)
.then(response => response.json())
.catch(() => handleError('events'))
EVENT_DATA_URL.map((url) =>
fetch(url)
.then((response) => response.json())
.catch(() => handleError("events"))
)
).then(results => results.flatMap(t => t))
).then((results) => results.flatMap((t) => t));
let associationsPromise = Promise.resolve([])
let associationsPromise = Promise.resolve([]);
if (features.USE_ASSOCIATIONS) {
if (!ASSOCIATIONS_URL) {
associationsPromise = Promise.resolve(handleError('USE_ASSOCIATIONS is true, but you have not provided a ASSOCIATIONS_EXT'))
associationsPromise = Promise.resolve(
handleError(
"USE_ASSOCIATIONS is true, but you have not provided a ASSOCIATIONS_EXT"
)
);
} else {
associationsPromise = fetch(ASSOCIATIONS_URL)
.then(response => response.json())
.catch(() => handleError(domainMsg('associations')))
.then((response) => response.json())
.catch(() => handleError(domainMsg("associations")));
}
}
let sourcesPromise = Promise.resolve([])
let sourcesPromise = Promise.resolve([]);
if (features.USE_SOURCES) {
if (!SOURCES_URL) {
sourcesPromise = Promise.resolve(handleError('USE_SOURCES is true, but you have not provided a SOURCES_EXT'))
sourcesPromise = Promise.resolve(
handleError(
"USE_SOURCES is true, but you have not provided a SOURCES_EXT"
)
);
} else {
sourcesPromise = fetch(SOURCES_URL)
.then(response => response.json())
.catch(() => handleError(domainMsg('sources')))
.then((response) => response.json())
.catch(() => handleError(domainMsg("sources")));
}
}
let sitesPromise = Promise.resolve([])
let sitesPromise = Promise.resolve([]);
if (features.USE_SITES) {
sitesPromise = fetch(SITES_URL)
.then(response => response.json())
.catch(() => handleError(domainMsg('sites')))
.then((response) => response.json())
.catch(() => handleError(domainMsg("sites")));
}
let shapesPromise = Promise.resolve([])
let shapesPromise = Promise.resolve([]);
if (features.USE_SHAPES) {
shapesPromise = fetch(SHAPES_URL)
.then(response => response.json())
.catch(() => handleError(domainMsg('shapes')))
.then((response) => response.json())
.catch(() => handleError(domainMsg("shapes")));
}
return Promise.all([
@@ -82,271 +92,277 @@ export function fetchDomain () {
associationsPromise,
sourcesPromise,
sitesPromise,
shapesPromise
shapesPromise,
])
.then(response => {
.then((response) => {
const result = {
events: response[0],
associations: response[1],
sources: response[2],
sites: response[3],
shapes: response[4],
notifications
notifications,
};
if (
Object.values(result).some((resp) => resp.hasOwnProperty("error"))
) {
throw new Error(
"Some URLs returned negative. If you are in development, check the server is running"
);
}
if (Object.values(result).some(resp => resp.hasOwnProperty('error'))) {
throw new Error('Some URLs returned negative. If you are in development, check the server is running')
}
dispatch(toggleFetchingDomain())
dispatch(setInitialCategories(result.associations))
return result
dispatch(toggleFetchingDomain());
dispatch(setInitialCategories(result.associations));
return result;
})
.catch(err => {
dispatch(fetchError(err.message))
dispatch(toggleFetchingDomain())
.catch((err) => {
dispatch(fetchError(err.message));
dispatch(toggleFetchingDomain());
// TODO: handle this appropriately in React hierarchy
alert(err.message)
})
}
alert(err.message);
});
};
}
export const FETCH_ERROR = 'FETCH_ERROR'
export function fetchError (message) {
export const FETCH_ERROR = "FETCH_ERROR";
export function fetchError(message) {
return {
type: FETCH_ERROR,
message
}
message,
};
}
export const UPDATE_DOMAIN = 'UPDATE_DOMAIN'
export function updateDomain (payload) {
export const UPDATE_DOMAIN = "UPDATE_DOMAIN";
export function updateDomain(payload) {
return {
type: UPDATE_DOMAIN,
payload
}
payload,
};
}
export function fetchSource (source) {
return dispatch => {
export function fetchSource(source) {
return (dispatch) => {
if (!SOURCES_URL) {
dispatch(fetchSourceError('No source extension specified.'))
dispatch(fetchSourceError("No source extension specified."));
} else {
dispatch(toggleFetchingSources())
dispatch(toggleFetchingSources());
fetch(`${SOURCES_URL}`)
.then(response => {
.then((response) => {
if (!response.ok) {
throw new Error('No sources are available at the URL specified in the config specified.')
throw new Error(
"No sources are available at the URL specified in the config specified."
);
} else {
return response.json()
return response.json();
}
})
.catch(err => {
dispatch(fetchSourceError(err.message))
dispatch(toggleFetchingSources())
})
.catch((err) => {
dispatch(fetchSourceError(err.message));
dispatch(toggleFetchingSources());
});
}
}
};
}
export const UPDATE_HIGHLIGHTED = 'UPDATE_HIGHLIGHTED'
export function updateHighlighted (highlighted) {
export const UPDATE_HIGHLIGHTED = "UPDATE_HIGHLIGHTED";
export function updateHighlighted(highlighted) {
return {
type: UPDATE_HIGHLIGHTED,
highlighted: highlighted
}
highlighted: highlighted,
};
}
export const UPDATE_SELECTED = 'UPDATE_SELECTED'
export function updateSelected (selected) {
export const UPDATE_SELECTED = "UPDATE_SELECTED";
export function updateSelected(selected) {
return {
type: UPDATE_SELECTED,
selected: selected
}
selected: selected,
};
}
export const UPDATE_DISTRICT = 'UPDATE_DISTRICT'
export function updateDistrict (district) {
export const UPDATE_DISTRICT = "UPDATE_DISTRICT";
export function updateDistrict(district) {
return {
type: UPDATE_DISTRICT,
district
}
district,
};
}
export const CLEAR_FILTER = 'CLEAR_FILTER'
export function clearFilter (filter) {
export const CLEAR_FILTER = "CLEAR_FILTER";
export function clearFilter(filter) {
return {
type: CLEAR_FILTER,
filter
}
filter,
};
}
export const TOGGLE_ASSOCIATIONS = 'TOGGLE_ASSOCIATIONS'
export function toggleAssociations (association, value, shouldColor) {
export const TOGGLE_ASSOCIATIONS = "TOGGLE_ASSOCIATIONS";
export function toggleAssociations(association, value, shouldColor) {
return {
type: TOGGLE_ASSOCIATIONS,
association,
value,
shouldColor
}
shouldColor,
};
}
export const SET_LOADING = 'SET_LOADING'
export function setLoading () {
export const SET_LOADING = "SET_LOADING";
export function setLoading() {
return {
type: SET_LOADING
}
type: SET_LOADING,
};
}
export const SET_NOT_LOADING = 'SET_NOT_LOADING'
export function setNotLoading () {
export const SET_NOT_LOADING = "SET_NOT_LOADING";
export function setNotLoading() {
return {
type: SET_NOT_LOADING
}
type: SET_NOT_LOADING,
};
}
export const SET_INITIAL_CATEGORIES = 'SET_INITIAL_CATEGORIES'
export function setInitialCategories (values) {
export const SET_INITIAL_CATEGORIES = "SET_INITIAL_CATEGORIES";
export function setInitialCategories(values) {
return {
type: SET_INITIAL_CATEGORIES,
values
}
values,
};
}
export const UPDATE_TIMERANGE = 'UPDATE_TIMERANGE'
export function updateTimeRange (timerange) {
export const UPDATE_TIMERANGE = "UPDATE_TIMERANGE";
export function updateTimeRange(timerange) {
return {
type: UPDATE_TIMERANGE,
timerange
}
timerange,
};
}
export const UPDATE_DIMENSIONS = 'UPDATE_DIMENSIONS'
export function updateDimensions (dims) {
export const UPDATE_DIMENSIONS = "UPDATE_DIMENSIONS";
export function updateDimensions(dims) {
return {
type: UPDATE_DIMENSIONS,
dims
}
dims,
};
}
export const UPDATE_NARRATIVE = 'UPDATE_NARRATIVE'
export function updateNarrative (narrative) {
export const UPDATE_NARRATIVE = "UPDATE_NARRATIVE";
export function updateNarrative(narrative) {
return {
type: UPDATE_NARRATIVE,
narrative
}
narrative,
};
}
export const UPDATE_NARRATIVE_STEP_IDX = 'UPDATE_NARRATIVE_STEP_IDX'
export function updateNarrativeStepIdx (idx) {
export const UPDATE_NARRATIVE_STEP_IDX = "UPDATE_NARRATIVE_STEP_IDX";
export function updateNarrativeStepIdx(idx) {
return {
type: UPDATE_NARRATIVE_STEP_IDX,
idx
}
idx,
};
}
export const UPDATE_SOURCE = 'UPDATE_SOURCE'
export function updateSource (source) {
export const UPDATE_SOURCE = "UPDATE_SOURCE";
export function updateSource(source) {
return {
type: UPDATE_SOURCE,
source
}
source,
};
}
export const UPDATE_COLORING_SET = 'UPDATE_COLORING_SET'
export function updateColoringSet (coloringSet) {
export const UPDATE_COLORING_SET = "UPDATE_COLORING_SET";
export function updateColoringSet(coloringSet) {
return {
type: UPDATE_COLORING_SET,
coloringSet
}
coloringSet,
};
}
// UI
export const TOGGLE_SITES = 'TOGGLE_SITES'
export function toggleSites () {
export const TOGGLE_SITES = "TOGGLE_SITES";
export function toggleSites() {
return {
type: TOGGLE_SITES
}
type: TOGGLE_SITES,
};
}
export const TOGGLE_FETCHING_DOMAIN = 'TOGGLE_FETCHING_DOMAIN'
export function toggleFetchingDomain () {
export const TOGGLE_FETCHING_DOMAIN = "TOGGLE_FETCHING_DOMAIN";
export function toggleFetchingDomain() {
return {
type: TOGGLE_FETCHING_DOMAIN
}
type: TOGGLE_FETCHING_DOMAIN,
};
}
export const TOGGLE_FETCHING_SOURCES = 'TOGGLE_FETCHING_SOURCES'
export function toggleFetchingSources () {
export const TOGGLE_FETCHING_SOURCES = "TOGGLE_FETCHING_SOURCES";
export function toggleFetchingSources() {
return {
type: TOGGLE_FETCHING_SOURCES
}
type: TOGGLE_FETCHING_SOURCES,
};
}
export const TOGGLE_LANGUAGE = 'TOGGLE_LANGUAGE'
export function toggleLanguage (language) {
export const TOGGLE_LANGUAGE = "TOGGLE_LANGUAGE";
export function toggleLanguage(language) {
return {
type: TOGGLE_LANGUAGE,
language
}
language,
};
}
export const CLOSE_TOOLBAR = 'CLOSE_TOOLBAR'
export function closeToolbar () {
export const CLOSE_TOOLBAR = "CLOSE_TOOLBAR";
export function closeToolbar() {
return {
type: CLOSE_TOOLBAR
}
type: CLOSE_TOOLBAR,
};
}
export const TOGGLE_INFOPOPUP = 'TOGGLE_INFOPOPUP'
export function toggleInfoPopup () {
export const TOGGLE_INFOPOPUP = "TOGGLE_INFOPOPUP";
export function toggleInfoPopup() {
return {
type: TOGGLE_INFOPOPUP
}
type: TOGGLE_INFOPOPUP,
};
}
export const TOGGLE_INTROPOPUP = 'TOGGLE_INTROPOPUP'
export function toggleIntroPopup () {
export const TOGGLE_INTROPOPUP = "TOGGLE_INTROPOPUP";
export function toggleIntroPopup() {
return {
type: TOGGLE_INTROPOPUP
}
type: TOGGLE_INTROPOPUP,
};
}
export const TOGGLE_NOTIFICATIONS = 'TOGGLE_NOTIFICATIONS'
export function toggleNotifications () {
export const TOGGLE_NOTIFICATIONS = "TOGGLE_NOTIFICATIONS";
export function toggleNotifications() {
return {
type: TOGGLE_NOTIFICATIONS
}
type: TOGGLE_NOTIFICATIONS,
};
}
export const MARK_NOTIFICATIONS_READ = 'MARK_NOTIFICATIONS_READ'
export function markNotificationsRead () {
export const MARK_NOTIFICATIONS_READ = "MARK_NOTIFICATIONS_READ";
export function markNotificationsRead() {
return {
type: MARK_NOTIFICATIONS_READ
}
type: MARK_NOTIFICATIONS_READ,
};
}
export const TOGGLE_COVER = 'TOGGLE_COVER'
export function toggleCover () {
export const TOGGLE_COVER = "TOGGLE_COVER";
export function toggleCover() {
return {
type: TOGGLE_COVER
}
type: TOGGLE_COVER,
};
}
export const UPDATE_SEARCH_QUERY = 'UPDATE_SEARCH_QUERY'
export function updateSearchQuery (searchQuery) {
export const UPDATE_SEARCH_QUERY = "UPDATE_SEARCH_QUERY";
export function updateSearchQuery(searchQuery) {
return {
type: UPDATE_SEARCH_QUERY,
searchQuery
}
searchQuery,
};
}
// ERRORS
export const FETCH_SOURCE_ERROR = 'FETCH_SOURCE_ERROR'
export function fetchSourceError (msg) {
export const FETCH_SOURCE_ERROR = "FETCH_SOURCE_ERROR";
export function fetchSourceError(msg) {
return {
type: FETCH_SOURCE_ERROR,
msg
}
msg,
};
}

View File

@@ -1,37 +0,0 @@
import copy from './data/copy.json'
import { language } from './utilities'
const cardStack = copy[language].cardstack
// Sensible defaults for generating a basic card layout
// based on the example Timemap datasheet.
const basic = ({ event }) => {
return [
[
{
kind: 'date',
title: cardStack['date_title'] || 'Incident Dates',
value: event.datetime || event.date || ``
},
{
kind: 'text',
title: cardStack['location_title'] || 'Location',
value: event.location || ``
}
],
[{ kind: 'line-break', times: 0.4 }],
[
{
kind: 'text',
title: cardStack['summary_title'] || 'Summary',
value: event.description || ``,
scaleFont: 1.1
}
]
]
}
export default {
basic
}

View File

@@ -1,5 +1,5 @@
export const ASSOCIATION_MODES = {
CATEGORY: 'CATEGORY',
NARRATIVE: 'NARRATIVE',
FILTER: 'FILTER'
}
CATEGORY: "CATEGORY",
NARRATIVE: "NARRATIVE",
FILTER: "FILTER",
};

View File

@@ -3,8 +3,42 @@
"date": "%d/%m/%Y",
"time": "%-I:%M:%S %p",
"periods": ["AM", "PM"],
"days": ["domingo", "lunes", "martes", "miércoles", "jueves", "viernes", "sábado"],
"days": [
"domingo",
"lunes",
"martes",
"miércoles",
"jueves",
"viernes",
"sábado"
],
"shortDays": ["dom", "lun", "mar", "mié", "jue", "vie", "sáb"],
"months": ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"],
"shortMonths": ["ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sep", "oct", "nov", "dic"]
"months": [
"enero",
"febrero",
"marzo",
"abril",
"mayo",
"junio",
"julio",
"agosto",
"septiembre",
"octubre",
"noviembre",
"diciembre"
],
"shortMonths": [
"ene",
"feb",
"mar",
"abr",
"may",
"jun",
"jul",
"ago",
"sep",
"oct",
"nov",
"dic"
]
}

View File

@@ -1,13 +1,13 @@
export const colors = {
fa_red: '#eb443e',
yellow: '#ffd800',
black: '#000',
white: '#fff'
}
fa_red: "#eb443e",
yellow: "#ffd800",
black: "#000",
white: "#fff",
};
export default {
fallbackEventColor: colors.fa_red,
darkBackground: colors.black,
primaryHighlight: colors.fa_red,
secondaryHighlight: colors.white
}
secondaryHighlight: colors.white,
};

View File

@@ -1,22 +1,22 @@
import moment from 'moment'
import hash from 'object-hash'
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'
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 const language = process.env.store.app.language || "en-US";
export function calcDatetime (date, time) {
if (!time) time = '00:00'
const dt = moment(`${date} ${time}`, `${DATE_FMT} ${TIME_FMT}`)
return dt.toDate()
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]
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];
}
/**
@@ -26,32 +26,34 @@ export function getCoordinatesForPercent (radius, percent) {
*
* 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')
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
}, {})
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, `\\$&`)
* 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)
const regex = new RegExp(`[?&]${name}(=([^&#]*)|&|#|$)`);
const results = regex.exec(url);
if (!results) return null
if (!results[2]) return ''
if (!results) return null;
if (!results[2]) return "";
return decodeURIComponent(results[2].replace(/\+/g, ' '))
return decodeURIComponent(results[2].replace(/\+/g, " "));
}
/**
@@ -59,32 +61,35 @@ export function getParameterByName (name, url) {
* @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]
}))
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)
* 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)
* 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) {
export function trimAndEllipse(string, stringNum) {
if (string.length > stringNum) {
return string.substring(0, 120) + '...'
return string.substring(0, 120) + "...";
}
return string
return string;
}
/**
@@ -94,71 +99,72 @@ export function trimAndEllipse (string, stringNum) {
* 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
*/
export function getFilterParents(associations, filter) {
for (const a of associations) {
const { filter_paths: fp } = a;
if (a.id === filter) {
return fp.slice(0, fp.length - 1)
return fp.slice(0, fp.length - 1);
}
const filterIndex = fp.indexOf(filter)
if (filterIndex === 0) return []
if (filterIndex > 0) return fp.slice(0, filterIndex)
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')
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]
*/
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)) ]
*/
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)
*/
export function getFilterSiblings(allFilters, filterParent, filterKey) {
const isMetaFilter = !allFilters.map((filt) => filt.id).includes(filterKey);
if (isMetaFilter) {
return getMetaFilterSiblings(allFilters, filterParent, filterKey)
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
}, [])
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 = []
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)
const foundCategory = categories.find((cat) => cat.id === val);
if (foundCategory) acc.push(foundCategory);
return acc;
}, matchedCategories);
}
return matchedCategories
return matchedCategories;
}
/**
@@ -167,186 +173,201 @@ export function getEventCategories (event, categories) {
* source, call with two sets of parentheses:
* const src = insetSourceFrom(sources)(anEvent)
*/
export function insetSourceFrom (allSources) {
export function insetSourceFrom(allSources) {
return (event) => {
let sources
let sources;
if (!event.sources) {
sources = []
sources = [];
} else {
sources = event.sources.map(id => {
return allSources.hasOwnProperty(id) ? allSources[id] : null
})
sources = event.sources.map((id) => {
return allSources.hasOwnProperty(id) ? allSources[id] : null;
});
}
return {
...event,
sources
}
}
sources,
};
};
}
/**
* Debugging function: put in place of a mapStateToProps function to
* view that source modal by default
*/
export function injectSource (id) {
return state => {
export function injectSource(id) {
return (state) => {
return {
...state,
app: {
...state.app,
source: state.domain.sources[id]
}
}
}
source: state.domain.sources[id],
},
};
};
}
export function urlFromEnv (ext) {
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}`)
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
return null;
}
}
export function toggleFlagAC (flag) {
export function toggleFlagAC(flag) {
return (appState) => ({
...appState,
flags: {
...appState.flags,
[flag]: !appState.flags[flag]
}
})
[flag]: !appState.flags[flag],
},
});
}
export function selectTypeFromPath (path) {
let type
export function selectTypeFromPath(path) {
let type;
switch (true) {
case /\.(png|jpg)$/.test(path):
type = 'Image'; break
type = "Image";
break;
case /\.(mp4)$/.test(path):
type = 'Video'; break
type = "Video";
break;
case /\.(md)$/.test(path):
type = 'Text'; break
type = "Text";
break;
default:
type = 'Unknown'; break
type = "Unknown";
break;
}
return { type, path }
return { type, path };
}
export function typeForPath (path) {
let type
path = path.trim()
export function typeForPath(path) {
let type;
path = path.trim();
switch (true) {
case /\.((png)|(jpg)|(jpeg))$/.test(path):
type = 'Image'; break
type = "Image";
break;
case /\.(mp4)$/.test(path):
type = 'Video'; break
type = "Video";
break;
case /\.(md)$/.test(path):
type = 'Text'; break
type = "Text";
break;
case /\.(pdf)$/.test(path):
type = 'Document'; break
type = "Document";
break;
default:
type = 'Unknown'; break
type = "Unknown";
break;
}
return type
return type;
}
export function selectTypeFromPathWithPoster (path, poster) {
return { type: typeForPath(path), path, poster }
export function selectTypeFromPathWithPoster(path, poster) {
return { type: typeForPath(path), path, poster };
}
export function isIdentical (obj1, obj2) {
return hash(obj1) === hash(obj2)
export function isIdentical(obj1, obj2) {
return hash(obj1) === hash(obj2);
}
export function calcOpacity (num) {
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)))
const base = num >= 1 ? 0.9 : 0;
return base + Math.min(0.5, 0.08 * (num - 1));
}
export function calcClusterOpacity (pointCount, totalPoints) {
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)
return Math.min(0.85, 0.08 + (pointCount / totalPoints) * 50);
}
export function calcClusterSize (pointCount, totalPoints) {
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)
const maxSize = totalPoints > 60 ? 40 : 20;
return Math.min(maxSize, 10 + (pointCount / totalPoints) * 150);
}
export function calculateTotalClusterPoints (clusters) {
export function calculateTotalClusterPoints(clusters) {
return clusters.reduce((total, cl) => {
if (cl && cl.properties && cl.properties.cluster) {
total += cl.properties.point_count
total += cl.properties.point_count;
}
return total
}, 0)
return total;
}, 0);
}
export function isLatitude (lat) {
return !!lat && isFinite(lat) && Math.abs(lat) <= 90
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 isLongitude(lng) {
return !!lng && isFinite(lng) && Math.abs(lng) <= 180;
}
export function mapClustersToLocations (clusters, locations) {
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
}, [])
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 = {}
*/
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
for (const filter of value) {
associationMap[filter] = idx;
}
}
const associationCounts = new Array(coloringSet.length)
associationCounts.fill(0)
const associationCounts = new Array(coloringSet.length);
associationCounts.fill(0);
let totalAssociations = 0
let totalAssociations = 0;
set.forEach(item => {
let innerSet = 'events' in item ? item.events : item
set.forEach((item) => {
let innerSet = "events" in item ? item.events : item;
if (!Array.isArray(innerSet)) innerSet = [innerSet]
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
})
})
})
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]
if (totalAssociations === 0) return [1];
return associationCounts.map(count => count / totalAssociations)
return associationCounts.map((count) => count / totalAssociations);
}
/**
@@ -354,73 +375,76 @@ export function calculateColorPercentages (set, coloringSet) {
*
* Example coloringSet = [['Chemical', 'Tear Gas'], ['Procedural', 'Destruction of property']]
*/
export function getFilterIdxFromColorSet (filter, coloringSet) {
let filterIdx = -1
export function getFilterIdxFromColorSet(filter, coloringSet) {
let filterIdx = -1;
coloringSet.map((set, idx) => {
const foundIdx = set.indexOf(filter)
if (foundIdx !== -1) filterIdx = idx
})
return filterIdx
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
})
}
return a < b ? a : b;
});
};
export const dateMax = function () {
return Array.prototype.slice.call(arguments).reduce(function (a, b) {
return a > b ? 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
* 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) {
var k = (n + m) >> 1
var cmp = compareFn(el, ar[k])
const k = (n + m) >> 1;
const cmp = compareFn(el, ar[k]);
if (cmp > 0) {
m = k + 1
m = k + 1;
} else if (cmp < 0) {
n = k - 1
n = k - 1;
} else {
return k
return k;
}
}
return -m - 1
return -m - 1;
}
export function makeNiceDate (datetime) {
if (datetime === null) return null
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 dateTimeFormat = new Intl.DateTimeFormat(language, {
year: "numeric",
month: "long",
day: "2-digit",
});
const [
{ value: month },,
{ value: day },,
{ value: year }
] = dateTimeFormat.formatToParts(datetime)
{ value: month },
,
{ value: day },
,
{ value: year },
] = dateTimeFormat.formatToParts(datetime);
return `${day} ${month}, ${year}`
return `${day} ${month}, ${year}`;
}
/**
* Sets the default locale for d3 to format dates in each available language.
* @param {Object} d3 - An instance of D3
*/
export function setD3Locale (d3) {
export function setD3Locale(d3) {
const languages = {
'es-MX': require('./data/es-MX.json')
}
"es-MX": require("./data/es-MX.json"),
};
if (language !== 'es-US' && languages[language]) {
d3.timeFormatDefaultLocale(languages[language])
if (language !== "es-US" && languages[language]) {
d3.timeFormatDefaultLocale(languages[language]);
}
}

View File

@@ -1,13 +1,11 @@
import '../scss/main.scss'
import React from 'react'
import Layout from './Layout'
import "../scss/main.scss";
import React from "react";
import Layout from "./Layout";
class App extends React.Component {
render () {
return (
<Layout />
)
render() {
return <Layout />;
}
}
export default App
export default App;

View File

@@ -1,82 +1,85 @@
import copy from '../common/data/copy.json'
import React from 'react'
import copy from "../common/data/copy.json";
import React from "react";
import CardCustomField from './presentational/Card/CustomField'
import CardTime from './presentational/Card/Time'
import CardLocation from './presentational/Card/Location'
import CardCaret from './presentational/Card/Caret'
import CardSummary from './presentational/Card/Summary'
import CardSource from './presentational/Card/Source'
import { makeNiceDate } from '../common/utilities'
import CardCustomField from "./presentational/Card/CustomField";
import CardTime from "./presentational/Card/Time";
import CardLocation from "./presentational/Card/Location";
import CardCaret from "./presentational/Card/Caret";
import CardSummary from "./presentational/Card/Summary";
import CardSource from "./presentational/Card/Source";
import { makeNiceDate } from "../common/utilities";
class Card extends React.Component {
constructor (props) {
super(props)
constructor(props) {
super(props);
this.state = {
isOpen: false
}
isOpen: false,
};
}
toggle () {
toggle() {
this.setState({
isOpen: !this.state.isOpen
})
isOpen: !this.state.isOpen,
});
}
makeTimelabel (datetime) {
return makeNiceDate(datetime)
makeTimelabel(datetime) {
return makeNiceDate(datetime);
}
handleCardSelect (e) {
if (!e.target.className.includes('arrow-down')) {
const selectedEventFormat = this.props.idx > 0 ? [this.props.event] : this.props.event
this.props.onSelect(selectedEventFormat, this.props.idx)
handleCardSelect(e) {
if (!e.target.className.includes("arrow-down")) {
const selectedEventFormat =
this.props.idx > 0 ? [this.props.event] : this.props.event;
this.props.onSelect(selectedEventFormat, this.props.idx);
}
}
renderSummary () {
renderSummary() {
return (
<CardSummary
language={this.props.language}
description={this.props.event.description}
isOpen={this.state.isOpen}
/>
)
);
}
renderLocation () {
renderLocation() {
return (
<CardLocation
language={this.props.language}
location={this.props.event.location}
isPrecise={(!this.props.event.type || this.props.event.type === 'Structure')}
isPrecise={
!this.props.event.type || this.props.event.type === "Structure"
}
/>
)
);
}
renderSources () {
renderSources() {
if (this.props.sourceError) {
return <div>ERROR: something went wrong loading sources, TODO:</div>
return <div>ERROR: something went wrong loading sources, TODO:</div>;
}
const sourceLang = copy[this.props.language].cardstack.sources
const sourceLang = copy[this.props.language].cardstack.sources;
return (
<div className='card-col'>
<div className="card-col">
<h4>{sourceLang}: </h4>
{this.props.event.sources.map(source => (
{this.props.event.sources.map((source) => (
<CardSource
isLoading={this.props.isLoading}
source={source}
onClickHandler={source => this.props.onViewSource(source)}
onClickHandler={(source) => this.props.onViewSource(source)}
/>
))}
</div>
)
);
}
// NB: should be internaionalized.
renderTime () {
let timelabel = this.makeTimelabel(this.props.event.datetime)
renderTime() {
const timelabel = this.makeTimelabel(this.props.event.datetime);
// let precision = this.props.event.time_display
// if (precision === '_date_only') {
@@ -97,54 +100,46 @@ class Card extends React.Component {
language={this.props.language}
timelabel={timelabel}
/>
)
);
}
renderCustomFields () {
return this.props.features.CUSTOM_EVENT_FIELDS
.map(field => {
const value = this.props.event[field.key]
return value ? (
<CardCustomField field={field} value={this.props.event[field.key]} />
) : null
})
renderCustomFields() {
return this.props.features.CUSTOM_EVENT_FIELDS.map((field) => {
const value = this.props.event[field.key];
return value ? (
<CardCustomField field={field} value={this.props.event[field.key]} />
) : null;
});
}
renderMain () {
renderMain() {
return (
<div className='card-container'>
<div className='card-row details'>
<div className="card-container">
<div className="card-row details">
{this.renderTime()}
{this.renderLocation()}
</div>
{this.renderSummary()}
{this.renderCustomFields()}
</div>
)
);
}
renderExtra () {
return (
<div className='card-bottomhalf'>
{this.renderSources()}
</div>
)
renderExtra() {
return <div className="card-bottomhalf">{this.renderSources()}</div>;
}
renderCaret () {
renderCaret() {
return this.props.features.USE_SOURCES ? (
<CardCaret
toggle={() => this.toggle()}
isOpen={this.state.isOpen}
/>
) : null
<CardCaret toggle={() => this.toggle()} isOpen={this.state.isOpen} />
) : null;
}
render () {
const { isSelected, idx } = this.props
render() {
const { isSelected, idx } = this.props;
return (
<li
className={`event-card ${isSelected ? 'selected' : ''}`}
className={`event-card ${isSelected ? "selected" : ""}`}
id={`event-card-${idx}`}
ref={this.props.innerRef}
onClick={(e) => this.handleCardSelect(e)}
@@ -153,9 +148,11 @@ class Card extends React.Component {
{this.state.isOpen ? this.renderExtra() : null}
{this.renderCaret()}
</li>
)
);
}
}
// The ref to each card will be used in CardStack for programmatic scrolling
export default React.forwardRef((props, ref) => <Card innerRef={ref} {...props} />)
export default React.forwardRef((props, ref) => (
<Card innerRef={ref} {...props} />
));

View File

@@ -1,180 +1,186 @@
import React from 'react'
import { connect } from 'react-redux'
import React from "react";
import { connect } from "react-redux";
import {
Card,
generateCardLayout,
} from "@forensic-architecture/design-system/dist/react";
import * as selectors from '../selectors'
import { 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'
import copy from "../common/data/copy.json";
class CardStack extends React.Component {
constructor () {
super()
this.refs = {}
this.refCardStack = React.createRef()
this.refCardStackContent = React.createRef()
constructor() {
super();
this.refs = {};
this.refCardStack = React.createRef();
this.refCardStackContent = React.createRef();
}
componentDidUpdate () {
const isNarrative = !!this.props.narrative
componentDidUpdate() {
const isNarrative = !!this.props.narrative;
if (isNarrative) {
this.scrollToCard()
this.scrollToCard();
}
}
scrollToCard () {
const duration = 500
const element = this.refCardStack.current
scrollToCard() {
const duration = 500;
const element = this.refCardStack.current;
const cardScroll = this.refs[this.props.narrative.current].current
.offsetTop
.offsetTop;
let start = element.scrollTop
let change = cardScroll - start
let currentTime = 0
const increment = 20
const start = element.scrollTop;
const change = cardScroll - start;
let currentTime = 0;
const increment = 20;
// t = current time
// b = start value
// c = change in value
// d = duration
Math.easeInOutQuad = function (t, b, c, d) {
t /= d / 2
if (t < 1) return (c / 2) * t * t + b
t -= 1
return (-c / 2) * (t * (t - 2) - 1) + b
}
t /= d / 2;
if (t < 1) return (c / 2) * t * t + b;
t -= 1;
return (-c / 2) * (t * (t - 2) - 1) + b;
};
const animateScroll = function () {
currentTime += increment
const val = Math.easeInOutQuad(currentTime, start, change, duration)
element.scrollTop = val
if (currentTime < duration) setTimeout(animateScroll, increment)
}
animateScroll()
currentTime += increment;
const val = Math.easeInOutQuad(currentTime, start, change, duration);
element.scrollTop = val;
if (currentTime < duration) setTimeout(animateScroll, increment);
};
animateScroll();
}
renderCards (events, selections) {
renderCards(events, selections) {
// if no selections provided, select all
if (!selections) {
selections = events.map((e) => true)
selections = events.map((e) => true);
}
this.refs = []
this.refs = [];
const generateTemplate =
generateCardLayout[this.props.cardUI.layout.template];
return events.map((event, idx) => {
const thisRef = React.createRef()
this.refs[idx] = thisRef
const thisRef = React.createRef();
this.refs[idx] = thisRef;
return (
<Card
ref={thisRef}
content={this.props.cardUI.layout({
content={generateTemplate({
event,
colors: this.props.colors,
coloringSet: this.props.coloringSet,
getFilterIdxFromColorSet
getFilterIdxFromColorSet,
})}
language={this.props.language}
isLoading={this.props.isLoading}
isSelected={selections[idx]}
/>
)
})
);
});
}
renderSelectedCards () {
const { selected } = this.props
renderSelectedCards() {
const { selected } = this.props;
if (selected.length > 0) {
return this.renderCards(selected)
return this.renderCards(selected);
}
return null
return null;
}
renderNarrativeCards () {
const { narrative } = this.props
const showing = narrative.steps
renderNarrativeCards() {
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)
return this.renderCards(showing, selections);
}
renderCardStackHeader () {
const headerLang = copy[this.props.language].cardstack.header
renderCardStackHeader() {
const headerLang = copy[this.props.language].cardstack.header;
return (
<div
id='card-stack-header'
className='card-stack-header'
id="card-stack-header"
className="card-stack-header"
onClick={() => this.props.onToggleCardstack()}
>
<button className='side-menu-burg is-active'>
<button className="side-menu-burg is-active">
<span />
</button>
<p className='header-copy top'>
<p className="header-copy top">
{`${this.props.selected.length} ${headerLang}`}
</p>
</div>
)
);
}
renderCardStackContent () {
renderCardStackContent() {
return (
<div id='card-stack-content' className='card-stack-content'>
<div id="card-stack-content" className="card-stack-content">
<ul>{this.renderSelectedCards()}</ul>
</div>
)
);
}
renderNarrativeContent () {
renderNarrativeContent() {
return (
<div
id='card-stack-content'
className='card-stack-content'
id="card-stack-content"
className="card-stack-content"
ref={this.refCardStackContent}
>
<ul>{this.renderNarrativeCards()}</ul>
</div>
)
);
}
render () {
const { isCardstack, selected, narrative, timelineDims } = this.props
render() {
const { isCardstack, selected, narrative, timelineDims } = this.props;
// TODO: make '237px', which is the narrative header, less hard-coded
const height = `calc(100% - 237px - ${timelineDims.height}px)`
const height = `calc(100% - 237px - ${timelineDims.height}px)`;
if (selected.length > 0) {
if (!narrative) {
return (
<div
id='card-stack'
id="card-stack"
className={`card-stack
${isCardstack ? '' : ' folded'}`}
${isCardstack ? "" : " folded"}`}
>
{this.renderCardStackHeader()}
{this.renderCardStackContent()}
</div>
)
);
} else {
return (
<div
id='card-stack'
id="card-stack"
ref={this.refCardStack}
className={`card-stack narrative-mode
${isCardstack ? '' : ' folded'}`}
${isCardstack ? "" : " folded"}`}
style={{ height }}
>
{this.renderNarrativeContent()}
</div>
)
);
}
}
return <div />
return <div />;
}
}
function mapStateToProps (state) {
function mapStateToProps(state) {
return {
narrative: selectors.selectActiveNarrative(state),
selected: selectors.selectSelected(state),
@@ -185,8 +191,8 @@ function mapStateToProps (state) {
cardUI: state.ui.card,
colors: state.ui.coloring.colors,
coloringSet: state.app.associations.coloringSet,
features: state.features
}
features: state.features,
};
}
export default connect(mapStateToProps)(CardStack)
export default connect(mapStateToProps)(CardStack);

View File

@@ -1,32 +1,57 @@
import React from 'react'
import React from "react";
const Icon = ({ iconType }) => {
if (iconType === 'personas') {
if (iconType === "personas") {
return (
<svg x='0px' y='0px' width='40px' height='40px' viewBox='0 0 40 40' enableBackground='new 0 0 40 40'>
<path d='M15.464,17.713' />
<path d='M5.526,17.713c-1.537,0.595-3,1.472-4.314,2.637l1.114,17.081h16.338' />
<path d='M12.283,15.522c-1.707,0.661-3.332,1.636-4.792,2.93l1.238,18.979h18.153' />
<circle cx='27.432' cy='8.876' r='6.877' />
<path d='M21.297,13.088c-1.896,0.733-3.702,1.817-5.326,3.256l1.375,21.087h20.17l1.376-21.087c-1.624-1.438-3.43-2.522-5.326-3.256' />
<path d='M20.968,6.547c-0.926-0.554-2.006-0.877-3.163-0.877c-3.418,0-6.188,2.771-6.188,6.188c0,2.811,1.875,5.18,4.441,5.935' />
<path d='M12.38,8.881c-0.738-0.361-1.564-0.57-2.441-0.57c-3.076,0-5.57,2.494-5.57,5.57c0,1.983,1.04,3.72,2.601,4.707' />
<svg
x="0px"
y="0px"
width="40px"
height="40px"
viewBox="0 0 40 40"
enableBackground="new 0 0 40 40"
>
<path d="M15.464,17.713" />
<path d="M5.526,17.713c-1.537,0.595-3,1.472-4.314,2.637l1.114,17.081h16.338" />
<path d="M12.283,15.522c-1.707,0.661-3.332,1.636-4.792,2.93l1.238,18.979h18.153" />
<circle cx="27.432" cy="8.876" r="6.877" />
<path d="M21.297,13.088c-1.896,0.733-3.702,1.817-5.326,3.256l1.375,21.087h20.17l1.376-21.087c-1.624-1.438-3.43-2.522-5.326-3.256" />
<path d="M20.968,6.547c-0.926-0.554-2.006-0.877-3.163-0.877c-3.418,0-6.188,2.771-6.188,6.188c0,2.811,1.875,5.18,4.441,5.935" />
<path d="M12.38,8.881c-0.738-0.361-1.564-0.57-2.441-0.57c-3.076,0-5.57,2.494-5.57,5.57c0,1.983,1.04,3.72,2.601,4.707" />
</svg>
)
} else if (iconType === 'tipos') {
);
} else if (iconType === "tipos") {
return (
<svg x='0px' y='0px' width='40px' height='40px' viewBox='0 0 40 40' enableBackground='new 0 0 40 40'>
<path strokeDasharray='3, 4' d='M22.326,5.346
<svg
x="0px"
y="0px"
width="40px"
height="40px"
viewBox="0 0 40 40"
enableBackground="new 0 0 40 40"
>
<path
strokeDasharray="3, 4"
d="M22.326,5.346
c-2.154-2.081-5.082-3.367-8.314-3.367c-6.614,0-11.976,5.361-11.976,11.974c0,6.613,5.361,11.977,11.976,11.977
c0.228,0,0.449-0.021,0.674-0.034' />
<circle cx='23' cy='17.288' r='11.975' />
<circle strokeDasharray='3, 4' cx='25.987' cy='26.926' r='11.976' />
c0.228,0,0.449-0.021,0.674-0.034"
/>
<circle cx="23" cy="17.288" r="11.975" />
<circle strokeDasharray="3, 4" cx="25.987" cy="26.926" r="11.976" />
</svg>
)
} else if (iconType === 'hardware') {
);
} else if (iconType === "hardware") {
return (
<svg x='0px' y='0px' width='40px' height='40px' viewBox='0 0 40 40' enableBackground='new 0 0 40 40'>
<path d='M20,1.695C12.571,1.696,6.286,2.019,5.272,2.452C5.253,2.458,5.233,2.466,5.215,2.474
<svg
x="0px"
y="0px"
width="40px"
height="40px"
viewBox="0 0 40 40"
enableBackground="new 0 0 40 40"
>
<path
d="M20,1.695C12.571,1.696,6.286,2.019,5.272,2.452C5.253,2.458,5.233,2.466,5.215,2.474
c-0.01,0.004-0.019,0.008-0.027,0.012C4.38,2.831,3.803,4.256,3.802,5.907v3.502H2.926H1.175c-0.241,0-0.438,0.196-0.438,0.438
v0.875v5.254c0,0.242,0.196,0.438,0.438,0.438h1.751c0.242,0,0.438-0.195,0.438-0.438V11.16h0.438v15.324h5.691
c0.242,0,0.438,0.195,0.438,0.438v1.751c0,0.241-0.195,0.438-0.438,0.438H3.802v3.063c0,0.626,0.167,1.203,0.438,1.515v3.74
@@ -38,46 +63,83 @@ const Icon = ({ iconType }) => {
V7.22c0,0.242-0.195,0.438-0.438,0.438H4.991c-0.242,0-0.438-0.196-0.438-0.438V5.469C4.553,4.261,4.945,3.28,5.429,3.28z
M5.553,8.534h28.895c0.483,0,0.876,0.392,0.876,0.875v13.134c0,0.484-0.393,0.876-0.876,0.876h-3.466c0,0-0.863,0.613-0.912,0.613
H9.931c-0.113,0-0.225-0.022-0.33-0.065l-0.778-0.548h-3.27c-0.483,0-0.875-0.392-0.875-0.876V9.409
C4.678,8.926,5.069,8.534,5.553,8.534L5.553,8.534z' />
C4.678,8.926,5.069,8.534,5.553,8.534L5.553,8.534z"
/>
</svg>
)
} else if (iconType === 'escenas') {
);
} else if (iconType === "escenas") {
return (
<svg className='scenes' x='0px' y='0px' width='40px' height='40px' viewBox='0 0 40 40' enableBackground='new 0 0 40 40'>
<path d='M36.729,14.743v13.15l-14.225,6.693V21.438L36.729,14.743 M38.732,11.045L20.5,19.625v18.662l18.232-8.58V11.045
L38.732,11.045z' />
<path d='M4.271,14.743l14.225,6.695v13.148L4.271,27.894V14.743 M2.268,11.045v18.662l18.232,8.58V19.625L2.268,11.045L2.268,11.045
z' />
<path d='M20.5,4.844l13.289,6.202L20.5,17.247L7.209,11.046L20.5,4.844 M20.5,2.537L2.268,11.045L20.5,19.554l18.232-8.509
L20.5,2.537L20.5,2.537z' />
<svg
className="scenes"
x="0px"
y="0px"
width="40px"
height="40px"
viewBox="0 0 40 40"
enableBackground="new 0 0 40 40"
>
<path
d="M36.729,14.743v13.15l-14.225,6.693V21.438L36.729,14.743 M38.732,11.045L20.5,19.625v18.662l18.232-8.58V11.045
L38.732,11.045z"
/>
<path
d="M4.271,14.743l14.225,6.695v13.148L4.271,27.894V14.743 M2.268,11.045v18.662l18.232,8.58V19.625L2.268,11.045L2.268,11.045
z"
/>
<path
d="M20.5,4.844l13.289,6.202L20.5,17.247L7.209,11.046L20.5,4.844 M20.5,2.537L2.268,11.045L20.5,19.554l18.232-8.509
L20.5,2.537L20.5,2.537z"
/>
</svg>
)
} else if (iconType === 'docs') {
);
} else if (iconType === "docs") {
return (
<svg x='0px' y='0px' width='40px' height='40px' viewBox='0 0 40 40' enableBackground='new 0 0 40 40'>
<path d='M31.543,5.987V3.158
c0-1.103-0.095-1.197-1.197-1.197H4.791c-1.103,0-1.198,0.095-1.198,1.197V32.84c0,1.103,0.095,1.197,1.198,1.197h2.829' />
<path d='M35.57,36.866
c0,1.103-0.096,1.198-1.198,1.198H8.817c-1.103,0-1.198-0.096-1.198-1.198V7.185c0-1.103,0.095-1.197,1.198-1.197h25.555
c1.103,0,1.198,0.095,1.198,1.197V36.866z' />
<path d='M58.755,29.633' />
<path d='M21.86,40.072' />
<path d='M-22.755,58.555' />
<line x1='11.612' y1='11.977' x2='31.577' y2='11.977' />
<line x1='11.612' y1='17.966' x2='31.577' y2='17.966' />
<line x1='11.612' y1='29.945' x2='31.577' y2='29.945' />
<line x1='11.612' y1='23.955' x2='31.577' y2='23.955' />
<svg
x="0px"
y="0px"
width="40px"
height="40px"
viewBox="0 0 40 40"
enableBackground="new 0 0 40 40"
>
<path
d="M31.543,5.987V3.158
c0-1.103-0.095-1.197-1.197-1.197H4.791c-1.103,0-1.198,0.095-1.198,1.197V32.84c0,1.103,0.095,1.197,1.198,1.197h2.829"
/>
<path
d="M35.57,36.866
c0,1.103-0.096,1.198-1.198,1.198H8.817c-1.103,0-1.198-0.096-1.198-1.198V7.185c0-1.103,0.095-1.197,1.198-1.197h25.555
c1.103,0,1.198,0.095,1.198,1.197V36.866z"
/>
<path d="M58.755,29.633" />
<path d="M21.86,40.072" />
<path d="M-22.755,58.555" />
<line x1="11.612" y1="11.977" x2="31.577" y2="11.977" />
<line x1="11.612" y1="17.966" x2="31.577" y2="17.966" />
<line x1="11.612" y1="29.945" x2="31.577" y2="29.945" />
<line x1="11.612" y1="23.955" x2="31.577" y2="23.955" />
</svg>
)
} else if (iconType === 'search') {
);
} else if (iconType === "search") {
return (
<svg x='0px' y='0px' width='40px' height='40px' viewBox='0 0 40 40' enableBackground='new 0 0 40 40'>
<circle cx='18.306' cy='18.307' r='13.856' />
<path strokeLinecap='round' strokeLinejoin='round' d='M28.24,28.24
l8.346,8.346L28.24,28.24z' />
<svg
x="0px"
y="0px"
width="40px"
height="40px"
viewBox="0 0 40 40"
enableBackground="new 0 0 40 40"
>
<circle cx="18.306" cy="18.307" r="13.856" />
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M28.24,28.24
l8.346,8.346L28.24,28.24z"
/>
</svg>
)
);
}
}
};
export default Icon
export default Icon;

View File

@@ -1,6 +1,6 @@
import React from 'react'
import Popup from './presentational/Popup'
import copy from '../common/data/copy.json'
import React from "react";
import Popup from "./presentational/Popup";
import copy from "../common/data/copy.json";
export default ({ isOpen, onClose, language, styles }) => (
<Popup
@@ -10,4 +10,4 @@ export default ({ isOpen, onClose, language, styles }) => (
isOpen={isOpen}
styles={styles}
/>
)
);

View File

@@ -1,304 +1,321 @@
/* global alert, Event */
import React from 'react'
import React from "react";
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import * as actions from '../actions'
import * as selectors from '../selectors'
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
import * as actions from "../actions";
import * as selectors from "../selectors";
import MediaOverlay from './Overlay/Media'
import LoadingOverlay from './Overlay/Loading'
import Map from './Map.jsx'
import Toolbar from './Toolbar/Layout'
import CardStack from './CardStack.jsx'
import MediaOverlay from "./Overlay/Media";
import LoadingOverlay from "./Overlay/Loading";
import Map from "./Map.jsx";
import Toolbar from "./Toolbar/Layout";
import CardStack from "./CardStack.jsx";
// import {CardStack} from '@forensic-architecture/design-system'
import NarrativeControls from './presentational/Narrative/Controls.js'
import InfoPopup from './InfoPopup.jsx'
import Popup from './presentational/Popup'
import Timeline from './Timeline.jsx'
import Notification from './Notification.jsx'
import StateOptions from './StateOptions.jsx'
import StaticPage from './StaticPage'
import TemplateCover from './TemplateCover'
import NarrativeControls from "./presentational/Narrative/Controls.js";
import InfoPopup from "./InfoPopup.jsx";
import Popup from "./presentational/Popup";
import Timeline from "./Timeline.jsx";
import Notification from "./Notification.jsx";
import StateOptions from "./StateOptions.jsx";
import StaticPage from "./StaticPage";
import TemplateCover from "./TemplateCover";
import colors from '../common/global'
import { binarySearch, insetSourceFrom } from '../common/utilities'
import { isMobileOnly } from 'react-device-detect'
import Search from './Search.jsx'
import colors from "../common/global";
import { binarySearch, insetSourceFrom } from "../common/utilities";
import { isMobileOnly } from "react-device-detect";
import Search from "./Search.jsx";
class Dashboard extends React.Component {
constructor (props) {
super(props)
constructor(props) {
super(props);
this.handleViewSource = this.handleViewSource.bind(this)
this.handleHighlight = this.handleHighlight.bind(this)
this.setNarrative = this.setNarrative.bind(this)
this.setNarrativeFromFilters = this.setNarrativeFromFilters.bind(this)
this.handleSelect = this.handleSelect.bind(this)
this.getCategoryColor = this.getCategoryColor.bind(this)
this.findEventIdx = this.findEventIdx.bind(this)
this.onKeyDown = this.onKeyDown.bind(this)
this.selectNarrativeStep = this.selectNarrativeStep.bind(this)
this.handleViewSource = this.handleViewSource.bind(this);
this.handleHighlight = this.handleHighlight.bind(this);
this.setNarrative = this.setNarrative.bind(this);
this.setNarrativeFromFilters = this.setNarrativeFromFilters.bind(this);
this.handleSelect = this.handleSelect.bind(this);
this.getCategoryColor = this.getCategoryColor.bind(this);
this.findEventIdx = this.findEventIdx.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.selectNarrativeStep = this.selectNarrativeStep.bind(this);
}
componentDidMount () {
componentDidMount() {
if (!this.props.app.isMobile) {
this.props.actions.fetchDomain()
.then(domain =>
this.props.actions.updateDomain({
domain,
features: this.props.features
}))
this.props.actions.fetchDomain().then((domain) =>
this.props.actions.updateDomain({
domain,
features: this.props.features,
})
);
}
// NOTE: hack to get the timeline to always show. Not entirely sure why
// this is necessary.
window.dispatchEvent(new Event('resize'))
window.dispatchEvent(new Event("resize"));
}
handleHighlight (highlighted) {
this.props.actions.updateHighlighted((highlighted) || null)
handleHighlight(highlighted) {
this.props.actions.updateHighlighted(highlighted || null);
}
handleViewSource (source) {
this.props.actions.updateSource(source)
handleViewSource(source) {
this.props.actions.updateSource(source);
}
findEventIdx (theEvent) {
const { events } = this.props.domain
return binarySearch(
events,
theEvent,
(theev, otherev) => {
return theev.datetime - otherev.datetime
}
)
findEventIdx(theEvent) {
const { events } = this.props.domain;
return binarySearch(events, theEvent, (theev, otherev) => {
return theev.datetime - otherev.datetime;
});
}
handleSelect (selected, axis) {
const matchedEvents = []
const TIMELINE_AXIS = 0
handleSelect(selected, axis) {
const matchedEvents = [];
const TIMELINE_AXIS = 0;
if (axis === TIMELINE_AXIS) {
matchedEvents.push(selected)
matchedEvents.push(selected);
// find in events
const { events } = this.props.domain
const idx = this.findEventIdx(selected)
const { events } = this.props.domain;
const idx = this.findEventIdx(selected);
// check events before
let ptr = idx - 1
let ptr = idx - 1;
while (
ptr >= 0 &&
(events[idx].datetime).getTime() === (events[ptr].datetime).getTime()
events[idx].datetime.getTime() === events[ptr].datetime.getTime()
) {
if (events[ptr].id !== selected.id) {
matchedEvents.push(events[ptr])
matchedEvents.push(events[ptr]);
}
ptr -= 1
ptr -= 1;
}
// check events after
ptr = idx + 1
ptr = idx + 1;
while (
ptr < events.length &&
(events[idx].datetime).getTime() === (events[ptr].datetime).getTime()
events[idx].datetime.getTime() === events[ptr].datetime.getTime()
) {
if (events[ptr].id !== selected.id) {
matchedEvents.push(events[ptr])
matchedEvents.push(events[ptr]);
}
ptr += 1
ptr += 1;
}
} else { // Map..
const std = { ...selected }
delete std.sources
Object.values(std).forEach(ev => matchedEvents.push(ev))
}
this.props.actions.updateSelected(matchedEvents)
}
getCategoryColor (category) {
if (!this.props.features.USE_CATEGORIES) { return colors.fallbackEventColor }
const cat = this.props.ui.style.categories[category]
if (cat) {
return cat
} else {
return this.props.ui.style.categories['default']
// Map..
const std = { ...selected };
delete std.sources;
Object.values(std).forEach((ev) => matchedEvents.push(ev));
}
this.props.actions.updateSelected(matchedEvents);
}
getCategoryColor(category) {
if (!this.props.features.USE_CATEGORIES) {
return colors.fallbackEventColor;
}
const cat = this.props.ui.style.categories[category];
if (cat) {
return cat;
} else {
return this.props.ui.style.categories.default;
}
}
setNarrative (narrative) {
setNarrative(narrative) {
// only handleSelect if narrative is not null and has associated events
if (narrative && narrative.steps.length >= 1) {
this.handleSelect([ narrative.steps[0] ])
this.handleSelect([narrative.steps[0]]);
}
this.props.actions.updateNarrative(narrative)
this.props.actions.updateNarrative(narrative);
}
setNarrativeFromFilters (withSteps) {
const { app, domain } = this.props
let activeFilters = app.associations.filters
setNarrativeFromFilters(withSteps) {
const { app, domain } = this.props;
let activeFilters = app.associations.filters;
if (activeFilters.length === 0) {
alert('No filters selected, cant narrativise')
return
alert("No filters selected, cant narrativise");
return;
}
activeFilters = activeFilters.map(f => ({ name: f }))
activeFilters = activeFilters.map((f) => ({ name: f }));
const evs = domain.events.filter(ev => {
let hasOne = false
const evs = domain.events.filter((ev) => {
let hasOne = false;
// add event if it has at least one matching filter
for (let i = 0; i < activeFilters.length; i++) {
if (ev.associations.includes(activeFilters[i].name)) {
hasOne = true
break
hasOne = true;
break;
}
}
if (hasOne) return true
return false
})
if (hasOne) return true;
return false;
});
if (evs.length === 0) {
alert('No associated events, cant narrativise')
return
alert("No associated events, cant narrativise");
return;
}
const name = activeFilters.map(f => f.name).join('-')
const desc = activeFilters.map(f => f.description).join('\n\n')
const name = activeFilters.map((f) => f.name).join("-");
const desc = activeFilters.map((f) => f.description).join("\n\n");
this.setNarrative({
id: name,
label: name,
description: desc,
withLines: withSteps,
steps: evs.map(insetSourceFrom(domain.sources))
})
steps: evs.map(insetSourceFrom(domain.sources)),
});
}
selectNarrativeStep (idx) {
selectNarrativeStep(idx) {
// Try to find idx if event passed rather than number
if (typeof idx !== 'number') {
let e = idx[0] || idx
if (typeof idx !== "number") {
const e = idx[0] || idx;
if (this.props.app.associations.narrative) {
const { steps } = this.props.app.associations.narrative
const { steps } = this.props.app.associations.narrative;
// choose the first event at a given location
const locationEventId = e.id
const narrativeIdxObj = steps.find(s => s.id === locationEventId)
let narrativeIdx = steps.indexOf(narrativeIdxObj)
const locationEventId = e.id;
const narrativeIdxObj = steps.find((s) => s.id === locationEventId);
const narrativeIdx = steps.indexOf(narrativeIdxObj);
if (narrativeIdx > -1) {
idx = narrativeIdx
idx = narrativeIdx;
}
}
}
const { narrative } = this.props.app.associations
if (narrative === null) return
const { narrative } = this.props.app.associations;
if (narrative === null) return;
if (idx < narrative.steps.length && idx >= 0) {
const step = narrative.steps[idx]
const step = narrative.steps[idx];
this.handleSelect([step])
this.props.actions.updateNarrativeStepIdx(idx)
this.handleSelect([step]);
this.props.actions.updateNarrativeStepIdx(idx);
}
}
onKeyDown (e) {
const { narrative, selected } = this.props.app
const { events } = this.props.domain
onKeyDown(e) {
const { narrative, selected } = this.props.app;
const { events } = this.props.domain;
const prev = idx => {
const prev = (idx) => {
if (narrative === null) {
this.handleSelect(events[idx - 1], 0)
this.handleSelect(events[idx - 1], 0);
} else {
this.selectNarrativeStep(this.props.narrativeIdx - 1)
this.selectNarrativeStep(this.props.narrativeIdx - 1);
}
}
const next = idx => {
};
const next = (idx) => {
if (narrative === null) {
this.handleSelect(events[idx + 1], 0)
this.handleSelect(events[idx + 1], 0);
} else {
this.selectNarrativeStep(this.props.narrativeIdx + 1)
this.selectNarrativeStep(this.props.narrativeIdx + 1);
}
}
};
if (selected.length > 0) {
const ev = selected[selected.length - 1]
const idx = this.findEventIdx(ev)
const ev = selected[selected.length - 1];
const idx = this.findEventIdx(ev);
switch (e.keyCode) {
case 37: // left arrow
case 38: // up arrow
if (idx <= 0) return
prev(idx)
break
if (idx <= 0) return;
prev(idx);
break;
case 39: // right arrow
case 40: // down arrow
if (idx < 0 || idx >= this.props.domain.length - 1) return
next(idx)
break
if (idx < 0 || idx >= this.props.domain.length - 1) return;
next(idx);
break;
default:
}
}
}
renderIntroPopup (isMobile, styles) {
const { app, actions } = this.props
renderIntroPopup(isMobile, styles) {
const { app, actions } = this.props;
const extraContent = isMobile ? <div style={{ position: 'relative', bottom: 0 }}>
<h3 style={{ color: 'var(--error-red)' }}>This platform is not suitable for mobile.<br /><br />Please re-visit the site on a device with a larger screen.</h3>
</div> : null
const extraContent = isMobile ? (
<div style={{ position: "relative", bottom: 0 }}>
<h3 style={{ color: "var(--error-red)" }}>
This platform is not suitable for mobile.
<br />
<br />
Please re-visit the site on a device with a larger screen.
</h3>
</div>
) : null;
return <Popup
title='Introduction to the platform'
theme='dark'
isOpen={app.flags.isIntropopup}
onClose={actions.toggleIntroPopup}
content={app.intro}
styles={styles}
isMobile={isMobile}
>
{extraContent}
</Popup>
return (
<Popup
title="Introduction to the platform"
theme="dark"
isOpen={app.flags.isIntropopup}
onClose={actions.toggleIntroPopup}
content={app.intro}
styles={styles}
isMobile={isMobile}
>
{extraContent}
</Popup>
);
}
render () {
const { actions, app, domain, features } = this.props
const dateHeight = 80
const padding = 2
const checkMobile = (isMobileOnly || window.innerWidth < 600)
render() {
const { actions, app, domain, features } = this.props;
const dateHeight = 80;
const padding = 2;
const checkMobile = isMobileOnly || window.innerWidth < 600;
const popupStyles = {
height: checkMobile ? '100vh' : 'fit-content',
display: checkMobile ? 'block' : 'table',
width: checkMobile ? '100vw' : window.innerWidth > 768 ? '60vw' : `calc(100vw - var(--toolbar-width))`,
maxWidth: checkMobile ? '100vw' : 600,
maxHeight: checkMobile ? '100vh' : window.innerHeight > 768 ? `calc(100vh - ${app.timeline.dimensions.height}px - ${dateHeight}px)` : `100vh`,
left: checkMobile ? padding : 'var(--toolbar-width)',
height: checkMobile ? "100vh" : "fit-content",
display: checkMobile ? "block" : "table",
width: checkMobile
? "100vw"
: window.innerWidth > 768
? "60vw"
: "calc(100vw - var(--toolbar-width))",
maxWidth: checkMobile ? "100vw" : 600,
maxHeight: checkMobile
? "100vh"
: window.innerHeight > 768
? `calc(100vh - ${app.timeline.dimensions.height}px - ${dateHeight}px)`
: "100vh",
left: checkMobile ? padding : "var(--toolbar-width)",
top: 0,
overflowY: 'scroll'
}
overflowY: "scroll",
};
if (checkMobile) {
const msg = 'This platform is not suitable for mobile. Please re-visit the site on a device with a larger screen.'
const msg =
"This platform is not suitable for mobile. Please re-visit the site on a device with a larger screen.";
return (
<div>
{(features.USE_COVER && !app.intro) && (
{features.USE_COVER && !app.intro && (
<StaticPage showing={app.flags.isCover}>
{/* enable USE_COVER in config.js features, and customise your header */}
{/* pass 'actions.toggleCover' as a prop to your custom header */}
<TemplateCover showAppHandler={() => {
/* eslint-disable no-undef */
alert(msg)
/* eslint-enable no-undef */
}} />
<TemplateCover
showAppHandler={() => {
/* eslint-disable no-undef */
alert(msg);
/* eslint-enable no-undef */
}}
/>
</StaticPage>
)}
{app.intro && <>
{this.renderIntroPopup(true, popupStyles)}
</>}
{app.intro && <>{this.renderIntroPopup(true, popupStyles)}</>}
{!app.intro && !features.USE_COVER && (
<div className='fixedTooSmallMessage'>{msg}</div>
<div className="fixedTooSmallMessage">{msg}</div>
)}
</div>
)
);
}
return (
@@ -307,9 +324,11 @@ class Dashboard extends React.Component {
isNarrative={!!app.associations.narrative}
methods={{
onTitle: actions.toggleCover,
onSelectFilter: filters => actions.toggleAssociations('filters', filters),
onCategoryFilter: categories => actions.toggleAssociations('categories', categories),
onSelectNarrative: this.setNarrative
onSelectFilter: (filters) =>
actions.toggleAssociations("filters", filters),
onCategoryFilter: (categories) =>
actions.toggleAssociations("categories", categories),
onSelectNarrative: this.setNarrative,
}}
/>
<Map
@@ -317,39 +336,54 @@ class Dashboard extends React.Component {
methods={{
onSelectNarrative: this.setNarrative,
getCategoryColor: this.getCategoryColor,
onSelect: app.associations.narrative ? this.selectNarrativeStep : ev => this.handleSelect(ev, 1)
onSelect: app.associations.narrative
? this.selectNarrativeStep
: (ev) => this.handleSelect(ev, 1),
}}
/>
<Timeline
onKeyDown={this.onKeyDown}
methods={{
onSelect: app.associations.narrative ? this.selectNarrativeStep : ev => this.handleSelect(ev, 0),
onSelect: app.associations.narrative
? this.selectNarrativeStep
: (ev) => this.handleSelect(ev, 0),
onUpdateTimerange: actions.updateTimeRange,
getCategoryColor: this.getCategoryColor
getCategoryColor: this.getCategoryColor,
}}
/>
<CardStack
timelineDims={app.timeline.dimensions}
onViewSource={this.handleViewSource}
onSelect={app.associations.narrative ? this.selectNarrativeStep : () => null}
onSelect={
app.associations.narrative ? this.selectNarrativeStep : () => null
}
onHighlight={this.handleHighlight}
onToggleCardstack={() => actions.updateSelected([])}
getCategoryColor={this.getCategoryColor}
/>
<StateOptions
showing={this.props.narratives && this.props.narratives.length !== 0 && !app.associations.narrative && app.associations.filters.length > 0}
showing={
this.props.narratives &&
this.props.narratives.length !== 0 &&
!app.associations.narrative &&
app.associations.filters.length > 0
}
timelineDims={app.timeline.dimensions}
onClickHandler={this.setNarrativeFromFilters}
/>
<NarrativeControls
narrative={app.associations.narrative ? {
...app.associations.narrative,
current: this.props.narrativeIdx
} : null}
narrative={
app.associations.narrative
? {
...app.associations.narrative,
current: this.props.narrativeIdx,
}
: null
}
methods={{
onNext: () => this.selectNarrativeStep(this.props.narrativeIdx + 1),
onPrev: () => this.selectNarrativeStep(this.props.narrativeIdx - 1),
onSelectNarrative: this.setNarrative
onSelectNarrative: this.setNarrative,
}}
/>
<InfoPopup
@@ -359,24 +393,27 @@ class Dashboard extends React.Component {
onClose={actions.toggleInfoPopup}
/>
{this.renderIntroPopup(false, popupStyles)}
{app.debug ? <Notification
isNotification={app.flags.isNotification}
notifications={domain.notifications}
onToggle={actions.markNotificationsRead}
/> : null}
{features.USE_SEARCH && (<Search
narrative={app.narrative}
queryString={app.searchQuery}
events={domain.events}
onSearchRowClick={this.handleSelect}
/>)}
{app.debug ? (
<Notification
isNotification={app.flags.isNotification}
notifications={domain.notifications}
onToggle={actions.markNotificationsRead}
/>
) : null}
{features.USE_SEARCH && (
<Search
narrative={app.narrative}
queryString={app.searchQuery}
events={domain.events}
onSearchRowClick={this.handleSelect}
/>
)}
{app.source ? (
<MediaOverlay
source={app.source}
onCancel={() => {
actions.updateSource(null)
}
}
actions.updateSource(null);
}}
/>
) : null}
<LoadingOverlay
@@ -388,26 +425,29 @@ class Dashboard extends React.Component {
<StaticPage showing={app.flags.isCover}>
{/* enable USE_COVER in config.js features, and customise your header */}
{/* pass 'actions.toggleCover' as a prop to your custom header */}
<TemplateCover showing={app.flags.isCover} showAppHandler={actions.toggleCover} />
<TemplateCover
showing={app.flags.isCover}
showAppHandler={actions.toggleCover}
/>
</StaticPage>
)}
</div>
)
);
}
}
function mapDispatchToProps (dispatch) {
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(actions, dispatch)
}
actions: bindActionCreators(actions, dispatch),
};
}
export default connect(
state => ({
(state) => ({
...state,
narrativeIdx: selectors.selectNarrativeIdx(state),
narratives: selectors.selectNarratives(state),
selected: selectors.selectSelected(state)
selected: selectors.selectSelected(state),
}),
mapDispatchToProps
)(Dashboard)
)(Dashboard);

View File

@@ -1,255 +1,302 @@
/* global L, Event */
import React from 'react'
import { Portal } from 'react-portal'
import Supercluster from 'supercluster'
import React from "react";
import { Portal } from "react-portal";
import Supercluster from "supercluster";
import { connect } from 'react-redux'
import * as selectors from '../selectors'
import { connect } from "react-redux";
import * as selectors from "../selectors";
import 'leaflet'
import "leaflet";
import Sites from './presentational/Map/Sites.jsx'
import Shapes from './presentational/Map/Shapes.jsx'
import Events from './presentational/Map/Events.jsx'
import Clusters from './presentational/Map/Clusters.jsx'
import SelectedEvents from './presentational/Map/SelectedEvents.jsx'
import Narratives from './presentational/Map/Narratives'
import DefsMarkers from './presentational/Map/DefsMarkers.jsx'
import LoadingOverlay from '../components/Overlay/Loading'
import Sites from "./presentational/Map/Sites.jsx";
import Shapes from "./presentational/Map/Shapes.jsx";
import Events from "./presentational/Map/Events.jsx";
import Clusters from "./presentational/Map/Clusters.jsx";
import SelectedEvents from "./presentational/Map/SelectedEvents.jsx";
import Narratives from "./presentational/Map/Narratives";
import DefsMarkers from "./presentational/Map/DefsMarkers.jsx";
import LoadingOverlay from "../components/Overlay/Loading";
import { mapClustersToLocations, isIdentical, isLatitude, isLongitude, calculateTotalClusterPoints, calcClusterSize } from '../common/utilities'
import {
mapClustersToLocations,
isIdentical,
isLatitude,
isLongitude,
calculateTotalClusterPoints,
calcClusterSize,
} from "../common/utilities";
// NB: important constants for map, TODO: make statics
const supportedMapboxMap = ['streets', 'satellite']
const defaultToken = 'your_token'
const supportedMapboxMap = ["streets", "satellite"];
const defaultToken = "your_token";
class Map extends React.Component {
constructor () {
super()
this.projectPoint = this.projectPoint.bind(this)
this.onClusterSelect = this.onClusterSelect.bind(this)
this.loadClusterData = this.loadClusterData.bind(this)
this.getClusterChildren = this.getClusterChildren.bind(this)
this.svgRef = React.createRef()
this.map = null
this.superclusterIndex = null
constructor() {
super();
this.projectPoint = this.projectPoint.bind(this);
this.onClusterSelect = this.onClusterSelect.bind(this);
this.loadClusterData = this.loadClusterData.bind(this);
this.getClusterChildren = this.getClusterChildren.bind(this);
this.svgRef = React.createRef();
this.map = null;
this.superclusterIndex = null;
this.state = {
mapTransformX: 0,
mapTransformY: 0,
indexLoaded: false,
clusters: []
}
this.styleLocation = this.styleLocation.bind(this)
clusters: [],
};
this.styleLocation = this.styleLocation.bind(this);
}
componentDidMount () {
componentDidMount() {
if (this.map === null) {
this.initializeMap()
this.initializeMap();
}
window.dispatchEvent(new Event('resize'))
window.dispatchEvent(new Event("resize"));
}
componentWillReceiveProps (nextProps) {
componentWillReceiveProps(nextProps) {
if (!isIdentical(nextProps.domain.locations, this.props.domain.locations)) {
this.loadClusterData(nextProps.domain.locations)
this.loadClusterData(nextProps.domain.locations);
}
// Set appropriate zoom for narrative
const { bounds } = nextProps.app.map
if (!isIdentical(bounds, this.props.app.map.bounds) &&
bounds !== null) {
this.map.fitBounds(bounds)
const { bounds } = nextProps.app.map;
if (!isIdentical(bounds, this.props.app.map.bounds) && bounds !== null) {
this.map.fitBounds(bounds);
} else {
if (!isIdentical(nextProps.app.selected, this.props.app.selected)) {
// Fly to first of events selected
const eventPoint = (nextProps.app.selected.length > 0) ? nextProps.app.selected[0] : null
const eventPoint =
nextProps.app.selected.length > 0 ? nextProps.app.selected[0] : null;
if (eventPoint !== null && eventPoint.latitude && eventPoint.longitude) {
if (
eventPoint !== null &&
eventPoint.latitude &&
eventPoint.longitude
) {
// this.map.setView([eventPoint.latitude, eventPoint.longitude])
this.map.setView([eventPoint.latitude, eventPoint.longitude], this.map.getZoom(), {
'animate': true,
'pan': {
'duration': 0.7
this.map.setView(
[eventPoint.latitude, eventPoint.longitude],
this.map.getZoom(),
{
animate: true,
pan: {
duration: 0.7,
},
}
})
);
}
}
}
}
initializeMap () {
initializeMap() {
/**
* Creates a Leaflet map and a tilelayer for the map background
*/
const { map: mapConfig, cluster: clusterConfig } = this.props.app
const { map: mapConfig, cluster: clusterConfig } = this.props.app;
const map =
L.map(this.props.ui.dom.map)
.setView(mapConfig.anchor, mapConfig.startZoom)
.setMinZoom(mapConfig.minZoom)
.setMaxZoom(mapConfig.maxZoom)
.setMaxBounds(mapConfig.maxBounds)
const map = L.map(this.props.ui.dom.map)
.setView(mapConfig.anchor, mapConfig.startZoom)
.setMinZoom(mapConfig.minZoom)
.setMaxZoom(mapConfig.maxZoom)
.setMaxBounds(mapConfig.maxBounds);
// Initialize supercluster index
this.superclusterIndex = new Supercluster(clusterConfig)
this.superclusterIndex = new Supercluster(clusterConfig);
let firstLayer
let firstLayer;
if ((supportedMapboxMap.indexOf(this.props.ui.tiles) !== -1) && process.env.MAPBOX_TOKEN && process.env.MAPBOX_TOKEN !== defaultToken) {
if (
supportedMapboxMap.indexOf(this.props.ui.tiles) !== -1 &&
process.env.MAPBOX_TOKEN &&
process.env.MAPBOX_TOKEN !== defaultToken
) {
firstLayer = L.tileLayer(
`http://a.tiles.mapbox.com/v4/mapbox.${this.props.ui.tiles}/{z}/{x}/{y}@2x.png?access_token=${process.env.MAPBOX_TOKEN}`
)
} else if (process.env.MAPBOX_TOKEN && process.env.MAPBOX_TOKEN !== defaultToken) {
);
} else if (
process.env.MAPBOX_TOKEN &&
process.env.MAPBOX_TOKEN !== defaultToken
) {
firstLayer = L.tileLayer(
`http://a.tiles.mapbox.com/styles/v1/${this.props.ui.tiles}/tiles/{z}/{x}/{y}?access_token=${process.env.MAPBOX_TOKEN}`
)
);
} else {
firstLayer = L.tileLayer(
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
)
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
);
}
firstLayer.addTo(map)
firstLayer.addTo(map);
map.keyboard.disable()
map.zoomControl.remove()
map.keyboard.disable();
map.zoomControl.remove();
map.on('moveend', () => {
this.updateClusters()
this.alignLayers()
})
map.on("moveend", () => {
this.updateClusters();
this.alignLayers();
});
map.on('move zoomend viewreset', () => this.alignLayers())
map.on('zoomstart', () => { if (this.svgRef.current !== null) this.svgRef.current.classList.add('hide') })
map.on('zoomend', () => { if (this.svgRef.current !== null) this.svgRef.current.classList.remove('hide') })
window.addEventListener('resize', () => { this.alignLayers() })
map.on("move zoomend viewreset", () => this.alignLayers());
map.on("zoomstart", () => {
if (this.svgRef.current !== null)
this.svgRef.current.classList.add("hide");
});
map.on("zoomend", () => {
if (this.svgRef.current !== null)
this.svgRef.current.classList.remove("hide");
});
window.addEventListener("resize", () => {
this.alignLayers();
});
this.map = map
this.map = map;
}
getMapDetails () {
const bounds = this.map.getBounds()
const bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()]
const zoom = this.map.getZoom()
return [bbox, zoom]
getMapDetails() {
const bounds = this.map.getBounds();
const bbox = [
bounds.getWest(),
bounds.getSouth(),
bounds.getEast(),
bounds.getNorth(),
];
const zoom = this.map.getZoom();
return [bbox, zoom];
}
updateClusters () {
const [bbox, zoom] = this.getMapDetails()
updateClusters() {
const [bbox, zoom] = this.getMapDetails();
if (this.superclusterIndex && this.state.indexLoaded) {
this.setState({
clusters: this.superclusterIndex.getClusters(bbox, zoom)
})
clusters: this.superclusterIndex.getClusters(bbox, zoom),
});
}
}
loadClusterData (locations) {
loadClusterData(locations) {
if (locations && locations.length > 0 && this.superclusterIndex) {
const convertedLocations = locations.reduce((acc, loc) => {
const { longitude, latitude } = loc
const validCoordinates = isLatitude(latitude) && isLongitude(longitude)
const { longitude, latitude } = loc;
const validCoordinates = isLatitude(latitude) && isLongitude(longitude);
if (validCoordinates) {
const feature = {
type: 'Feature',
type: "Feature",
properties: {
cluster: false,
id: loc.label
id: loc.label,
},
geometry: {
type: 'Point',
coordinates: [longitude, latitude]
}
}
acc.push(feature)
type: "Point",
coordinates: [longitude, latitude],
},
};
acc.push(feature);
}
return acc
}, [])
this.superclusterIndex.load(convertedLocations)
return acc;
}, []);
this.superclusterIndex.load(convertedLocations);
this.setState({ indexLoaded: true }, () => {
this.updateClusters()
})
this.updateClusters();
});
} else {
this.setState({ clusters: [] })
this.setState({ clusters: [] });
}
}
getClusterChildren (clusterId) {
getClusterChildren(clusterId) {
if (this.superclusterIndex) {
try {
const children = this.superclusterIndex.getLeaves(clusterId, Infinity, 0)
return mapClustersToLocations(children, this.props.domain.locations)
const children = this.superclusterIndex.getLeaves(
clusterId,
Infinity,
0
);
return mapClustersToLocations(children, this.props.domain.locations);
} catch (err) {
return []
return [];
}
}
return []
return [];
}
getSelectedClusters () {
const { selected } = this.props.app
const selectedIds = selected.map(sl => sl.id)
getSelectedClusters() {
const { selected } = this.props.app;
const selectedIds = selected.map((sl) => sl.id);
if (this.state.clusters && this.state.clusters.length > 0) {
return this.state.clusters.reduce((acc, cl) => {
if (cl.properties.cluster) {
const children = this.getClusterChildren(cl.properties.cluster_id)
const children = this.getClusterChildren(cl.properties.cluster_id);
if (children && children.length > 0) {
children.forEach(child => {
const clusterPresent = acc.findIndex(item => item.id === cl.id) >= 0
children.forEach((child) => {
const clusterPresent =
acc.findIndex((item) => item.id === cl.id) >= 0;
if (selectedIds.includes(child.id) && !clusterPresent) {
acc.push(cl)
acc.push(cl);
}
})
});
}
}
return acc
}, [])
return acc;
}, []);
}
return []
return [];
}
alignLayers () {
const mapNode = document.querySelector('.leaflet-map-pane')
if (mapNode === null) return { transformX: 0, transformY: 0 }
alignLayers() {
const mapNode = document.querySelector(".leaflet-map-pane");
if (mapNode === null) return { transformX: 0, transformY: 0 };
// We'll get the transform of the leaflet container,
// which will let us offset the SVG by the same quantity
const transform = window
.getComputedStyle(mapNode)
.getPropertyValue('transform')
.getPropertyValue("transform");
// Offset with leaflet map transform boundaries
this.setState({
mapTransformX: +transform.split(',')[4],
mapTransformY: +transform.split(',')[5].split(')')[0]
})
mapTransformX: +transform.split(",")[4],
mapTransformY: +transform.split(",")[5].split(")")[0],
});
}
projectPoint (location) {
const latLng = new L.LatLng(location[0], location[1])
projectPoint(location) {
const latLng = new L.LatLng(location[0], location[1]);
return {
x: this.map.latLngToLayerPoint(latLng).x + this.state.mapTransformX,
y: this.map.latLngToLayerPoint(latLng).y + this.state.mapTransformY
}
y: this.map.latLngToLayerPoint(latLng).y + this.state.mapTransformY,
};
}
onClusterSelect ({ id, latitude, longitude }) {
const expansionZoom = Math.max(this.superclusterIndex.getClusterExpansionZoom(parseInt(id)), this.superclusterIndex.options.minZoom)
const zoomLevelsToSkip = 2
const zoomToFly = Math.max(expansionZoom + zoomLevelsToSkip, this.props.app.cluster.maxZoom)
this.map.flyTo(new L.LatLng(latitude, longitude), zoomToFly)
onClusterSelect({ id, latitude, longitude }) {
const expansionZoom = Math.max(
this.superclusterIndex.getClusterExpansionZoom(parseInt(id)),
this.superclusterIndex.options.minZoom
);
const zoomLevelsToSkip = 2;
const zoomToFly = Math.max(
expansionZoom + zoomLevelsToSkip,
this.props.app.cluster.maxZoom
);
this.map.flyTo(new L.LatLng(latitude, longitude), zoomToFly);
}
getClientDims () {
const boundingClient = document.querySelector(`#${this.props.ui.dom.map}`).getBoundingClientRect()
getClientDims() {
const boundingClient = document
.querySelector(`#${this.props.ui.dom.map}`)
.getBoundingClientRect();
return {
width: boundingClient.width,
height: boundingClient.height
}
height: boundingClient.height,
};
}
renderTiles () {
const pane = this.map.getPanes().overlayPane
const { width, height } = this.getClientDims()
renderTiles() {
const pane = this.map.getPanes().overlayPane;
const { width, height } = this.getClientDims();
return this.map ? (
<Portal node={pane}>
@@ -257,24 +304,27 @@ class Map extends React.Component {
ref={this.svgRef}
width={width}
height={height}
style={{ transform: `translate3d(${-this.state.mapTransformX}px, ${-this.state.mapTransformY}px, 0)` }}
className='leaflet-svg'
style={{
transform: `translate3d(${-this.state.mapTransformX}px, ${-this
.state.mapTransformY}px, 0)`,
}}
className="leaflet-svg"
/>
</Portal>
) : null
) : null;
}
renderSites () {
renderSites() {
return (
<Sites
sites={this.props.domain.sites}
projectPoint={this.projectPoint}
isEnabled={this.props.app.views.sites}
/>
)
);
}
renderShapes () {
renderShapes() {
return (
<Shapes
svg={this.svgRef.current}
@@ -282,22 +332,26 @@ class Map extends React.Component {
projectPoint={this.projectPoint}
styles={this.props.ui.shapes}
/>
)
);
}
renderNarratives () {
const hasNarratives = this.props.domain.narratives.length > 0
renderNarratives() {
const hasNarratives = this.props.domain.narratives.length > 0;
return (
<Narratives
svg={this.svgRef.current}
narratives={hasNarratives ? this.props.domain.narratives : [this.props.app.narrative]}
narratives={
hasNarratives
? this.props.domain.narratives
: [this.props.app.narrative]
}
projectPoint={this.projectPoint}
narrative={this.props.app.narrative}
styles={this.props.ui.narratives}
onSelectNarrative={this.props.methods.onSelectNarrative}
features={this.props.features}
/>
)
);
}
/**
@@ -309,22 +363,27 @@ class Map extends React.Component {
* at the second index is an optional additional component that renders in
* the <g/> div.
*/
styleLocation (location) {
return [null, null]
styleLocation(location) {
return [null, null];
}
styleCluster (cluster) {
return [null, null]
styleCluster(cluster) {
return [null, null];
}
renderEvents () {
renderEvents() {
/*
Uncomment below to filter out the locations already present in a cluster.
Leaving these lines commented out renders all the locations on the map, regardless of whether or not they are clustered
*/
const individualClusters = this.state.clusters.filter(cl => !cl.properties.cluster)
const filteredLocations = mapClustersToLocations(individualClusters, this.props.domain.locations)
const individualClusters = this.state.clusters.filter(
(cl) => !cl.properties.cluster
);
const filteredLocations = mapClustersToLocations(
individualClusters,
this.props.domain.locations
);
return (
<Events
svg={this.svgRef.current}
@@ -343,11 +402,13 @@ class Map extends React.Component {
filterColors={this.props.ui.filterColors}
features={this.props.features}
/>
)
);
}
renderClusters () {
const allClusters = this.state.clusters.filter(cl => cl.properties.cluster)
renderClusters() {
const allClusters = this.state.clusters.filter(
(cl) => cl.properties.cluster
);
return (
<Clusters
svg={this.svgRef.current}
@@ -360,34 +421,37 @@ class Map extends React.Component {
getClusterChildren={this.getClusterChildren}
filterColors={this.props.ui.filterColors}
/>
)
);
}
renderSelected () {
const selectedClusters = this.getSelectedClusters()
const totalMarkers = []
renderSelected() {
const selectedClusters = this.getSelectedClusters();
const totalMarkers = [];
this.props.app.selected.forEach(s => {
const { latitude, longitude } = s
this.props.app.selected.forEach((s) => {
const { latitude, longitude } = s;
totalMarkers.push({
latitude,
longitude,
radius: this.props.ui.eventRadius
})
})
radius: this.props.ui.eventRadius,
});
});
const totalClusterPoints = calculateTotalClusterPoints(this.state.clusters)
const totalClusterPoints = calculateTotalClusterPoints(this.state.clusters);
selectedClusters.forEach(cl => {
selectedClusters.forEach((cl) => {
if (cl.properties.cluster) {
const { coordinates } = cl.geometry
const { coordinates } = cl.geometry;
totalMarkers.push({
latitude: String(coordinates[1]),
longitude: String(coordinates[0]),
radius: calcClusterSize(cl.properties.point_count, totalClusterPoints)
})
radius: calcClusterSize(
cl.properties.point_count,
totalClusterPoints
),
});
}
})
});
return (
<SelectedEvents
@@ -396,22 +460,24 @@ class Map extends React.Component {
projectPoint={this.projectPoint}
styles={this.props.ui.mapSelectedEvents}
/>
)
);
}
renderMarkers () {
renderMarkers() {
return (
<Portal node={this.svgRef.current}>
<DefsMarkers />
</Portal>
)
);
}
render () {
const { isShowingSites, isFetchingDomain } = this.props.app.flags
const classes = this.props.app.narrative ? 'map-wrapper narrative-mode' : 'map-wrapper'
render() {
const { isShowingSites, isFetchingDomain } = this.props.app.flags;
const classes = this.props.app.narrative
? "map-wrapper narrative-mode"
: "map-wrapper";
const innerMap = this.map ? (
<React.Fragment>
<>
{this.renderTiles()}
{this.renderMarkers()}
{isShowingSites ? this.renderSites() : null}
@@ -420,14 +486,11 @@ class Map extends React.Component {
{this.renderEvents()}
{this.renderClusters()}
{this.renderSelected()}
</React.Fragment>
) : null
</>
) : null;
return (
<div className={classes}
onKeyDown={this.props.onKeyDown}
tabIndex='0'
>
<div className={classes} onKeyDown={this.props.onKeyDown} tabIndex="0">
<div id={this.props.ui.dom.map} />
<LoadingOverlay
isLoading={this.props.app.loading || isFetchingDomain}
@@ -436,18 +499,18 @@ class Map extends React.Component {
/>
{innerMap}
</div>
)
);
}
}
function mapStateToProps (state) {
function mapStateToProps(state) {
return {
domain: {
locations: selectors.selectLocations(state),
narratives: selectors.selectNarratives(state),
categories: selectors.getCategories(state),
sites: selectors.selectSites(state),
shapes: selectors.selectShapes(state)
shapes: selectors.selectShapes(state),
},
app: {
views: state.app.associations.views,
@@ -461,8 +524,8 @@ function mapStateToProps (state) {
coloringSet: state.app.associations.coloringSet,
flags: {
isShowingSites: state.app.flags.isShowingSites,
isFetchingDomain: state.app.flags.isFetchingDomain
}
isFetchingDomain: state.app.flags.isFetchingDomain,
},
},
ui: {
tiles: state.ui.tiles,
@@ -472,10 +535,10 @@ function mapStateToProps (state) {
shapes: state.ui.style.shapes,
eventRadius: state.ui.eventRadius,
radial: state.ui.style.clusters.radial,
filterColors: state.ui.coloring.colors
filterColors: state.ui.coloring.colors,
},
features: selectors.getFeatures(state)
}
features: selectors.getFeatures(state),
};
}
export default connect(mapStateToProps)(Map)
export default connect(mapStateToProps)(Map);

View File

@@ -1,69 +1,71 @@
import React from 'react'
import React from "react";
export default class Notification extends React.Component {
constructor (props) {
super()
constructor(props) {
super();
this.state = {
isExtended: false
}
isExtended: false,
};
}
toggleDetails () {
this.setState({ isExtended: !this.state.isExtended })
toggleDetails() {
this.setState({ isExtended: !this.state.isExtended });
}
renderItems (items) {
if (!items) return ''
renderItems(items) {
if (!items) return "";
return (
<div>
{items.map((item) => {
if (item.error) {
return (<p>{item.error.message}</p>)
return <p>{item.error.message}</p>;
}
return ''
return "";
})}
</div>
)
);
}
renderNotificationContent (notification) {
let { type, message, items } = notification
renderNotificationContent(notification) {
const { type, message, items } = notification;
return (
<div>
<div className={`message ${type}`}>
{message}
</div>
<div className={`message ${type}`}>{message}</div>
<div className={`details ${this.state.isExtended}`}>
{(items !== null) ? this.renderItems(items) : ''}
{items !== null ? this.renderItems(items) : ""}
</div>
</div>
)
);
}
render () {
if (!this.props.notifications) return null
const notificationsToRender = this.props.notifications.filter(n => !('isRead' in n && n.isRead))
render() {
if (!this.props.notifications) return null;
const notificationsToRender = this.props.notifications.filter(
(n) => !("isRead" in n && n.isRead)
);
if (notificationsToRender.length > 0) {
return (
<div className={`notification-wrapper`}>
<div className="notification-wrapper">
{this.props.notifications.map((notification) => {
return (
<div className='notification' onClick={() => this.toggleDetails()}>
<div
className="notification"
onClick={() => this.toggleDetails()}
>
<button
onClick={this.props.onToggle}
className='side-menu-burg over-white is-active'
className="side-menu-burg over-white is-active"
>
<span />
</button>
{this.renderNotificationContent(notification)}
</div>
)
})
}
);
})}
</div>
)
);
}
return (<div />)
return <div />;
}
}

View File

@@ -1,76 +1,94 @@
import React from 'react'
import { Player } from 'video-react'
import Img from 'react-image'
import Md from './Md'
import Spinner from '../presentational/Spinner'
import NoSource from '../presentational/NoSource'
import React from "react";
import { Player } from "video-react";
import Img from "react-image";
import Md from "./Md";
import Spinner from "../presentational/Spinner";
import NoSource from "../presentational/NoSource";
export default ({ media, viewIdx, translations, switchLanguage, langIdx }) => {
const el = document.querySelector(`.source-media-gallery`)
const shiftW = el ? el.getBoundingClientRect().width : 0
const el = document.querySelector(".source-media-gallery");
const shiftW = el ? el.getBoundingClientRect().width : 0;
function renderMedia (media) {
let { path, type, poster } = media
function renderMedia(media) {
const { path, type, poster } = media;
switch (type) {
case 'Image':
case "Image":
return (
<div className='source-image-container'>
<div className="source-image-container">
<Img
className='source-image'
className="source-image"
src={path}
loader={<div className='source-image-loader'><Spinner /></div>}
unloader={<NoSource failedUrls={[ path ]} />}
onClick={() => window.open(path, '_blank')}
loader={
<div className="source-image-loader">
<Spinner />
</div>
}
unloader={<NoSource failedUrls={[path]} />}
onClick={() => window.open(path, "_blank")}
/>
</div>
)
case 'Video':
);
case "Video":
return (
<div className='media-player'>
<div className='banner-trans right-overlay'>
{translations ? translations.map((trans, idx) => (
langIdx !== idx + 1 ? (
<div className='trans-button' onClick={() => switchLanguage(idx + 1)}>{trans.code}</div>
) : (
<div className='trans-button' onClick={() => switchLanguage(0)}>EN</div>
)
)) : null}
<div className="media-player">
<div className="banner-trans right-overlay">
{translations
? translations.map((trans, idx) =>
langIdx !== idx + 1 ? (
<div
className="trans-button"
onClick={() => switchLanguage(idx + 1)}
>
{trans.code}
</div>
) : (
<div
className="trans-button"
onClick={() => switchLanguage(0)}
>
EN
</div>
)
)
: null}
</div>
<Player
poster={poster}
className='source-video'
className="source-video"
playsInline
src={path}
/>
</div>
)
case 'Text':
);
case "Text":
return (
<div className='source-text-container'>
<div className="source-text-container">
<Md
path={path}
loader={<Spinner />}
unloader={() => this.renderError()}
/>
</div>
)
case 'Document':
return (
<iframe className='source-document' src={path} />
)
);
case "Document":
return <iframe className="source-document" src={path} />;
default:
return (
<NoSource failedUrls={[`Application does not support extension: ${path.split('.')[1]}`]} />
)
<NoSource
failedUrls={[
`Application does not support extension: ${path.split(".")[1]}`,
]}
/>
);
}
}
return (
<div
className='source-media-gallery'
className="source-media-gallery"
style={{ transform: `translate(${viewIdx * -shiftW}px)` }}
>
{media.map((m) => renderMedia(m))}
</div>
)
}
);
};

View File

@@ -1,36 +1,30 @@
import React from 'react'
import React from "react";
export default ({ viewIdx, paths, onShiftHandler }) => {
const backArrow = viewIdx !== 0 ? (
<div
className='back'
onClick={() => onShiftHandler(-1)}
>
<div className='centerer'>
<i className='material-icons'>arrow_left</i>
const backArrow =
viewIdx !== 0 ? (
<div className="back" onClick={() => onShiftHandler(-1)}>
<div className="centerer">
<i className="material-icons">arrow_left</i>
</div>
</div>
</div>
) : null
const forwardArrow = viewIdx < paths.length - 1 ? (
<div
className='next'
onClick={() => onShiftHandler(1)}
>
<div className='centerer'>
<i className='material-icons'>arrow_right</i>
) : null;
const forwardArrow =
viewIdx < paths.length - 1 ? (
<div className="next" onClick={() => onShiftHandler(1)}>
<div className="centerer">
<i className="material-icons">arrow_right</i>
</div>
</div>
</div>
) : null
) : null;
if (paths.length > 1) {
return (
<div className='media-gallery-controls'>
<div className="media-gallery-controls">
{backArrow}
{forwardArrow}
</div>
)
);
}
return (
<div className='media-gallery-controls' />
)
}
return <div className="media-gallery-controls" />;
};

View File

@@ -1,21 +1,23 @@
import React from 'react'
import copy from '../../common/data/copy.json'
import React from "react";
import copy from "../../common/data/copy.json";
const LoadingOverlay = ({ isLoading, language }) => {
let classes = 'loading-overlay'
classes += (!isLoading) ? ' hidden' : ''
let classes = "loading-overlay";
classes += !isLoading ? " hidden" : "";
return (
<div id='loading-overlay' className={classes}>
<div className='loading-wrapper'>
<span id='loading-text' className='text'>{copy[language].loading}</span>
<div className='spinner'>
<div className='double-bounce1' />
<div className='double-bounce2' />
<div id="loading-overlay" className={classes}>
<div className="loading-wrapper">
<span id="loading-text" className="text">
{copy[language].loading}
</span>
<div className="spinner">
<div className="double-bounce1" />
<div className="double-bounce2" />
</div>
</div>
</div>
)
}
);
};
export default LoadingOverlay
export default LoadingOverlay;

View File

@@ -1,36 +1,41 @@
/* global fetch */
import React from 'react'
import PropTypes from 'prop-types'
import marked from 'marked'
import React from "react";
import PropTypes from "prop-types";
import marked from "marked";
class Md extends React.Component {
constructor (props) {
super(props)
this.state = { md: null, error: null }
constructor(props) {
super(props);
this.state = { md: null, error: null };
}
componentDidMount () {
componentDidMount() {
fetch(this.props.path)
.then(resp => resp.text())
.then(text => {
if (text.length <= 0) { throw new Error() }
.then((resp) => resp.text())
.then((text) => {
if (text.length <= 0) {
throw new Error();
}
this.setState({ md: marked(text) })
this.setState({ md: marked(text) });
})
.catch(() => {
this.setState({ error: true })
})
this.setState({ error: true });
});
}
render () {
render() {
if (this.state.md && !this.state.error) {
return (
<div className='md-container' dangerouslySetInnerHTML={{ __html: this.state.md }} />
)
<div
className="md-container"
dangerouslySetInnerHTML={{ __html: this.state.md }}
/>
);
} else if (this.state.error) {
return this.props.unloader || <div>Error: couldn't load source</div>
return this.props.unloader || <div>Error: couldn't load source</div>;
} else {
return this.props.loader
return this.props.loader;
}
}
}
@@ -38,7 +43,7 @@ class Md extends React.Component {
Md.propTypes = {
loader: PropTypes.func,
unloader: PropTypes.func.isRequired,
path: PropTypes.string.isRequired
}
path: PropTypes.string.isRequired,
};
export default Md
export default Md;

View File

@@ -1,8 +1,8 @@
import React from 'react'
import marked from 'marked'
import Content from './Content'
import Controls from './Controls'
import { selectTypeFromPathWithPoster } from '../../common/utilities'
import React from "react";
import marked from "marked";
import Content from "./Content";
import Controls from "./Controls";
import { selectTypeFromPathWithPoster } from "../../common/utilities";
/*
* Inside the SourceOverlay, both the currently displaying media and language
@@ -10,95 +10,124 @@ import { selectTypeFromPathWithPoster } from '../../common/utilities'
* state.
*/
class SourceOverlay extends React.Component {
constructor () {
super()
this.state = { mediaIdx: 0, langIdx: 0 }
this.onShiftGallery = this.onShiftGallery.bind(this)
constructor() {
super();
this.state = { mediaIdx: 0, langIdx: 0 };
this.onShiftGallery = this.onShiftGallery.bind(this);
}
getTypeCounts (media) {
getTypeCounts(media) {
return media.reduce(
(acc, vl) => {
acc[vl.type] += 1
return acc
acc[vl.type] += 1;
return acc;
},
{ Image: 0, Video: 0, Text: 0 }
)
);
}
onShiftGallery (shift) {
onShiftGallery(shift) {
// no more left
if (this.state.mediaIdx === 0 && shift === -1) return
if (this.state.mediaIdx === 0 && shift === -1) return;
// no more right
if (this.state.mediaIdx === this.props.source.paths.length - 1 && shift === 1) return
this.setState({ mediaIdx: this.state.mediaIdx + shift })
if (
this.state.mediaIdx === this.props.source.paths.length - 1 &&
shift === 1
)
return;
this.setState({ mediaIdx: this.state.mediaIdx + shift });
}
switchLanguage (idx) {
this.setState({ langIdx: idx })
switchLanguage(idx) {
this.setState({ langIdx: idx });
}
renderContent (source) {
const { url, title, paths, date, type, poster, description } = source
const shortenedTitle = title.substring(0, 100)
renderContent(source) {
const { url, title, paths, date, type, poster, description } = source;
const shortenedTitle = title.substring(0, 100);
return (
<React.Fragment>
<div className='mo-banner'>
<div className='mo-banner-close' onClick={this.props.onCancel}>
<i className='material-icons'>close</i>
<>
<div className="mo-banner">
<div className="mo-banner-close" onClick={this.props.onCancel}>
<i className="material-icons">close</i>
</div>
<h3 className='mo-banner-content'>{shortenedTitle}</h3>
<h3 className="mo-banner-content">{shortenedTitle}</h3>
</div>
<div className='mo-container' onClick={e => e.stopPropagation()}>
<div className='mo-media-container'>
<div className="mo-container" onClick={(e) => e.stopPropagation()}>
<div className="mo-media-container">
<Content
switchLanguage={(lang) => this.switchLanguage(lang)}
translations={this.props.translations}
langIdx={this.state.langIdx}
media={paths.map(p => selectTypeFromPathWithPoster(p, poster))}
media={paths.map((p) => selectTypeFromPathWithPoster(p, poster))}
viewIdx={this.state.mediaIdx}
/>
</div>
</div>
<div className='mo-footer'>
<Controls paths={paths} viewIdx={this.state.mediaIdx} onShiftHandler={this.onShiftGallery} />
<div className="mo-footer">
<Controls
paths={paths}
viewIdx={this.state.mediaIdx}
onShiftHandler={this.onShiftGallery}
/>
<div className='mo-meta-container'>
{description ? <div className='mo-box-desc'>
<div className='md-container' dangerouslySetInnerHTML={{ __html: marked(description) }} />
</div> : null}
<div className="mo-meta-container">
{description ? (
<div className="mo-box-desc">
<div
className="md-container"
dangerouslySetInnerHTML={{ __html: marked(description) }}
/>
</div>
) : null}
{(type || date || url) ? (
<div className='mo-box'>
{type || date || url ? (
<div className="mo-box">
<div>
{type ? <h4>Evidence type</h4> : null}
{type ? <p><i className='material-icons left'>perm_media</i>{type}</p> : null}
{type ? (
<p>
<i className="material-icons left">perm_media</i>
{type}
</p>
) : null}
</div>
<div>
{date ? <h4>Date Published</h4> : null}
{date ? <p><i className='material-icons left'>today</i>{date}</p> : null}
{date ? (
<p>
<i className="material-icons left">today</i>
{date}
</p>
) : null}
</div>
<div>
{url ? <h4>Link</h4> : null}
{url ? <span><i className='material-icons left'>link</i><a href={url} target='_blank'>Link to original URL</a></span> : null}
{url ? (
<span>
<i className="material-icons left">link</i>
<a href={url} target="_blank">
Link to original URL
</a>
</span>
) : null}
</div>
</div>
) : null}
</div>
</div>
</React.Fragment>
)
</>
);
}
renderIntlContent () {
const { langIdx } = this.state
const { translations, source } = this.props
let translated = null
renderIntlContent() {
const { langIdx } = this.state;
const { translations, source } = this.props;
let translated = null;
if (translations && translations.length && langIdx > 0) {
translated = translations[langIdx - 1]
translated = translations[langIdx - 1];
}
if (translated) {
translated = {
@@ -106,24 +135,24 @@ class SourceOverlay extends React.Component {
poster: source.poster,
// NOTE: this is to allow a slightly nicer syntax when using the Media
// overlay in cover videos.
paths: translated.file ? [translated.file] : translated.paths
}
paths: translated.file ? [translated.file] : translated.paths,
};
}
return this.renderContent(langIdx === 0 ? source : translated)
return this.renderContent(langIdx === 0 ? source : translated);
}
render () {
if (typeof (this.props.source) !== 'object') {
return this.renderError()
render() {
if (typeof this.props.source !== "object") {
return this.renderError();
}
return (
<div className={`mo-overlay ${this.props.opaque ? 'opaque' : ''}`}>
<div className={`mo-overlay ${this.props.opaque ? "opaque" : ""}`}>
{this.renderIntlContent()}
</div>
)
);
}
}
export default SourceOverlay
export default SourceOverlay;

View File

@@ -1,76 +1,100 @@
import React from 'react'
import React from "react";
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import * as actions from '../actions'
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
import * as actions from "../actions";
import '../scss/search.scss'
import "../scss/search.scss";
import SearchRow from './SearchRow.jsx'
import SearchRow from "./SearchRow.jsx";
class Search extends React.Component {
constructor (props) {
super(props)
constructor(props) {
super(props);
this.state = {
isFolded: true
}
this.onButtonClick = this.onButtonClick.bind(this)
this.updateSearchQuery = this.updateSearchQuery.bind(this)
isFolded: true,
};
this.onButtonClick = this.onButtonClick.bind(this);
this.updateSearchQuery = this.updateSearchQuery.bind(this);
}
onButtonClick () {
this.setState(prevState => {
return { isFolded: !prevState.isFolded }
})
onButtonClick() {
this.setState((prevState) => {
return { isFolded: !prevState.isFolded };
});
}
updateSearchQuery (e) {
let queryString = e.target.value
this.props.actions.updateSearchQuery(queryString)
updateSearchQuery(e) {
const queryString = e.target.value;
this.props.actions.updateSearchQuery(queryString);
}
render () {
let searchResults
render() {
let searchResults;
const searchAttributes = ['description', 'location', 'category', 'date']
const searchAttributes = ["description", "location", "category", "date"];
if (!this.props.queryString) {
searchResults = []
searchResults = [];
} else {
searchResults = this.props.events.filter(event =>
searchAttributes.some(attribute => event[attribute].toLowerCase().includes(this.props.queryString.toLowerCase()))
)
searchResults = this.props.events.filter((event) =>
searchAttributes.some((attribute) =>
event[attribute]
.toLowerCase()
.includes(this.props.queryString.toLowerCase())
)
);
}
return (
<div class={'search-outer-container' + (this.props.narrative ? ' narrative-mode ' : '')}>
<div id='search-bar-icon-container' onClick={this.onButtonClick}>
<i className='material-icons'>search</i>
<div
class={
"search-outer-container" +
(this.props.narrative ? " narrative-mode " : "")
}
>
<div id="search-bar-icon-container" onClick={this.onButtonClick}>
<i className="material-icons">search</i>
</div>
<div class={'search-bar-overlay' + (this.state.isFolded ? ' folded' : '')}>
<div class='search-input-container'>
<input class='search-bar-input' onChange={this.updateSearchQuery} type='text' />
<i id='close-search-overlay' className='material-icons' onClick={this.onButtonClick} >close</i>
<div
class={"search-bar-overlay" + (this.state.isFolded ? " folded" : "")}
>
<div class="search-input-container">
<input
class="search-bar-input"
onChange={this.updateSearchQuery}
type="text"
/>
<i
id="close-search-overlay"
className="material-icons"
onClick={this.onButtonClick}
>
close
</i>
</div>
<div class='search-results'>
{searchResults.map(result => {
return <SearchRow onSearchRowClick={this.props.onSearchRowClick} eventObj={result} query={this.props.queryString} />
<div class="search-results">
{searchResults.map((result) => {
return (
<SearchRow
onSearchRowClick={this.props.onSearchRowClick}
eventObj={result}
query={this.props.queryString}
/>
);
})}
</div>
</div>
</div>
)
);
}
}
function mapDispatchToProps (dispatch) {
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(actions, dispatch)
}
actions: bindActionCreators(actions, dispatch),
};
}
export default connect(
state => state,
mapDispatchToProps
)(Search)
export default connect((state) => state, mapDispatchToProps)(Search);

View File

@@ -1,40 +1,62 @@
import React from 'react'
import React from "react";
const SearchRow = ({ query, eventObj, onSearchRowClick }) => {
const { description, location, date } = eventObj
function getHighlightedText (text, highlight) {
const { description, location, date } = eventObj;
function getHighlightedText(text, highlight) {
// Split text on highlight term, include term itself into parts, ignore case
const parts = text.split(new RegExp(`(${highlight})`, 'gi'))
return <span>{ parts.map(part => part.toLowerCase() === highlight.toLowerCase() ? <span style={{ backgroundColor: 'yellow', color: 'black' }}>{part}</span> : part) }</span>
const parts = text.split(new RegExp(`(${highlight})`, "gi"));
return (
<span>
{parts.map((part) =>
part.toLowerCase() === highlight.toLowerCase() ? (
<span style={{ backgroundColor: "yellow", color: "black" }}>
{part}
</span>
) : (
part
)
)}
</span>
);
}
function getShortDescription (text, searchQuery) {
var regexp = new RegExp(`(([^ ]* ){0,6}[a-zA-Z]*${searchQuery.toLowerCase()}[a-zA-Z]*( [^ ]*){0,5})`, 'gm')
let parts = text.toLowerCase().match(regexp)
for (var x = 0; x < (parts ? parts.length : 0); x++) {
parts[x] = '...' + parts[x]
function getShortDescription(text, searchQuery) {
const regexp = new RegExp(
`(([^ ]* ){0,6}[a-zA-Z]*${searchQuery.toLowerCase()}[a-zA-Z]*( [^ ]*){0,5})`,
"gm"
);
const parts = text.toLowerCase().match(regexp);
for (let x = 0; x < (parts ? parts.length : 0); x++) {
parts[x] = "..." + parts[x];
}
const firstLine = [text.match('(([^ ]* ){0,10})', 'm')[0]]
return parts || firstLine
const firstLine = [text.match("(([^ ]* ){0,10})", "m")[0]];
return parts || firstLine;
}
return (
<div className='search-row' onClick={() => onSearchRowClick([eventObj])}>
<div className='location-date-container'>
<div className='date-container'>
<i className='material-icons'>event</i>
<div className="search-row" onClick={() => onSearchRowClick([eventObj])}>
<div className="location-date-container">
<div className="date-container">
<i className="material-icons">event</i>
<p>{getHighlightedText(date, query)}</p>
</div>
<div className='location-container'>
<i className='material-icons'>location_on</i>
<div className="location-container">
<i className="material-icons">location_on</i>
<p>{getHighlightedText(location, query)}</p>
</div>
</div>
<p>{getShortDescription(description, query).map(match => {
return <span>{getHighlightedText(match, query)}...<br /></span>
})}</p>
<p>
{getShortDescription(description, query).map((match) => {
return (
<span>
{getHighlightedText(match, query)}...
<br />
</span>
);
})}
</p>
</div>
)
}
);
};
export default SearchRow
export default SearchRow;

View File

@@ -1,19 +1,28 @@
import React, { useState } from 'react'
import React, { useState } from "react";
export default ({ showing, onClickHandler, timelineDims }) => {
if (!showing) {
return null
return null;
}
const [checked, setChecked] = useState(false)
const handleCheck = () => setChecked(!checked)
const onNarrativise = () => onClickHandler(checked)
const [checked, setChecked] = useState(false);
const handleCheck = () => setChecked(!checked);
const onNarrativise = () => onClickHandler(checked);
return <div className='stateoptions-panel' style={{ bottom: timelineDims.height }}>
<div>
<div className='button' onClick={onNarrativise}>Narrativise</div>
<label for='withlines'>Connect by lines</label>
<input name='withlines' onClick={handleCheck} checked={checked} type='checkbox' />
return (
<div className="stateoptions-panel" style={{ bottom: timelineDims.height }}>
<div>
<div className="button" onClick={onNarrativise}>
Narrativise
</div>
<label for="withlines">Connect by lines</label>
<input
name="withlines"
onClick={handleCheck}
checked={checked}
type="checkbox"
/>
</div>
</div>
</div>
}
);
};

View File

@@ -1,9 +1,9 @@
import React from 'react'
import React from "react";
export default ({ showing, children }) => {
return (
<div className={`cover-container ${showing ? 'showing' : ''}`}>
<div className={`cover-container ${showing ? "showing" : ""}`}>
{children}
</div>
)
}
);
};

View File

@@ -1,11 +1,11 @@
import React from 'react'
import { connect } from 'react-redux'
import { Player } from 'video-react'
import marked from 'marked'
import MediaOverlay from './Overlay/Media'
import falogo from '../assets/fa-logo.png'
import bcatlogo from '../assets/bellingcat-logo.png'
const MEDIA_HIDDEN = -2
import React from "react";
import { connect } from "react-redux";
import { Player } from "video-react";
import marked from "marked";
import MediaOverlay from "./Overlay/Media";
import falogo from "../assets/fa-logo.png";
import bcatlogo from "../assets/bellingcat-logo.png";
const MEDIA_HIDDEN = -2;
/**
* Manages the presentation of props that come in from the store's app.cover.
@@ -14,211 +14,260 @@ const MEDIA_HIDDEN = -2
* a couple of weird offset calculations... but it works for the time being.
*/
class TemplateCover extends React.Component {
constructor (props) {
super(props)
constructor(props) {
super(props);
this.state = {
video: MEDIA_HIDDEN,
featureLang: 0
}
featureLang: 0,
};
}
getVideo (index, headerEndIndex) {
getVideo(index, headerEndIndex) {
if (index < headerEndIndex) {
return this.props.cover.headerVideos[index]
return this.props.cover.headerVideos[index];
} else if (index >= 0) {
return this.props.cover.videos[index - headerEndIndex]
return this.props.cover.videos[index - headerEndIndex];
} else {
return null
return null;
}
}
onVideoClickHandler (index) {
const buffer = this.props.cover.headerVideos ? this.props.cover.headerVideos.length : 0
onVideoClickHandler(index) {
const buffer = this.props.cover.headerVideos
? this.props.cover.headerVideos.length
: 0;
return () => {
this.setState({
video: index + buffer
})
}
video: index + buffer,
});
};
}
renderFeature () {
const { featureVideo } = this.props.cover
const { featureLang } = this.state
const { translations } = featureVideo
const source = featureLang === 0
? featureVideo
: {
...translations[featureLang - 1],
poster: featureVideo.poster
}
renderFeature() {
const { featureVideo } = this.props.cover;
const { featureLang } = this.state;
const { translations } = featureVideo;
const source =
featureLang === 0
? featureVideo
: {
...translations[featureLang - 1],
poster: featureVideo.poster,
};
return (
<div>
<div className='banner-trans right-overlay'>
{translations && translations.map((trans, idx) => {
const langIdx = idx + 1 // default lang idx is 0
if (featureLang !== langIdx) {
return <div onClick={() => this.setState({ featureLang: langIdx })} className='trans-button'>{trans.code}</div>
} else {
return <div onClick={() => this.setState({ featureLang: 0 })} className='trans-button'>ENG</div>
}
})}
<div className="banner-trans right-overlay">
{translations &&
translations.map((trans, idx) => {
const langIdx = idx + 1; // default lang idx is 0
if (featureLang !== langIdx) {
return (
<div
onClick={() => this.setState({ featureLang: langIdx })}
className="trans-button"
>
{trans.code}
</div>
);
} else {
return (
<div
onClick={() => this.setState({ featureLang: 0 })}
className="trans-button"
>
ENG
</div>
);
}
})}
</div>
<Player
className='source-video'
className="source-video"
poster={source.poster}
playsInline
src={source.file}
/>
</div>
)
);
}
renderHeaderVideos () {
const { headerVideos } = this.props.cover
renderHeaderVideos() {
const { headerVideos } = this.props.cover;
return (
<div className='row'>
{ headerVideos.slice(0, 2).map((media, index) => (
<div className='cell plain' onClick={() => this.setState({ video: index })}>
<div className="row">
{headerVideos.slice(0, 2).map((media, index) => (
<div
className="cell plain"
onClick={() => this.setState({ video: index })}
>
{media.buttonTitle}
</div>
)) }
))}
</div>
)
);
}
renderButton (button, yellow) {
renderButton(button, yellow) {
return (
<div className='row'>
<a className={`cell ${yellow ? 'yellow' : 'plain'}`} href={button.href}>
<div className="row">
<a className={`cell ${yellow ? "yellow" : "plain"}`} href={button.href}>
{button.title}
</a>
</div>
)
);
}
renderMediaOverlay () {
const video = this.getVideo(this.state.video, this.props.cover.headerVideos ? this.props.cover.headerVideos.length : 0)
renderMediaOverlay() {
const video = this.getVideo(
this.state.video,
this.props.cover.headerVideos ? this.props.cover.headerVideos.length : 0
);
return (
<MediaOverlay
opaque
source={
{
title: video.title,
desc: video.desc,
paths: [video.file],
poster: video.poster
}}
source={{
title: video.title,
desc: video.desc,
paths: [video.file],
poster: video.poster,
}}
translations={video.translations}
onCancel={() => this.setState({ video: MEDIA_HIDDEN })}
/>
)
);
}
render () {
render() {
if (!this.props.cover) {
return (
<div className='default-cover-container'>
You haven't specified any cover props. Put them in the values that overwrite the store in <code>app.cover</code>
<div className="default-cover-container">
You haven't specified any cover props. Put them in the values that
overwrite the store in <code>app.cover</code>
</div>
)
);
}
const { videos, footerButton } = this.props.cover
const { showing } = this.props
const { videos, footerButton } = this.props.cover;
const { showing } = this.props;
return (
<div className='default-cover-container'>
<div className={showing ? 'cover-header' : 'cover-header minimized'}>
<a className='cover-logo-container' href='https://forensic-architecture.org'>
<img className='cover-logo' src={falogo} />
<div className="default-cover-container">
<div className={showing ? "cover-header" : "cover-header minimized"}>
<a
className="cover-logo-container"
href="https://forensic-architecture.org"
>
<img className="cover-logo" src={falogo} />
</a>
<a className='cover-logo-container' href='https://bellingcat.com'>
<img className='cover-logo' src={bcatlogo} />
<a className="cover-logo-container" href="https://bellingcat.com">
<img className="cover-logo" src={bcatlogo} />
</a>
</div>
<div className='cover-content'>
{
this.props.cover.bgVideo ? (
<div className={`fullscreen-bg ${!this.props.showing ? 'hidden' : ''}`}>
<video
loop
muted
autoPlay
preload='auto'
className='fullscreen-bg__video'
>
<source src={this.props.cover.bgVideo} type='video/mp4' />
</video>
</div>
) : null
}
<h2 style={{ margin: 0 }} dangerouslySetInnerHTML={{ __html: marked(this.props.cover.title) }} />
{
this.props.cover.subtitle ? (
<h3 style={{ marginTop: 0 }}>{this.props.cover.subtitle}</h3>
) : null
}
{
this.props.cover.subsubtitle ? (
<h5>{this.props.cover.subsubtitle}</h5>
) : null
}
<div className="cover-content">
{this.props.cover.bgVideo ? (
<div
className={`fullscreen-bg ${!this.props.showing ? "hidden" : ""}`}
>
<video
loop
muted
autoPlay
preload="auto"
className="fullscreen-bg__video"
>
<source src={this.props.cover.bgVideo} type="video/mp4" />
</video>
</div>
) : null}
<h2
style={{ margin: 0 }}
dangerouslySetInnerHTML={{ __html: marked(this.props.cover.title) }}
/>
{this.props.cover.subtitle ? (
<h3 style={{ marginTop: 0 }}>{this.props.cover.subtitle}</h3>
) : null}
{this.props.cover.subsubtitle ? (
<h5>{this.props.cover.subsubtitle}</h5>
) : null}
{this.props.cover.featureVideo ? this.renderFeature() : null}
<div className='hero thin'>
<div className="hero thin">
{this.props.cover.headerVideos ? this.renderHeaderVideos() : null}
{this.props.cover.headerButton ? this.renderButton(this.props.cover.headerButton) : null}
<div className='row'>
<div className='cell yellow' onClick={this.props.showAppHandler}>
{this.props.cover.headerButton
? this.renderButton(this.props.cover.headerButton)
: null}
<div className="row">
<div className="cell yellow" onClick={this.props.showAppHandler}>
{this.props.cover.exploreButton}
</div>
</div>
</div>
{Array.isArray(this.props.cover.description)
? this.props.cover.description.map(e => <div className='md-container' dangerouslySetInnerHTML={{ __html: marked(e) }} />)
: <div className='md-container' dangerouslySetInnerHTML={{ __html: marked(this.props.cover.description) }} />}
{Array.isArray(this.props.cover.description) ? (
this.props.cover.description.map((e) => (
<div
className="md-container"
dangerouslySetInnerHTML={{ __html: marked(e) }}
/>
))
) : (
<div
className="md-container"
dangerouslySetInnerHTML={{
__html: marked(this.props.cover.description),
}}
/>
)}
{videos ? (
<div className='hero'>
<div className='row'>
<div className="hero">
<div className="row">
{/* NOTE: only take first four videos, drop any others for style reasons */}
{ videos && videos.slice(0, 2).map((media, index) => (
<div className='cell small' onClick={this.onVideoClickHandler(index)} >
{media.buttonTitle}<br />{media.buttonSubtitle}
</div>
)) }
{videos &&
videos.slice(0, 2).map((media, index) => (
<div
className="cell small"
onClick={this.onVideoClickHandler(index)}
>
{media.buttonTitle}
<br />
{media.buttonSubtitle}
</div>
))}
</div>
<div className='row'>
{ videos.length > 2 && this.props.cover.videos.slice(2, 4).map((media, index) => (
<div className='cell small' onClick={this.onVideoClickHandler(index + 2)} >
{media.buttonTitle}<br />{media.buttonSubtitle}
</div>
)) }
<div className="row">
{videos.length > 2 &&
this.props.cover.videos.slice(2, 4).map((media, index) => (
<div
className="cell small"
onClick={this.onVideoClickHandler(index + 2)}
>
{media.buttonTitle}
<br />
{media.buttonSubtitle}
</div>
))}
</div>
</div>
) : null}
{footerButton ? (
<div className='hero'>
<div className='row'>
{this.renderButton(footerButton)}
</div>
<div className="hero">
<div className="row">{this.renderButton(footerButton)}</div>
</div>
) : null}
</div>
{
this.state.video !== MEDIA_HIDDEN ? this.renderMediaOverlay() : null }
{this.state.video !== MEDIA_HIDDEN ? this.renderMediaOverlay() : null}
</div>
)
);
}
}
function mapStateToProps (state) {
function mapStateToProps(state) {
return {
cover: state.app.cover
}
cover: state.app.cover,
};
}
export default connect(mapStateToProps)(TemplateCover)
export default connect(mapStateToProps)(TemplateCover);

View File

@@ -1,29 +1,29 @@
import React from 'react'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import * as d3 from 'd3'
import * as selectors from '../selectors'
import { setLoading, setNotLoading } from '../actions'
import hash from 'object-hash'
import React from "react";
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
import * as d3 from "d3";
import * as selectors from "../selectors";
import { setLoading, setNotLoading } from "../actions";
import hash from "object-hash";
import copy from '../common/data/copy.json'
import Header from './presentational/Timeline/Header'
import Axis from './TimelineAxis.jsx'
import Clip from './presentational/Timeline/Clip'
import Handles from './presentational/Timeline/Handles.js'
import ZoomControls from './presentational/Timeline/ZoomControls.js'
import Markers from './presentational/Timeline/Markers.js'
import Events from './presentational/Timeline/Events.js'
import Categories from './TimelineCategories.jsx'
import copy from "../common/data/copy.json";
import Header from "./presentational/Timeline/Header";
import Axis from "./TimelineAxis.jsx";
import Clip from "./presentational/Timeline/Clip";
import Handles from "./presentational/Timeline/Handles.js";
import ZoomControls from "./presentational/Timeline/ZoomControls.js";
import Markers from "./presentational/Timeline/Markers.js";
import Events from "./presentational/Timeline/Events.js";
import Categories 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.getY = this.getY.bind(this)
this.onApplyZoom = this.onApplyZoom.bind(this)
this.svgRef = React.createRef()
constructor(props) {
super(props);
this.styleDatetime = this.styleDatetime.bind(this);
this.getDatetimeX = this.getDatetimeX.bind(this);
this.getY = this.getY.bind(this);
this.onApplyZoom = this.onApplyZoom.bind(this);
this.svgRef = React.createRef();
this.state = {
isFolded: false,
dims: props.dimensions,
@@ -31,103 +31,128 @@ class Timeline extends React.Component {
scaleY: null,
timerange: [null, null], // two datetimes
dragPos0: null,
transitionDuration: 300
}
transitionDuration: 300,
};
}
componentDidMount () {
this.addEventListeners()
componentDidMount() {
this.addEventListeners();
}
componentWillReceiveProps (nextProps) {
componentWillReceiveProps(nextProps) {
if (hash(nextProps) !== hash(this.props)) {
this.setState({
timerange: nextProps.app.timeline.range,
scaleX: this.makeScaleX()
})
scaleX: this.makeScaleX(),
});
}
if ((hash(nextProps.domain.categories) !== hash(this.props.domain.categories)) || hash(nextProps.dimensions) !== hash(this.props.dimensions)) {
const { trackHeight, marginTop } = nextProps.dimensions
if (
hash(nextProps.domain.categories) !==
hash(this.props.domain.categories) ||
hash(nextProps.dimensions) !== hash(this.props.dimensions)
) {
const { trackHeight, marginTop } = nextProps.dimensions;
this.setState({
scaleY: this.makeScaleY(nextProps.domain.categories, trackHeight, marginTop)
})
scaleY: this.makeScaleY(
nextProps.domain.categories,
trackHeight,
marginTop
),
});
}
if (nextProps.dimensions.trackHeight !== this.props.dimensions.trackHeight) {
this.computeDims()
if (
nextProps.dimensions.trackHeight !== this.props.dimensions.trackHeight
) {
this.computeDims();
}
}
addEventListeners () {
window.addEventListener('resize', () => { this.computeDims() })
let element = document.querySelector('.timeline-wrapper')
addEventListeners() {
window.addEventListener("resize", () => {
this.computeDims();
});
const element = document.querySelector(".timeline-wrapper");
if (element !== null) {
element.addEventListener('transitionend', (event) => {
this.computeDims()
})
element.addEventListener("transitionend", (event) => {
this.computeDims();
});
}
}
makeScaleX () {
return d3.scaleTime()
makeScaleX() {
return d3
.scaleTime()
.domain(this.state.timerange)
.range([this.state.dims.marginLeft, this.state.dims.width - this.state.dims.width_controls])
.range([
this.state.dims.marginLeft,
this.state.dims.width - this.state.dims.width_controls,
]);
}
makeScaleY (categories, trackHeight, marginTop) {
const { features } = this.props
makeScaleY(categories, trackHeight, marginTop) {
const { features } = this.props;
if (features.GRAPH_NONLOCATED && features.GRAPH_NONLOCATED.categories) {
categories = categories.filter(cat => !features.GRAPH_NONLOCATED.categories.includes(cat.id))
categories = categories.filter(
(cat) => !features.GRAPH_NONLOCATED.categories.includes(cat.id)
);
}
const extraPadding = 0
const catHeight = categories.length > 2 ? trackHeight / categories.length : trackHeight / (categories.length + 1)
const extraPadding = 0;
const catHeight =
categories.length > 2
? trackHeight / categories.length
: trackHeight / (categories.length + 1);
const catsYpos = categories.map((g, i) => {
return ((i + 1) * catHeight) + marginTop + (extraPadding / 2)
})
const catMap = categories.map(c => c.id)
return (i + 1) * catHeight + marginTop + extraPadding / 2;
});
const catMap = categories.map((c) => c.id);
return (cat) => {
const idx = catMap.indexOf(cat)
return catsYpos[idx]
}
const idx = catMap.indexOf(cat);
return catsYpos[idx];
};
}
componentDidUpdate (prevProps, prevState) {
componentDidUpdate(prevProps, prevState) {
if (prevState.timerange !== this.state.timerange) {
this.setState({ scaleX: this.makeScaleX() })
this.setState({ scaleX: this.makeScaleX() });
}
}
/**
* Returns the time scale (x) extent in minutes
*/
getTimeScaleExtent () {
if (!this.state.scaleX) return 0
const timeDomain = this.state.scaleX.domain()
return (timeDomain[1].getTime() - timeDomain[0].getTime()) / 60000
getTimeScaleExtent() {
if (!this.state.scaleX) return 0;
const timeDomain = this.state.scaleX.domain();
return (timeDomain[1].getTime() - timeDomain[0].getTime()) / 60000;
}
onClickArrow () {
onClickArrow() {
this.setState((prevState, props) => {
return { isFolded: !prevState.isFolded }
})
return { isFolded: !prevState.isFolded };
});
}
computeDims () {
const dom = this.props.ui.dom.timeline
computeDims() {
const dom = this.props.ui.dom.timeline;
if (document.querySelector(`#${dom}`) !== null) {
const boundingClient = document.querySelector(`#${dom}`).getBoundingClientRect()
const boundingClient = document
.querySelector(`#${dom}`)
.getBoundingClientRect();
this.setState({
dims: {
...this.props.dimensions,
width: boundingClient.width
this.setState(
{
dims: {
...this.props.dimensions,
width: boundingClient.width,
},
},
() => {
this.setState({ scaleX: this.makeScaleX() });
}
},
() => {
this.setState({ scaleX: this.makeScaleX() })
})
);
}
}
@@ -135,34 +160,37 @@ class Timeline extends React.Component {
* Shift time range by moving forward or backwards
* @param {String} direction: 'forward' / 'backwards'
*/
onMoveTime (direction) {
const extent = this.getTimeScaleExtent()
const newCentralTime = d3.timeMinute.offset(this.state.scaleX.domain()[0], extent / 2)
onMoveTime(direction) {
const extent = this.getTimeScaleExtent();
const newCentralTime = d3.timeMinute.offset(
this.state.scaleX.domain()[0],
extent / 2
);
// if forward
let domain0 = newCentralTime
let domainF = d3.timeMinute.offset(newCentralTime, extent)
let domain0 = newCentralTime;
let domainF = d3.timeMinute.offset(newCentralTime, extent);
// if backwards
if (direction === 'backwards') {
domain0 = d3.timeMinute.offset(newCentralTime, -extent)
domainF = newCentralTime
if (direction === "backwards") {
domain0 = d3.timeMinute.offset(newCentralTime, -extent);
domainF = newCentralTime;
}
this.setState({ timerange: [domain0, domainF] }, () => {
this.props.methods.onUpdateTimerange(this.state.timerange)
})
this.props.methods.onUpdateTimerange(this.state.timerange);
});
}
onCenterTime (newCentralTime) {
const extent = this.getTimeScaleExtent()
onCenterTime(newCentralTime) {
const extent = this.getTimeScaleExtent();
const domain0 = d3.timeMinute.offset(newCentralTime, -extent / 2)
const domainF = d3.timeMinute.offset(newCentralTime, +extent / 2)
const domain0 = d3.timeMinute.offset(newCentralTime, -extent / 2);
const domainF = d3.timeMinute.offset(newCentralTime, +extent / 2);
this.setState({ timerange: [domain0, domainF] }, () => {
this.props.methods.onUpdateTimerange(this.state.timerange)
})
this.props.methods.onUpdateTimerange(this.state.timerange);
});
}
/**
@@ -170,119 +198,132 @@ class Timeline extends React.Component {
* WITHOUT updating the store, or data shown.
* Used for updates in the middle of a transition, for performance purposes
*/
onSoftTimeRangeUpdate (timerange) {
this.setState({ timerange })
onSoftTimeRangeUpdate(timerange) {
this.setState({ timerange });
}
/**
* Apply zoom level to timeline
* @param {object} zoom: zoom level from zoomLevels
*/
onApplyZoom (zoom) {
const extent = this.getTimeScaleExtent()
const newCentralTime = d3.timeMinute.offset(this.state.scaleX.domain()[0], extent / 2)
const { rangeLimits } = this.props.app.timeline
onApplyZoom(zoom) {
const extent = this.getTimeScaleExtent();
const newCentralTime = d3.timeMinute.offset(
this.state.scaleX.domain()[0],
extent / 2
);
const { rangeLimits } = this.props.app.timeline;
let newDomain0 = d3.timeMinute.offset(newCentralTime, -zoom.duration / 2)
let newDomainF = d3.timeMinute.offset(newCentralTime, zoom.duration / 2)
let newDomain0 = d3.timeMinute.offset(newCentralTime, -zoom.duration / 2);
let newDomainF = d3.timeMinute.offset(newCentralTime, zoom.duration / 2);
if (rangeLimits) {
// If the store contains absolute time limits,
// make sure the zoom doesn't go over them
const minDate = rangeLimits[0]
const maxDate = rangeLimits[1]
const minDate = rangeLimits[0];
const maxDate = rangeLimits[1];
if (newDomain0 < minDate) {
newDomain0 = minDate
newDomainF = d3.timeMinute.offset(newDomain0, zoom.duration)
newDomain0 = minDate;
newDomainF = d3.timeMinute.offset(newDomain0, zoom.duration);
}
if (newDomainF > maxDate) {
newDomainF = maxDate
newDomain0 = d3.timeMinute.offset(newDomainF, -zoom.duration)
newDomainF = maxDate;
newDomain0 = d3.timeMinute.offset(newDomainF, -zoom.duration);
}
}
this.setState({ timerange: [
newDomain0,
newDomainF
] }, () => {
this.props.methods.onUpdateTimerange(this.state.timerange)
})
this.setState(
{
timerange: [newDomain0, newDomainF],
},
() => {
this.props.methods.onUpdateTimerange(this.state.timerange);
}
);
}
toggleTransition (isTransition) {
this.setState({ transitionDuration: (isTransition) ? 300 : 0 })
toggleTransition(isTransition) {
this.setState({ transitionDuration: isTransition ? 300 : 0 });
}
/*
* Setup drag behavior
*/
onDragStart () {
d3.event.sourceEvent.stopPropagation()
this.setState({
dragPos0: d3.event.x
}, () => {
this.toggleTransition(false)
})
onDragStart() {
d3.event.sourceEvent.stopPropagation();
this.setState(
{
dragPos0: d3.event.x,
},
() => {
this.toggleTransition(false);
}
);
}
/*
* Drag and update
*/
onDrag () {
const drag0 = this.state.scaleX.invert(this.state.dragPos0).getTime()
const dragNow = this.state.scaleX.invert(d3.event.x).getTime()
const timeShift = (drag0 - dragNow) / 1000
onDrag() {
const drag0 = this.state.scaleX.invert(this.state.dragPos0).getTime();
const dragNow = this.state.scaleX.invert(d3.event.x).getTime();
const timeShift = (drag0 - dragNow) / 1000;
const { range, rangeLimits } = this.props.app.timeline
let newDomain0 = d3.timeSecond.offset(range[0], timeShift)
let newDomainF = d3.timeSecond.offset(range[1], timeShift)
const { range, rangeLimits } = this.props.app.timeline;
let newDomain0 = d3.timeSecond.offset(range[0], timeShift);
let newDomainF = d3.timeSecond.offset(range[1], timeShift);
if (rangeLimits) {
// If the store contains absolute time limits,
// make sure the zoom doesn't go over them
const minDate = rangeLimits[0]
const maxDate = rangeLimits[1]
const minDate = rangeLimits[0];
const maxDate = rangeLimits[1];
newDomain0 = (newDomain0 < minDate) ? minDate : newDomain0
newDomainF = (newDomainF > maxDate) ? maxDate : newDomainF
newDomain0 = newDomain0 < minDate ? minDate : newDomain0;
newDomainF = newDomainF > maxDate ? maxDate : newDomainF;
}
// Updates components without updating timerange
this.onSoftTimeRangeUpdate([newDomain0, newDomainF])
this.onSoftTimeRangeUpdate([newDomain0, newDomainF]);
}
/**
* Stop dragging and update data
*/
onDragEnd () {
this.toggleTransition(true)
this.props.methods.onUpdateTimerange(this.state.timerange)
onDragEnd() {
this.toggleTransition(true);
this.props.methods.onUpdateTimerange(this.state.timerange);
}
getDatetimeX (datetime) {
return this.state.scaleX(datetime)
getDatetimeX(datetime) {
return this.state.scaleX(datetime);
}
getY (event) {
const { features, domain } = this.props
const { USE_CATEGORIES, GRAPH_NONLOCATED } = features
const { categories } = domain
const categoriesExist = USE_CATEGORIES && categories && categories.length > 0
getY(event) {
const { features, domain } = this.props;
const { USE_CATEGORIES, GRAPH_NONLOCATED } = features;
const { categories } = domain;
const categoriesExist =
USE_CATEGORIES && categories && categories.length > 0;
if (!categoriesExist) {
return this.state.dims.trackHeight / 2
return this.state.dims.trackHeight / 2;
}
const { category } = 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
const { project } = event;
return (
this.state.dims.marginTop +
domain.projects[project].offset +
this.props.ui.eventRadius
);
}
if (!this.state.scaleY) return 0
if (!this.state.scaleY) return 0;
return this.state.scaleY(category)
return this.state.scaleY(category);
}
/**
@@ -294,39 +335,44 @@ class Timeline extends React.Component {
* at the second index is an optional additional component that renders in
* the <g/> div.
*/
styleDatetime (timestamp, category) {
return [null, null]
styleDatetime(timestamp, category) {
return [null, null];
}
render () {
const { isNarrative, app } = this.props
let classes = `timeline-wrapper ${(this.state.isFolded) ? ' folded' : ''}`
classes += (app.narrative !== null) ? ' narrative-mode' : ''
const { dims } = this.state
const foldedStyle = { bottom: this.state.isFolded ? -dims.height : 0 }
const heightStyle = { height: dims.height }
const extraStyle = { ...heightStyle, ...foldedStyle }
const contentHeight = { height: dims.contentHeight }
const { categories } = this.props.domain
render() {
const { isNarrative, app } = this.props;
let classes = `timeline-wrapper ${this.state.isFolded ? " folded" : ""}`;
classes += app.narrative !== null ? " narrative-mode" : "";
const { dims } = this.state;
const foldedStyle = { bottom: this.state.isFolded ? -dims.height : 0 };
const heightStyle = { height: dims.height };
const extraStyle = { ...heightStyle, ...foldedStyle };
const contentHeight = { height: dims.contentHeight };
const { categories } = this.props.domain;
return (
<div className={classes} style={extraStyle} onKeyDown={this.props.onKeyDown} tabIndex='1'>
<div
className={classes}
style={extraStyle}
onKeyDown={this.props.onKeyDown}
tabIndex="1"
>
<Header
title={copy[this.props.app.language].timeline.info}
from={this.state.timerange[0]}
to={this.state.timerange[1]}
onClick={() => { this.onClickArrow() }}
onClick={() => {
this.onClickArrow();
}}
hideInfo={isNarrative}
/>
<div className='timeline-content' style={heightStyle}>
<div id={this.props.ui.dom.timeline} className='timeline' style={contentHeight} >
<svg
ref={this.svgRef}
width={dims.width}
style={contentHeight}
>
<Clip
dims={dims}
/>
<div className="timeline-content" style={heightStyle}>
<div
id={this.props.ui.dom.timeline}
className="timeline"
style={contentHeight}
>
<svg ref={this.svgRef} width={dims.width} style={contentHeight}>
<Clip dims={dims} />
<Axis
dims={dims}
extent={this.getTimeScaleExtent()}
@@ -335,17 +381,30 @@ class Timeline extends React.Component {
/>
<Categories
dims={dims}
getCategoryY={category => this.getY({ category, project: null })}
onDragStart={() => { this.onDragStart() }}
onDrag={() => { this.onDrag() }}
onDragEnd={() => { this.onDragEnd() }}
categories={categories.map(c => c.id)}
getCategoryY={(category) =>
this.getY({ category, project: null })
}
onDragStart={() => {
this.onDragStart();
}}
onDrag={() => {
this.onDrag();
}}
onDragEnd={() => {
this.onDragEnd();
}}
categories={categories.map((c) => c.id)}
features={this.props.features}
fallbackLabel={copy[this.props.app.language].timeline.default_categories_label}
fallbackLabel={
copy[this.props.app.language].timeline
.default_categories_label
}
/>
<Handles
dims={dims}
onMoveTime={(dir) => { this.onMoveTime(dir) }}
onMoveTime={(dir) => {
this.onMoveTime(dir);
}}
/>
<ZoomControls
extent={this.getTimeScaleExtent()}
@@ -356,7 +415,7 @@ class Timeline extends React.Component {
<Markers
dims={dims}
selected={this.props.app.selected}
getEventX={ev => this.getDatetimeX(ev.datetime)}
getEventX={(ev) => this.getDatetimeX(ev.datetime)}
getEventY={this.getY}
categories={categories}
transitionDuration={this.state.transitionDuration}
@@ -372,11 +431,11 @@ class Timeline extends React.Component {
narrative={this.props.app.narrative}
getDatetimeX={this.getDatetimeX}
getY={this.getY}
getHighlights={group => {
if (group === 'None') {
return []
getHighlights={(group) => {
if (group === "None") {
return [];
}
return categories.map(c => c.group === group)
return categories.map((c) => c.group === group);
}}
getCategoryColor={this.props.methods.getCategoryColor}
transitionDuration={this.state.transitionDuration}
@@ -393,48 +452,45 @@ class Timeline extends React.Component {
</div>
</div>
</div>
)
);
}
}
function mapStateToProps (state) {
function mapStateToProps(state) {
return {
dimensions: selectors.selectDimensions(state),
isNarrative: !!state.app.associations.narrative,
domain: {
events: selectors.selectStackedEvents(state),
projects: selectors.selectProjects(state),
categories: (state => {
const allcats = selectors.getCategories(state)
const active = selectors.getActiveCategories(state)
return allcats.filter(c => active.includes(c.id))
categories: ((state) => {
const allcats = selectors.getCategories(state);
const active = selectors.getActiveCategories(state);
return allcats.filter((c) => active.includes(c.id));
})(state),
narratives: state.domain.narratives
narratives: state.domain.narratives,
},
app: {
selected: state.app.selected,
language: state.app.language,
timeline: state.app.timeline,
narrative: state.app.associations.narrative,
coloringSet: state.app.associations.coloringSet
coloringSet: state.app.associations.coloringSet,
},
ui: {
dom: state.ui.dom,
styles: state.ui.style.selectedEvents,
eventRadius: state.ui.eventRadius,
filterColors: state.ui.coloring.colors
filterColors: state.ui.coloring.colors,
},
features: selectors.getFeatures(state)
}
features: selectors.getFeatures(state),
};
}
function mapDispatchToProps (dispatch) {
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({ setLoading, setNotLoading }, dispatch)
}
actions: bindActionCreators({ setLoading, setNotLoading }, dispatch),
};
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(Timeline)
export default connect(mapStateToProps, mapDispatchToProps)(Timeline);

View File

@@ -1,85 +1,85 @@
import React from 'react'
import * as d3 from 'd3'
import { setD3Locale } from '../common/utilities'
import React from "react";
import * as d3 from "d3";
import { setD3Locale } from "../common/utilities";
const TEXT_HEIGHT = 15
setD3Locale(d3)
const TEXT_HEIGHT = 15;
setD3Locale(d3);
class TimelineAxis extends React.Component {
constructor () {
super()
this.xAxis0Ref = React.createRef()
this.xAxis1Ref = React.createRef()
constructor() {
super();
this.xAxis0Ref = React.createRef();
this.xAxis1Ref = React.createRef();
this.state = {
isInitialized: false
}
isInitialized: false,
};
}
componentDidUpdate () {
let fstFmt, sndFmt
componentDidUpdate() {
let fstFmt, sndFmt;
// 10yrs
if (this.props.extent > 5256000) {
fstFmt = '%Y'
sndFmt = ''
// 1yr
fstFmt = "%Y";
sndFmt = "";
// 1yr
} else if (this.props.extent > 43200) {
sndFmt = '%d %b'
fstFmt = ''
sndFmt = "%d %b";
fstFmt = "";
} else {
sndFmt = '%d %b'
fstFmt = '%H:%M'
sndFmt = "%d %b";
fstFmt = "%H:%M";
}
let { marginTop, contentHeight } = this.props.dims
const { marginTop, contentHeight } = this.props.dims;
if (this.props.scaleX) {
this.x0 =
d3.axisBottom(this.props.scaleX)
.ticks(10)
.tickPadding(0)
.tickSize(contentHeight - TEXT_HEIGHT - marginTop)
.tickFormat(d3.timeFormat(fstFmt))
this.x0 = d3
.axisBottom(this.props.scaleX)
.ticks(10)
.tickPadding(0)
.tickSize(contentHeight - TEXT_HEIGHT - marginTop)
.tickFormat(d3.timeFormat(fstFmt));
this.x1 =
d3.axisBottom(this.props.scaleX)
.ticks(10)
.tickPadding(marginTop)
.tickSize(0)
.tickFormat(d3.timeFormat(sndFmt))
this.x1 = d3
.axisBottom(this.props.scaleX)
.ticks(10)
.tickPadding(marginTop)
.tickSize(0)
.tickFormat(d3.timeFormat(sndFmt));
if (!this.state.isInitialized) this.setState({ isInitialized: true })
if (!this.state.isInitialized) this.setState({ isInitialized: true });
}
if (this.state.isInitialized) {
d3.select(this.xAxis0Ref.current)
.transition()
.duration(this.props.transitionDuration)
.call(this.x0)
.call(this.x0);
d3.select(this.xAxis1Ref.current)
.transition()
.duration(this.props.transitionDuration)
.call(this.x1)
.call(this.x1);
}
}
render () {
render() {
return (
<React.Fragment>
<>
<g
ref={this.xAxis0Ref}
transform={`translate(0, ${this.props.dims.marginTop})`}
clipPath={`url(#clip)`}
className={`axis xAxis`}
clipPath="url(#clip)"
className="axis xAxis"
/>
<g
ref={this.xAxis1Ref}
transform={`translate(0, ${this.props.dims.marginTop})`}
clipPath={`url(#clip)`}
className={`axis xAxis`}
clipPath="url(#clip)"
className="axis xAxis"
/>
</React.Fragment>
)
</>
);
}
}
export default TimelineAxis
export default TimelineAxis;

View File

@@ -1,76 +1,84 @@
import React from 'react'
import * as d3 from 'd3'
import React from "react";
import * as d3 from "d3";
class TimelineCategories extends React.Component {
constructor (props) {
super(props)
this.grabRef = React.createRef()
constructor(props) {
super(props);
this.grabRef = React.createRef();
this.state = {
isInitialized: false
}
isInitialized: false,
};
}
componentDidUpdate () {
componentDidUpdate() {
if (!this.state.isInitialized) {
const drag = d3.drag()
.on('start', this.props.onDragStart)
.on('drag', this.props.onDrag)
.on('end', this.props.onDragEnd)
const drag = d3
.drag()
.on("start", this.props.onDragStart)
.on("drag", this.props.onDrag)
.on("end", this.props.onDragEnd);
d3.select(this.grabRef.current)
.call(drag)
d3.select(this.grabRef.current).call(drag);
this.setState({ isInitialized: true })
this.setState({ isInitialized: true });
}
}
renderCategory (cat, idx) {
const { features, dims } = this.props
const strokeWidth = 1 // dims.trackHeight / (this.props.categories.length + 1)
if (features.GRAPH_NONLOCATED &&
renderCategory(cat, idx) {
const { features, dims } = this.props;
const strokeWidth = 1; // dims.trackHeight / (this.props.categories.length + 1)
if (
features.GRAPH_NONLOCATED &&
features.GRAPH_NONLOCATED.categories &&
features.GRAPH_NONLOCATED.categories.includes(cat)) {
return null
features.GRAPH_NONLOCATED.categories.includes(cat)
) {
return null;
}
return (
<React.Fragment>
<>
<g
class='tick'
class="tick"
style={{ strokeWidth }}
opacity='0.5'
opacity="0.5"
transform={`translate(0,${this.props.getCategoryY(cat)})`}
>
<line x1={dims.marginLeft} x2={dims.width - dims.width_controls} />
</g>
<g class='tick' opacity='1' transform={`translate(0,${this.props.getCategoryY(cat)})`}>
<text x={dims.marginLeft - 5} dy='0.32em'>{cat}</text>
<g
class="tick"
opacity="1"
transform={`translate(0,${this.props.getCategoryY(cat)})`}
>
<text x={dims.marginLeft - 5} dy="0.32em">
{cat}
</text>
</g>
</React.Fragment>
)
</>
);
}
render () {
const { dims, categories, fallbackLabel } = this.props
const categoriesExist = categories && categories.length > 0
render() {
const { dims, categories, fallbackLabel } = this.props;
const categoriesExist = categories && categories.length > 0;
const renderedCategories = categoriesExist
? this.props.categories.map((cat, idx) => this.renderCategory(cat, idx))
: this.renderCategory(fallbackLabel, 0)
: this.renderCategory(fallbackLabel, 0);
return (
<g class='yAxis'>
<g class="yAxis">
{renderedCategories}
<rect
ref={this.grabRef}
class='drag-grabber'
class="drag-grabber"
x={dims.marginLeft}
y={dims.marginTop}
width={dims.width - dims.marginLeft - dims.width_controls}
height={dims.contentHeight}
/>
</g>
)
);
}
}
export default TimelineCategories
export default TimelineCategories;

View File

@@ -1,37 +1,35 @@
import React from 'react'
import React from "react";
import SitesIcon from '../presentational/Icons/Sites'
import CoverIcon from '../presentational/Icons/Cover'
import InfoIcon from '../presentational/Icons/Info'
import SitesIcon from "../presentational/Icons/Sites";
import CoverIcon from "../presentational/Icons/Cover";
import InfoIcon from "../presentational/Icons/Info";
function BottomActions (props) {
function renderToggles () {
function BottomActions(props) {
function renderToggles() {
return [
<div className='bottom-action-block'>
{props.features.USE_SITES ? <SitesIcon
isActive={props.sites.enabled}
onClickHandler={props.sites.toggle}
/> : null}
<div className="bottom-action-block">
{props.features.USE_SITES ? (
<SitesIcon
isActive={props.sites.enabled}
onClickHandler={props.sites.toggle}
/>
) : null}
</div>,
<div className='botttom-action-block'>
<div className="botttom-action-block">
<InfoIcon
isActive={props.info.enabled}
onClickHandler={props.info.toggle}
/>
</div>,
<div className='botttom-action-block'>
{props.features.USE_COVER ? <CoverIcon
onClickHandler={props.cover.toggle}
/> : null}
</div>
]
<div className="botttom-action-block">
{props.features.USE_COVER ? (
<CoverIcon onClickHandler={props.cover.toggle} />
) : null}
</div>,
];
}
return (
<div className='bottom-actions'>
{renderToggles()}
</div>
)
return <div className="bottom-actions">{renderToggles()}</div>;
}
export default BottomActions
export default BottomActions;

View File

@@ -1,39 +1,47 @@
import React from 'react'
import marked from 'marked'
import Checkbox from '../presentational/Checkbox'
import copy from '../../common/data/copy.json'
import React from "react";
import marked from "marked";
import Checkbox from "../presentational/Checkbox";
import copy from "../../common/data/copy.json";
export default ({
categories,
activeCategories,
onCategoryFilter,
language
language,
}) => {
function renderCategoryTree () {
function renderCategoryTree() {
return (
<div>
{categories.map(cat => {
return (<li
key={cat.id.replace(/ /g, '_')}
className={'filter-filter active'}
style={{ marginLeft: '20px' }}
>
<Checkbox
label={cat.id}
isActive={activeCategories.includes(cat.id)}
onClickCheckbox={() => onCategoryFilter(cat.id)}
/>
</li>)
{categories.map((cat) => {
return (
<li
key={cat.id.replace(/ /g, "_")}
className="filter-filter active"
style={{ marginLeft: "20px" }}
>
<Checkbox
label={cat.id}
isActive={activeCategories.includes(cat.id)}
onClickCheckbox={() => onCategoryFilter(cat.id)}
/>
</li>
);
})}
</div>
)
);
}
return (
<div className='react-innertabpanel'>
<div className="react-innertabpanel">
<h2>{copy[language].toolbar.categories}</h2>
<p dangerouslySetInnerHTML={{ __html: marked(copy[language].toolbar.explore_by_category__description) }} />
<p
dangerouslySetInnerHTML={{
__html: marked(
copy[language].toolbar.explore_by_category__description
),
}}
/>
{renderCategoryTree()}
</div>
)
}
);
};

View File

@@ -1,63 +1,69 @@
import React from 'react'
import Checkbox from '../presentational/Checkbox'
import marked from 'marked'
import copy from '../../common/data/copy.json'
import { getFilterIdxFromColorSet } from '../../common/utilities'
import React from "react";
import Checkbox from "../presentational/Checkbox";
import marked from "marked";
import copy from "../../common/data/copy.json";
import { getFilterIdxFromColorSet } from "../../common/utilities";
/** recursively get an array of node keys to toggle */
function getFiltersToToggle (filter, activeFilters) {
const [key, children] = filter
function getFiltersToToggle(filter, activeFilters) {
const [key, children] = filter;
// base case: no children to recurse through
if (children === {}) return [key]
if (children === {}) return [key];
const turningOff = activeFilters.includes(key)
let childKeys = Object.entries(children)
.flatMap(filter => getFiltersToToggle(filter, activeFilters))
.filter(child => activeFilters.includes(child) === turningOff)
const turningOff = activeFilters.includes(key);
const childKeys = Object.entries(children)
.flatMap((filter) => getFiltersToToggle(filter, activeFilters))
.filter((child) => activeFilters.includes(child) === turningOff);
childKeys.push(key)
return childKeys
childKeys.push(key);
return childKeys;
}
function aggregatePaths (filters) {
function insertPath (children = {}, [headOfPath, ...remainder]) {
let childKey = Object.keys(children).find(key => key === headOfPath)
if (!childKey) children[headOfPath] = {}
if (remainder.length > 0) insertPath(children[headOfPath], remainder)
return children
function aggregatePaths(filters) {
function insertPath(children = {}, [headOfPath, ...remainder]) {
const childKey = Object.keys(children).find((key) => key === headOfPath);
if (!childKey) children[headOfPath] = {};
if (remainder.length > 0) insertPath(children[headOfPath], remainder);
return children;
}
const allPaths = []
filters.forEach(filterItem => allPaths.push(filterItem.filter_paths))
const allPaths = [];
filters.forEach((filterItem) => allPaths.push(filterItem.filter_paths));
let aggregatedPaths = allPaths.reduce((children, path) => insertPath(children, path), {})
return aggregatedPaths
const aggregatedPaths = allPaths.reduce(
(children, path) => insertPath(children, path),
{}
);
return aggregatedPaths;
}
function FilterListPanel ({
function FilterListPanel({
filters,
activeFilters,
onSelectFilter,
language,
coloringSet,
filterColors
filterColors,
}) {
function createNodeComponent (filter, depth) {
const [key, children] = filter
const matchingKeys = getFiltersToToggle(filter, activeFilters)
const idxFromColorSet = getFilterIdxFromColorSet(key, coloringSet)
const assignedColor = idxFromColorSet !== -1 && activeFilters.includes(key) ? filterColors[idxFromColorSet] : ''
function createNodeComponent(filter, depth) {
const [key, children] = filter;
const matchingKeys = getFiltersToToggle(filter, activeFilters);
const idxFromColorSet = getFilterIdxFromColorSet(key, coloringSet);
const assignedColor =
idxFromColorSet !== -1 && activeFilters.includes(key)
? filterColors[idxFromColorSet]
: "";
const styles = ({
const styles = {
color: assignedColor,
marginLeft: `${depth * 20}px`
})
marginLeft: `${depth * 20}px`,
};
return (
<li
key={key.replace(/ /g, '_')}
className={'filter-filter'}
key={key.replace(/ /g, "_")}
className="filter-filter"
style={{ ...styles }}
>
<Checkbox
@@ -67,29 +73,37 @@ function FilterListPanel ({
color={assignedColor}
/>
{Object.keys(children).length > 0
? Object.entries(children).map(filter => createNodeComponent(filter, depth + 1))
? Object.entries(children).map((filter) =>
createNodeComponent(filter, depth + 1)
)
: null}
</li>
)
);
}
function renderTree (filters) {
const aggregatedFilterPaths = aggregatePaths(filters)
function renderTree(filters) {
const aggregatedFilterPaths = aggregatePaths(filters);
return (
<div>
{Object.entries(aggregatedFilterPaths).map(filter => createNodeComponent(filter, 1))}
{Object.entries(aggregatedFilterPaths).map((filter) =>
createNodeComponent(filter, 1)
)}
</div>
)
);
}
return (
<div className='react-innertabpanel'>
<div className="react-innertabpanel">
<h2>{copy[language].toolbar.filters}</h2>
<p dangerouslySetInnerHTML={{ __html: marked(copy[language].toolbar.explore_by_filter__description) }} />
<p
dangerouslySetInnerHTML={{
__html: marked(copy[language].toolbar.explore_by_filter__description),
}}
/>
{renderTree(filters)}
</div>
)
);
}
export default FilterListPanel
export default FilterListPanel;

View File

@@ -1,105 +1,122 @@
import React from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import * as actions from '../../actions'
import * as selectors from '../../selectors'
import React from "react";
import { connect } from "react-redux";
import { bindActionCreators } from "redux";
import * as actions from "../../actions";
import * as selectors from "../../selectors";
import { Tabs, TabPanel } from 'react-tabs'
import FilterListPanel from './FilterListPanel'
import CategoriesListPanel from './CategoriesListPanel'
import BottomActions from './BottomActions'
import copy from '../../common/data/copy.json'
import { trimAndEllipse, getImmediateFilterParent, getFilterSiblings, getFilterParents } from '../../common/utilities.js'
import { Tabs, TabPanel } from "react-tabs";
import FilterListPanel from "./FilterListPanel";
import CategoriesListPanel from "./CategoriesListPanel";
import BottomActions from "./BottomActions";
import copy from "../../common/data/copy.json";
import {
trimAndEllipse,
getImmediateFilterParent,
getFilterSiblings,
getFilterParents,
} from "../../common/utilities.js";
class Toolbar extends React.Component {
constructor (props) {
super(props)
this.onSelectFilter = this.onSelectFilter.bind(this)
this.state = { _selected: -1 }
constructor(props) {
super(props);
this.onSelectFilter = this.onSelectFilter.bind(this);
this.state = { _selected: -1 };
}
selectTab (selected) {
const _selected = (this.state._selected === selected) ? -1 : selected
this.setState({ _selected })
selectTab(selected) {
const _selected = this.state._selected === selected ? -1 : selected;
this.setState({ _selected });
}
onSelectFilter (key, matchingKeys) {
const { filters, activeFilters, coloringSet, maxNumOfColors } = this.props
onSelectFilter(key, matchingKeys) {
const { filters, activeFilters, coloringSet, maxNumOfColors } = this.props;
const parent = getImmediateFilterParent(filters, key)
const isTurningOff = activeFilters.includes(key)
const parent = getImmediateFilterParent(filters, key);
const isTurningOff = activeFilters.includes(key);
if (!isTurningOff) {
const flattenedColoringSet = coloringSet.flatMap(f => f)
const newColoringSet = matchingKeys.filter(k => flattenedColoringSet.indexOf(k) === -1)
const flattenedColoringSet = coloringSet.flatMap((f) => f);
const newColoringSet = matchingKeys.filter(
(k) => flattenedColoringSet.indexOf(k) === -1
);
const updatedColoringSet = [...coloringSet, newColoringSet]
const updatedColoringSet = [...coloringSet, newColoringSet];
if (updatedColoringSet.length <= maxNumOfColors) {
this.props.actions.updateColoringSet(updatedColoringSet)
this.props.actions.updateColoringSet(updatedColoringSet);
}
} else {
const newColoringSets = coloringSet.map(set => (
set.filter(s => {
return !matchingKeys.includes(s)
const newColoringSets = coloringSet.map((set) =>
set.filter((s) => {
return !matchingKeys.includes(s);
})
))
this.props.actions.updateColoringSet(newColoringSets.filter(item => item.length !== 0))
);
this.props.actions.updateColoringSet(
newColoringSets.filter((item) => item.length !== 0)
);
}
if (isTurningOff) {
if (parent && activeFilters.includes(parent)) {
const siblings = getFilterSiblings(filters, parent, key)
let siblingsOff = true
for (let sibling of siblings) {
const siblings = getFilterSiblings(filters, parent, key);
let siblingsOff = true;
for (const sibling of siblings) {
if (activeFilters.includes(sibling)) {
siblingsOff = false
break
siblingsOff = false;
break;
}
}
if (siblingsOff) {
const grandparentsOn = getFilterParents(filters, key).filter(filt => activeFilters.includes(filt))
matchingKeys = matchingKeys.concat(grandparentsOn)
const grandparentsOn = getFilterParents(filters, key).filter((filt) =>
activeFilters.includes(filt)
);
matchingKeys = matchingKeys.concat(grandparentsOn);
}
}
}
this.props.methods.onSelectFilter(matchingKeys)
this.props.methods.onSelectFilter(matchingKeys);
}
renderClosePanel () {
renderClosePanel() {
return (
<div className='panel-header' onClick={() => this.selectTab(-1)}>
<div className='caret' />
<div className="panel-header" onClick={() => this.selectTab(-1)}>
<div className="caret" />
</div>
)
);
}
goToNarrative (narrative) {
this.selectTab(-1) // set all unselected within this component
this.props.methods.onSelectNarrative(narrative)
goToNarrative(narrative) {
this.selectTab(-1); // set all unselected within this component
this.props.methods.onSelectNarrative(narrative);
}
renderToolbarNarrativePanel () {
renderToolbarNarrativePanel() {
return (
<TabPanel>
<h2>{copy[this.props.language].toolbar.narrative_panel_title}</h2>
<p>{copy[this.props.language].toolbar.narrative_summary}</p>
{this.props.narratives.map((narr) => {
return (
<div className='panel-action action'>
<button onClick={() => { this.goToNarrative(narr) }}>
<div className="panel-action action">
<button
onClick={() => {
this.goToNarrative(narr);
}}
>
<p>{narr.id}</p>
<p><small>{trimAndEllipse(narr.desc, 120)}</small></p>
<p>
<small>{trimAndEllipse(narr.desc, 120)}</small>
</p>
</button>
</div>
)
);
})}
</TabPanel>
)
);
}
renderToolbarCategoriesPanel () {
renderToolbarCategoriesPanel() {
if (this.props.features.CATEGORIES_AS_FILTERS) {
return (
<TabPanel>
@@ -110,11 +127,11 @@ class Toolbar extends React.Component {
language={this.props.language}
/>
</TabPanel>
)
);
}
}
renderToolbarFilterPanel () {
renderToolbarFilterPanel() {
return (
<TabPanel>
<FilterListPanel
@@ -126,106 +143,135 @@ class Toolbar extends React.Component {
filterColors={this.props.filterColors}
/>
</TabPanel>
)
);
}
renderToolbarTab (_selected, label, iconKey) {
const isActive = (this.state._selected === _selected)
let classes = (isActive) ? 'toolbar-tab active' : 'toolbar-tab'
renderToolbarTab(_selected, label, iconKey) {
const isActive = this.state._selected === _selected;
const classes = isActive ? "toolbar-tab active" : "toolbar-tab";
return (
<div className={classes} onClick={() => { this.selectTab(_selected) }}>
<i className='material-icons'>{iconKey}</i>
<div className='tab-caption'>{label}</div>
<div
className={classes}
onClick={() => {
this.selectTab(_selected);
}}
>
<i className="material-icons">{iconKey}</i>
<div className="tab-caption">{label}</div>
</div>
)
);
}
renderToolbarPanels () {
const { features, narratives } = this.props
let classes = (this.state._selected >= 0) ? 'toolbar-panels' : 'toolbar-panels folded'
renderToolbarPanels() {
const { features, narratives } = this.props;
const classes =
this.state._selected >= 0 ? "toolbar-panels" : "toolbar-panels folded";
return (
<div className={classes}>
{this.renderClosePanel()}
<Tabs selectedIndex={this.state._selected}>
{narratives && narratives.length !== 0 ? this.renderToolbarNarrativePanel() : null}
{features.CATEGORIES_AS_FILTERS ? this.renderToolbarCategoriesPanel() : null}
{narratives && narratives.length !== 0
? this.renderToolbarNarrativePanel()
: null}
{features.CATEGORIES_AS_FILTERS
? this.renderToolbarCategoriesPanel()
: null}
{features.USE_ASSOCIATIONS ? this.renderToolbarFilterPanel() : null}
</Tabs>
</div>
)
);
}
renderToolbarNavs () {
renderToolbarNavs() {
if (this.props.narratives) {
return this.props.narratives.map((nar, idx) => {
const isActive = (idx === this.state._selected)
const isActive = idx === this.state._selected;
let classes = (isActive) ? 'toolbar-tab active' : 'toolbar-tab'
const classes = isActive ? "toolbar-tab active" : "toolbar-tab";
return (
<div className={classes} onClick={() => { this.selectTab(idx) }}>
<div className='tab-caption'>{nar.label}</div>
<div
className={classes}
onClick={() => {
this.selectTab(idx);
}}
>
<div className="tab-caption">{nar.label}</div>
</div>
)
})
);
});
}
return null
return null;
}
renderToolbarTabs () {
const { features, narratives } = this.props
const narrativesExist = narratives && narratives.length !== 0
let title = copy[this.props.language].toolbar.title
if (process.env.display_title) title = process.env.display_title
const narrativesLabel = copy[this.props.language].toolbar.narratives_label
const filtersLabel = copy[this.props.language].toolbar.filters_label
const categoriesLabel = 'Categories' // TODO:
renderToolbarTabs() {
const { features, narratives } = this.props;
const narrativesExist = narratives && narratives.length !== 0;
let title = copy[this.props.language].toolbar.title;
if (process.env.display_title) title = process.env.display_title;
const narrativesLabel = copy[this.props.language].toolbar.narratives_label;
const filtersLabel = copy[this.props.language].toolbar.filters_label;
const categoriesLabel = "Categories"; // TODO:
const narrativesIdx = 0
const categoriesIdx = narrativesExist ? 1 : 0
const filtersIdx = (narrativesExist && features.CATEGORIES_AS_FILTERS) ? 2 : (
narrativesExist || features.CATEGORIES_AS_FILTERS ? 1 : 0
)
const narrativesIdx = 0;
const categoriesIdx = narrativesExist ? 1 : 0;
const filtersIdx =
narrativesExist && features.CATEGORIES_AS_FILTERS
? 2
: narrativesExist || features.CATEGORIES_AS_FILTERS
? 1
: 0;
return (
<div className='toolbar'>
<div className='toolbar-header'onClick={this.props.methods.onTitle}><p>{title}</p></div>
<div className='toolbar-tabs'>
{narrativesExist ? this.renderToolbarTab(narrativesIdx, narrativesLabel, 'timeline') : null}
{features.CATEGORIES_AS_FILTERS ? this.renderToolbarTab(categoriesIdx, categoriesLabel, 'widgets') : null}
{features.USE_ASSOCIATIONS ? this.renderToolbarTab(filtersIdx, filtersLabel, 'filter_list') : null}
<div className="toolbar">
<div className="toolbar-header" onClick={this.props.methods.onTitle}>
<p>{title}</p>
</div>
<div className="toolbar-tabs">
{narrativesExist
? this.renderToolbarTab(narrativesIdx, narrativesLabel, "timeline")
: null}
{features.CATEGORIES_AS_FILTERS
? this.renderToolbarTab(categoriesIdx, categoriesLabel, "widgets")
: null}
{features.USE_ASSOCIATIONS
? this.renderToolbarTab(filtersIdx, filtersLabel, "filter_list")
: null}
</div>
<BottomActions
info={{
enabled: this.props.infoShowing,
toggle: this.props.actions.toggleInfoPopup
toggle: this.props.actions.toggleInfoPopup,
}}
sites={{
enabled: this.props.sitesShowing,
toggle: this.props.actions.toggleSites
toggle: this.props.actions.toggleSites,
}}
cover={{
toggle: this.props.actions.toggleCover
toggle: this.props.actions.toggleCover,
}}
features={this.props.features}
/>
</div>
)
);
}
render () {
const { isNarrative } = this.props
render() {
const { isNarrative } = this.props;
return (
<div id='toolbar-wrapper' className={`toolbar-wrapper ${(isNarrative) ? 'narrative-mode' : ''}`}>
<div
id="toolbar-wrapper"
className={`toolbar-wrapper ${isNarrative ? "narrative-mode" : ""}`}
>
{this.renderToolbarTabs()}
{this.renderToolbarPanels()}
</div>
)
);
}
}
function mapStateToProps (state) {
function mapStateToProps(state) {
return {
filters: selectors.getFilters(state),
categories: selectors.getCategories(state),
@@ -240,14 +286,14 @@ function mapStateToProps (state) {
coloringSet: state.app.associations.coloringSet,
maxNumOfColors: state.ui.coloring.maxNumOfColors,
filterColors: state.ui.coloring.colors,
features: selectors.getFeatures(state)
}
features: selectors.getFeatures(state),
};
}
function mapDispatchToProps (dispatch) {
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(actions, dispatch)
}
actions: bindActionCreators(actions, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(Toolbar)
export default connect(mapStateToProps, mapDispatchToProps)(Toolbar);

View File

@@ -1,82 +1,80 @@
import React from 'react'
import Checkbox from '../presentational/Checkbox'
import React from "react";
import Checkbox from "../presentational/Checkbox";
function SelectFilter (props) {
function isActive () {
function SelectFilter(props) {
function isActive() {
if (props.isCategory) {
return props.categoryFilters.includes(props.filter.id)
return props.categoryFilters.includes(props.filter.id);
}
return props.filterFilters.includes(props.filter.id)
return props.filterFilters.includes(props.filter.id);
}
function onClickFilter () {
function onClickFilter() {
if (isActive()) {
props.filter({
filters: props.filterFilters.filter(element => element !== props.filter.id)
})
filters: props.filterFilters.filter(
(element) => element !== props.filter.id
),
});
} else {
props.filter({
filters: props.filterFilters.concat(props.filter.id)
})
filters: props.filterFilters.concat(props.filter.id),
});
}
}
function onClickCategory () {
function onClickCategory() {
if (isActive()) {
props.filter({
categories: props.categoryFilters.filter(element => element !== props.filter.id)
})
categories: props.categoryFilters.filter(
(element) => element !== props.filter.id
),
});
} else {
props.filter({
categories: props.categoryFilters.concat(props.filter.id)
})
categories: props.categoryFilters.concat(props.filter.id),
});
}
}
function renderFilter () {
const filter = props.filter
let classes = (isActive()) ? 'filter-filter active' : 'filter-filter'
let label = `${filter.name} ( ${filter.mentions} )`
function renderFilter() {
const filter = props.filter;
const classes = isActive() ? "filter-filter active" : "filter-filter";
let label = `${filter.name} ( ${filter.mentions} )`;
if (props.isShowTree) {
label = `${filter.group} > ${filter.subgroup} > ${filter.name} ( ${filter.mentions} )`
label = `${filter.group} > ${filter.subgroup} > ${filter.name} ( ${filter.mentions} )`;
}
return (
<li
key={props.filter.id}
className={classes}
>
<li key={props.filter.id} className={classes}>
<Checkbox
isActive={isActive()}
label={label}
onClickCheckbox={() => onClickFilter()}
/>
</li>
)
);
}
function renderCategory () {
const category = props.categories[props.filter.id]
let classes = (isActive()) ? 'filter-filter active' : 'filter-filter'
function renderCategory() {
const category = props.categories[props.filter.id];
const classes = isActive() ? "filter-filter active" : "filter-filter";
if (category) {
return (
<li
key={props.filter.id}
className={classes}
>
<li key={props.filter.id} className={classes}>
<Checkbox
isActive={isActive()}
label={`${category.name} ( ${category.counts} )`}
onClickCheckbox={onClickCategory}
/>
</li>
)
);
}
return (<div />)
return <div />;
}
if (props.isCategory) return (renderCategory())
return (renderFilter())
if (props.isCategory) return renderCategory();
return renderFilter();
}
export default SelectFilter
export default SelectFilter;

View File

@@ -1,17 +1,15 @@
import React from 'react'
import React from "react";
const CardCaret = ({ isOpen, toggle }) => {
let classes = (isOpen)
? 'arrow-down'
: 'arrow-down folded'
const classes = isOpen ? "arrow-down" : "arrow-down folded";
return (
<div className='card-toggle' onClick={toggle}>
<div className="card-toggle" onClick={toggle}>
<p>
<i className={classes} />
</p>
</div>
)
}
);
};
export default CardCaret
export default CardCaret;

View File

@@ -1,15 +1,15 @@
import React from 'react'
import React from "react";
import { capitalize } from '../../../common/utilities.js'
import { capitalize } from "../../../common/utilities.js";
const CardCategory = ({ categoryTitle, categoryLabel, color }) => (
<div className='card-row card-cell category'>
<div className="card-row card-cell category">
<h4>{categoryTitle}</h4>
<p>
{capitalize(categoryLabel)}
<span className='color-category' style={{ background: color }} />
<span className="color-category" style={{ background: color }} />
</p>
</div>
)
);
export default CardCategory
export default CardCategory;

View File

@@ -1,14 +1,14 @@
import React from 'react'
import marked from 'marked'
import React from "react";
import marked from "marked";
const CardCustomField = ({ field, value }) => (
<div className='card-cell'>
<div className="card-cell">
<p>
<i className='material-icons left'>{field.icon}</i>
<b>{field.title ? `${field.title}: ` : '- '}</b>
{field.kind === 'text' ? value : marked(`[${value}](${field.value})`)}
<i className="material-icons left">{field.icon}</i>
<b>{field.title ? `${field.title}: ` : "- "}</b>
{field.kind === "text" ? value : marked(`[${value}](${field.value})`)}
</p>
</div>
)
);
export default CardCustomField
export default CardCustomField;

View File

@@ -1,28 +1,28 @@
import React from 'react'
import React from "react";
import copy from '../../../common/data/copy.json'
import copy from "../../../common/data/copy.json";
const CardLocation = ({ language, location, isPrecise }) => {
if (location !== '') {
if (location !== "") {
return (
<div className='card-cell location'>
<div className="card-cell location">
<p>
<i className='material-icons left'>location_on</i>
{`${location}${(isPrecise) ? '' : ' (Approximated)'}`}
<i className="material-icons left">location_on</i>
{`${location}${isPrecise ? "" : " (Approximated)"}`}
</p>
</div>
)
);
} else {
const unknown = copy[language].cardstack.unknown_location
const unknown = copy[language].cardstack.unknown_location;
return (
<div className='card-cell location'>
<div className="card-cell location">
<p>
<i className='material-icons left'>location_on</i>
<i className="material-icons left">location_on</i>
{unknown}
</p>
</div>
)
);
}
}
};
export default CardLocation
export default CardLocation;

View File

@@ -1,79 +1,79 @@
import React from 'react'
import Img from 'react-image'
import Spinner from '../Spinner'
import { typeForPath } from '../../../common/utilities'
import React from "react";
import Img from "react-image";
import Spinner from "../Spinner";
import { typeForPath } from "../../../common/utilities";
const CardSource = ({ source, isLoading, onClickHandler }) => {
function renderIconText (type) {
function renderIconText(type) {
switch (type) {
case 'Eyewitness Testimony':
return 'visibility'
case 'Government Data':
return 'public'
case 'Satellite Imagery':
return 'satellite'
case 'Second-Hand Testimony':
return 'visibility_off'
case 'Video':
return 'videocam'
case 'Photo':
return 'photo'
case 'Photobook':
return 'photo_album'
case 'Document':
return 'picture_as_pdf'
case "Eyewitness Testimony":
return "visibility";
case "Government Data":
return "public";
case "Satellite Imagery":
return "satellite";
case "Second-Hand Testimony":
return "visibility_off";
case "Video":
return "videocam";
case "Photo":
return "photo";
case "Photobook":
return "photo_album";
case "Document":
return "picture_as_pdf";
default:
return 'help'
return "help";
}
}
if (!source) {
return (
<div className='card-source'>
<div className="card-source">
<div>Error: this source was not found</div>
</div>
)
);
}
const isImgUrl = /\.(jpg|png)$/
let thumbnail = source.thumbnail
const isImgUrl = /\.(jpg|png)$/;
let thumbnail = source.thumbnail;
if (!thumbnail || thumbnail === '' || !thumbnail.match(isImgUrl)) {
if (!thumbnail || thumbnail === "" || !thumbnail.match(isImgUrl)) {
// default to first image in paths, null if no images
const imgs = source.paths.filter(p => p.match(isImgUrl))
thumbnail = imgs.length > 0 ? imgs[0] : null
const imgs = source.paths.filter((p) => p.match(isImgUrl));
thumbnail = imgs.length > 0 ? imgs[0] : null;
}
if (source.type === '' && source.paths.length >= 1) {
source.type = typeForPath(source.paths[0])
if (source.type === "" && source.paths.length >= 1) {
source.type = typeForPath(source.paths[0]);
}
const fallbackIcon = (
<i className='material-icons source-icon'>
{renderIconText(source.type)}
</i>
)
<i className="material-icons source-icon">{renderIconText(source.type)}</i>
);
return (
<div className='card-source'>
{isLoading
? <Spinner />
: (
<div className='source-row' onClick={() => onClickHandler(source)}>
{thumbnail ? (
<Img
className='source-icon'
src={thumbnail}
loader={<Spinner small />}
unloader={fallbackIcon}
width={30}
height={30}
/>
) : fallbackIcon}
<p>{source.title ? source.title : source.id}</p>
</div>
)}
<div className="card-source">
{isLoading ? (
<Spinner />
) : (
<div className="source-row" onClick={() => onClickHandler(source)}>
{thumbnail ? (
<Img
className="source-icon"
src={thumbnail}
loader={<Spinner small />}
unloader={fallbackIcon}
width={30}
height={30}
/>
) : (
fallbackIcon
)}
<p>{source.title ? source.title : source.id}</p>
</div>
)}
</div>
)
}
);
};
export default CardSource
export default CardSource;

View File

@@ -1,18 +1,18 @@
import React from 'react'
import React from "react";
import copy from '../../../common/data/copy.json'
import copy from "../../../common/data/copy.json";
const CardSummary = ({ language, description, isHighlighted }) => {
const summary = copy[language].cardstack.description
const summary = copy[language].cardstack.description;
return (
<div className='card-row summary'>
<div className='card-cell'>
<div className="card-row summary">
<div className="card-cell">
<h4>{summary}</h4>
<p>{description}</p>
</div>
</div>
)
}
);
};
export default CardSummary
export default CardSummary;

View File

@@ -1,32 +1,33 @@
import React from 'react'
import React from "react";
import copy from '../../../common/data/copy.json'
import { isNotNullNorUndefined } from '../../../common/utilities'
import copy from "../../../common/data/copy.json";
import { isNotNullNorUndefined } from "../../../common/utilities";
const CardTime = ({ timelabel, language, precision }) => {
// const daytimeLang = copy[language].cardstack.timestamp
// const estimatedLang = copy[language].cardstack.estimated
const unknownLang = copy[language].cardstack.unknown_time
const unknownLang = copy[language].cardstack.unknown_time;
if (isNotNullNorUndefined(timelabel)) {
return (
<div className='card-cell timestamp'>
<div className="card-cell timestamp">
<p>
<i className='material-icons left'>today</i>
{timelabel}{(precision && precision !== '') ? ` - ${precision}` : ''}
<i className="material-icons left">today</i>
{timelabel}
{precision && precision !== "" ? ` - ${precision}` : ""}
</p>
</div>
)
);
} else {
return (
<div className='card-cell timestamp'>
<div className="card-cell timestamp">
<p>
<i className='material-icons left'>today</i>
<i className="material-icons left">today</i>
{unknownLang}
</p>
</div>
)
);
}
}
};
export default CardTime
export default CardTime;

View File

@@ -1,17 +1,17 @@
import React from 'react'
import React from "react";
export default ({ label, isActive, onClickCheckbox, color }) => {
const styles = ({
background: isActive ? color : 'none',
border: `1px solid ${color}`
})
const styles = {
background: isActive ? color : "none",
border: `1px solid ${color}`,
};
return (
<div className={(isActive) ? 'item active' : 'item'}>
<div className={isActive ? "item active" : "item"}>
<span style={{ color: color }}>{label}</span>
<button onClick={onClickCheckbox}>
<div className='checkbox' style={styles} />
<div className="checkbox" style={styles} />
</button>
</div>
)
}
);
};

View File

@@ -1,20 +1,43 @@
import React from 'react'
import React from "react";
const CoeventIcon = ({ isEnabled, toggleMapViews }) => {
return (
<button
onClick={() => toggleMapViews('coevents')}
>
<svg className='coevents' x='0px' y='0px' width='30px' height='20px' viewBox='0 0 30 20' enableBackground='new 0 0 30 20'>
<polygon stroke-linejoin='round' stroke-miterlimit='10' points='19.178,20 10.823,20 10.473,14.081
10,13.396 10,6.084 20,6.084 20,13.396 19.445,14.021 ' />
<rect className='no-fill' x='11.4' y='7.867' width='7.2' height='3.35' />
<line stroke-linejoin='round' stroke-miterlimit='10' x1='12.125' y1='1' x2='12.125' y2='5.35' />
<rect x='11.4' y='4.271' width='1.496' height='1.079' />
<rect x='17.104' y='4.271' width='1.496' height='1.079' />
<button onClick={() => toggleMapViews("coevents")}>
<svg
className="coevents"
x="0px"
y="0px"
width="30px"
height="20px"
viewBox="0 0 30 20"
enableBackground="new 0 0 30 20"
>
<polygon
stroke-linejoin="round"
stroke-miterlimit="10"
points="19.178,20 10.823,20 10.473,14.081
10,13.396 10,6.084 20,6.084 20,13.396 19.445,14.021 "
/>
<rect
className="no-fill"
x="11.4"
y="7.867"
width="7.2"
height="3.35"
/>
<line
stroke-linejoin="round"
stroke-miterlimit="10"
x1="12.125"
y1="1"
x2="12.125"
y2="5.35"
/>
<rect x="11.4" y="4.271" width="1.496" height="1.079" />
<rect x="17.104" y="4.271" width="1.496" height="1.079" />
</svg>
</button>
)
}
);
};
export default CoeventIcon
export default CoeventIcon;

View File

@@ -1,21 +1,16 @@
import React from 'react'
import React from "react";
const CoverIcon = ({ isActive, isDisabled, onClickHandler }) => {
let classes = (isActive) ? 'action-button enabled' : 'action-button'
let classes = isActive ? "action-button enabled" : "action-button";
if (isDisabled) {
classes = 'action-button disabled'
classes = "action-button disabled";
}
return (
<button
className={classes}
onClick={onClickHandler}
>
<i class='material-icons'>
home
</i>
<button className={classes} onClick={onClickHandler}>
<i class="material-icons">home</i>
</button>
)
}
);
};
export default CoverIcon
export default CoverIcon;

View File

@@ -1,21 +1,16 @@
import React from 'react'
import React from "react";
const CoverIcon = ({ isActive, isDisabled, onClickHandler }) => {
let classes = (isActive) ? 'action-button enabled' : 'action-button'
let classes = isActive ? "action-button enabled" : "action-button";
if (isDisabled) {
classes = 'action-button disabled'
classes = "action-button disabled";
}
return (
<button
className={classes}
onClick={onClickHandler}
>
<i class='material-icons'>
info
</i>
<button className={classes} onClick={onClickHandler}>
<i class="material-icons">info</i>
</button>
)
}
);
};
export default CoverIcon
export default CoverIcon;

View File

@@ -1,11 +1,23 @@
import React from 'react'
import React from "react";
export default ({ isActive, isDisabled, onClickHandler }) => {
return (
<svg className='reset' x='0px' y='0px' width='25px' height='25px' viewBox='7.5 7.5 25 25' enableBackground='new 7.5 7.5 25 25'>
<path stroke-width='2' stroke-miterlimit='10' d='M28.822,16.386c1.354,3.219,0.898,7.064-1.5,9.924
c-3.419,4.073-9.49,4.604-13.562,1.186c-4.073-3.417-4.604-9.49-1.187-13.562c1.987-2.368,4.874-3.54,7.74-3.433' />
<polygon points='26.137,12.748 27.621,19.464 28.9,16.741 31.898,16.503' />
<svg
className="reset"
x="0px"
y="0px"
width="25px"
height="25px"
viewBox="7.5 7.5 25 25"
enableBackground="new 7.5 7.5 25 25"
>
<path
stroke-width="2"
stroke-miterlimit="10"
d="M28.822,16.386c1.354,3.219,0.898,7.064-1.5,9.924
c-3.419,4.073-9.49,4.604-13.562,1.186c-4.073-3.417-4.604-9.49-1.187-13.562c1.987-2.368,4.874-3.54,7.74-3.433"
/>
<polygon points="26.137,12.748 27.621,19.464 28.9,16.741 31.898,16.503" />
</svg>
)
}
);
};

View File

@@ -1,16 +1,21 @@
import React from 'react'
import React from "react";
const RouteIcon = ({ isEnabled, toggleMapViews }) => {
return (
<button
onClick={() => toggleMapViews('routes')}
>
<svg x='0px' y='0px' width='30px' height='20px' viewBox='0 0 30 20' enableBackground='new 0 0 30 20'>
<path d='M0.806,13.646h7.619c2.762,0,3-0.238,3-3v-0.414c0-2.762,0.301-3,3.246-3h14.523' />
<polyline points='16.671,9.228 19.103,7.233 16.671,5.237 ' />
<button onClick={() => toggleMapViews("routes")}>
<svg
x="0px"
y="0px"
width="30px"
height="20px"
viewBox="0 0 30 20"
enableBackground="new 0 0 30 20"
>
<path d="M0.806,13.646h7.619c2.762,0,3-0.238,3-3v-0.414c0-2.762,0.301-3,3.246-3h14.523" />
<polyline points="16.671,9.228 19.103,7.233 16.671,5.237 " />
</svg>
</button>
)
}
);
};
export default RouteIcon
export default RouteIcon;

View File

@@ -1,21 +1,16 @@
import React from 'react'
import React from "react";
const SitesIcon = ({ isActive, isDisabled, onClickHandler }) => {
let classes = (isActive) ? 'action-button enabled' : 'action-button'
let classes = isActive ? "action-button enabled" : "action-button";
if (isDisabled) {
classes = 'action-button disabled'
classes = "action-button disabled";
}
return (
<button
className={classes}
onClick={onClickHandler}
>
<i class='material-icons'>
location_on
</i>
<button className={classes} onClick={onClickHandler}>
<i class="material-icons">location_on</i>
</button>
)
}
);
};
export default SitesIcon
export default SitesIcon;

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react'
import { Portal } from 'react-portal'
import colors from '../../../common/global.js'
import ColoredMarkers from './ColoredMarkers.jsx'
import React, { useState } from "react";
import { Portal } from "react-portal";
import colors from "../../../common/global.js";
import ColoredMarkers from "./ColoredMarkers.jsx";
import {
calcClusterOpacity,
calcClusterSize,
@@ -9,18 +9,30 @@ import {
isLongitude,
calculateColorPercentages,
zipColorsToPercentages,
calculateTotalClusterPoints } from '../../../common/utilities'
calculateTotalClusterPoints,
} from "../../../common/utilities";
const DefsClusters = () => (
<defs>
<radialGradient id='clusterGradient'>
<stop offset='10%' stop-color='red' />
<stop offset='90%' stop-color='transparent' />
<radialGradient id="clusterGradient">
<stop offset="10%" stop-color="red" />
<stop offset="90%" stop-color="transparent" />
</radialGradient>
</defs>
)
);
function Cluster ({ cluster, size, projectPoint, totalPoints, styles, renderHover, onClick, getClusterChildren, coloringSet, filterColors }) {
function Cluster({
cluster,
size,
projectPoint,
totalPoints,
styles,
renderHover,
onClick,
getClusterChildren,
coloringSet,
filterColors,
}) {
/**
{
geometry: {
@@ -35,22 +47,25 @@ function Cluster ({ cluster, size, projectPoint, totalPoints, styles, renderHove
type: "Feature"
}
*/
const { cluster_id: clusterId } = cluster.properties
const { cluster_id: clusterId } = cluster.properties;
const individualChildren = getClusterChildren(clusterId)
const colorPercentages = calculateColorPercentages(individualChildren, coloringSet)
const individualChildren = getClusterChildren(clusterId);
const colorPercentages = calculateColorPercentages(
individualChildren,
coloringSet
);
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)
const { coordinates } = cluster.geometry;
const [longitude, latitude] = coordinates;
const { x, y } = projectPoint([latitude, longitude]);
const [hovered, setHovered] = useState(false);
if (!isLatitude(latitude) || !isLongitude(longitude)) return null;
return (
<g
className={'cluster-event'}
className="cluster-event"
transform={`translate(${x}, ${y})`}
onClick={e => onClick({ id: clusterId, latitude, longitude })}
onClick={(e) => onClick({ id: clusterId, latitude, longitude })}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
@@ -58,16 +73,16 @@ function Cluster ({ cluster, size, projectPoint, totalPoints, styles, renderHove
radius={size}
colorPercentMap={zipColorsToPercentages(filterColors, colorPercentages)}
styles={{
...styles
...styles,
}}
className={'cluster-event-marker'}
className="cluster-event-marker"
/>
{hovered ? renderHover(cluster) : null}
</g>
)
);
}
function ClusterEvents ({
function ClusterEvents({
projectPoint,
onSelect,
getClusterChildren,
@@ -76,56 +91,66 @@ function ClusterEvents ({
svg,
clusters,
filterColors,
selected
selected,
}) {
const totalPoints = calculateTotalClusterPoints(clusters)
const totalPoints = calculateTotalClusterPoints(clusters);
const styles = {
fill: isRadial ? "url('#clusterGradient')" : colors.fallbackEventColor,
stroke: colors.darkBackground,
strokeWidth: 0
}
strokeWidth: 0,
};
function renderHover (txt, circleSize) {
return <>
<text text-anchor='middle' y='3px' style={{ fontWeight: 'bold', fill: 'black', zIndex: 10000 }}>{txt}</text>
<circle
class='event-hover'
cx='0'
cy='0'
r={circleSize + 2}
stroke={colors.primaryHighlight}
fill-opacity='0.0'
/>
</>
function renderHover(txt, circleSize) {
return (
<>
<text
text-anchor="middle"
y="3px"
style={{ fontWeight: "bold", fill: "black", zIndex: 10000 }}
>
{txt}
</text>
<circle
class="event-hover"
cx="0"
cy="0"
r={circleSize + 2}
stroke={colors.primaryHighlight}
fill-opacity="0.0"
/>
</>
);
}
return (
<Portal node={svg}>
<g className='cluster-locations'>
<g className="cluster-locations">
{isRadial ? <DefsClusters /> : null}
{clusters.map(c => {
const pointCount = c.properties.point_count
const clusterSize = calcClusterSize(pointCount, totalPoints)
return <Cluster
onClick={onSelect}
getClusterChildren={getClusterChildren}
coloringSet={coloringSet}
cluster={c}
filterColors={filterColors}
size={clusterSize}
projectPoint={projectPoint}
totalPoints={totalPoints}
styles={{
...styles,
fillOpacity: calcClusterOpacity(pointCount, totalPoints)
}}
renderHover={() => renderHover(pointCount, clusterSize)}
/>
{clusters.map((c) => {
const pointCount = c.properties.point_count;
const clusterSize = calcClusterSize(pointCount, totalPoints);
return (
<Cluster
onClick={onSelect}
getClusterChildren={getClusterChildren}
coloringSet={coloringSet}
cluster={c}
filterColors={filterColors}
size={clusterSize}
projectPoint={projectPoint}
totalPoints={totalPoints}
styles={{
...styles,
fillOpacity: calcClusterOpacity(pointCount, totalPoints),
}}
renderHover={() => renderHover(pointCount, clusterSize)}
/>
);
})}
</g>
</Portal>
)
);
}
export default ClusterEvents
export default ClusterEvents;

View File

@@ -1,35 +1,41 @@
import React from 'react'
import { getCoordinatesForPercent } from '../../../common/utilities'
import React from "react";
import { getCoordinatesForPercent } from "../../../common/utilities";
function ColoredMarkers ({ radius, colorPercentMap, styles, className }) {
let cumulativeAngleSweep = 0
const colors = Object.keys(colorPercentMap)
function ColoredMarkers({ radius, colorPercentMap, styles, className }) {
let cumulativeAngleSweep = 0;
const colors = Object.keys(colorPercentMap);
return (
<React.Fragment>
<>
{colors.map((color, idx) => {
const colorPercent = colorPercentMap[color]
const colorPercent = colorPercentMap[color];
const [startX, startY] = getCoordinatesForPercent(radius, cumulativeAngleSweep)
const [startX, startY] = getCoordinatesForPercent(
radius,
cumulativeAngleSweep
);
cumulativeAngleSweep += colorPercent
cumulativeAngleSweep += colorPercent;
const [endX, endY] = getCoordinatesForPercent(radius, cumulativeAngleSweep)
const [endX, endY] = getCoordinatesForPercent(
radius,
cumulativeAngleSweep
);
// if the slices are less than 2, take the long arc
const largeArcFlag = (colors.length === 1) || colorPercent > 0.5 ? 1 : 0
const largeArcFlag = colors.length === 1 || colorPercent > 0.5 ? 1 : 0;
// create an array and join it just for code readability
const arc = [
`M ${startX} ${startY}`, // Move
`A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endX} ${endY}`, // Arc
`L 0 0 `, // Line
`L ${startX} ${startY} Z` // Line
].join(' ')
"L 0 0 ", // Line
`L ${startX} ${startY} Z`, // Line
].join(" ");
const extraStyles = ({
const extraStyles = {
...styles,
fill: color
})
fill: color,
};
return (
<path
@@ -38,10 +44,10 @@ function ColoredMarkers ({ radius, colorPercentMap, styles, className }) {
d={arc}
style={extraStyles}
/>
)
);
})}
</React.Fragment>
)
</>
);
}
export default ColoredMarkers
export default ColoredMarkers;

View File

@@ -1,14 +1,30 @@
import React from 'react'
import React from "react";
const MapDefsMarkers = () => (
<defs>
<marker id='arrow' viewBox='0 0 6 6' refX='3' refY='3' markerWidth='6' markerHeight='6' orient='auto'>
<path d='M0,3v-3l6,3l-6,3z' style={{ fill: 'red' }} />
<marker
id="arrow"
viewBox="0 0 6 6"
refX="3"
refY="3"
markerWidth="6"
markerHeight="6"
orient="auto"
>
<path d="M0,3v-3l6,3l-6,3z" style={{ fill: "red" }} />
</marker>
<marker id='arrow-off' viewBox='0 0 6 6' refX='3' refY='3' markerWidth='6' markerHeight='6' orient='auto'>
<path d='M0,3v-3l6,3l-6,3z' style={{ fill: 'black', fillOpacity: 0.2 }} />
<marker
id="arrow-off"
viewBox="0 0 6 6"
refX="3"
refY="3"
markerWidth="6"
markerHeight="6"
orient="auto"
>
<path d="M0,3v-3l6,3l-6,3z" style={{ fill: "black", fillOpacity: 0.2 }} />
</marker>
</defs>
)
);
export default MapDefsMarkers
export default MapDefsMarkers;

View File

@@ -1,10 +1,15 @@
import React from 'react'
import { Portal } from 'react-portal'
import colors from '../../../common/global.js'
import ColoredMarkers from './ColoredMarkers.jsx'
import { calcOpacity, getCoordinatesForPercent, calculateColorPercentages, zipColorsToPercentages } from '../../../common/utilities'
import React from "react";
import { Portal } from "react-portal";
import colors from "../../../common/global.js";
import ColoredMarkers from "./ColoredMarkers.jsx";
import {
calcOpacity,
getCoordinatesForPercent,
calculateColorPercentages,
zipColorsToPercentages,
} from "../../../common/utilities";
function MapEvents ({
function MapEvents({
getCategoryColor,
categories,
projectPoint,
@@ -17,110 +22,120 @@ function MapEvents ({
eventRadius,
coloringSet,
filterColors,
features
features,
}) {
function handleEventSelect (e, location) {
const events = e.shiftKey ? selected.concat(location.events) : location.events
onSelect(events)
function handleEventSelect(e, location) {
const events = e.shiftKey
? selected.concat(location.events)
: location.events;
onSelect(events);
}
function renderBorder () {
function renderBorder() {
return (
<React.Fragment>
{<circle
class='event-hover'
cx='0'
cy='0'
r='10'
<>
<circle
class="event-hover"
cx="0"
cy="0"
r="10"
stroke={colors.primaryHighlight}
fill-opacity='0.0'
/>}
</React.Fragment>
)
fill-opacity="0.0"
/>
</>
);
}
function renderLocationSlicesByAssociation (location) {
const colorPercentages = calculateColorPercentages([location], coloringSet)
function renderLocationSlicesByAssociation(location) {
const colorPercentages = calculateColorPercentages([location], coloringSet);
let styles = ({
const styles = {
stroke: colors.darkBackground,
strokeWidth: 0,
fillOpacity: narrative ? 1 : calcOpacity(location.events.length)
})
fillOpacity: narrative ? 1 : calcOpacity(location.events.length),
};
return (
<ColoredMarkers
radius={eventRadius}
colorPercentMap={zipColorsToPercentages(filterColors, colorPercentages)}
styles={{
...styles
...styles,
}}
className={'location-event-marker'}
className="location-event-marker"
/>
)
);
}
function renderLocationSlicesByCategory (location) {
const locCategory = location.events.length > 0 ? location.events[0].category : 'default'
const customStyles = styleLocation ? styleLocation(location) : null
const extraStyles = customStyles[0]
function renderLocationSlicesByCategory(location) {
const locCategory =
location.events.length > 0 ? location.events[0].category : "default";
const customStyles = styleLocation ? styleLocation(location) : null;
const extraStyles = customStyles[0];
let styles = ({
const styles = {
fill: getCategoryColor(locCategory),
stroke: colors.darkBackground,
strokeWidth: 0,
fillOpacity: narrative ? 1 : calcOpacity(location.events.length),
...extraStyles
})
...extraStyles,
};
const colorSlices = location.events.map(e => e.colour ? e.colour : getCategoryColor(e.category))
const colorSlices = location.events.map((e) =>
e.colour ? e.colour : getCategoryColor(e.category)
);
let cumulativeAngleSweep = 0
let cumulativeAngleSweep = 0;
return (
<React.Fragment>
<>
{colorSlices.map((color, idx) => {
const r = eventRadius
const r = eventRadius;
// Based on the number of events in each location,
// create a slice per event filled with its category color
const [startX, startY] = getCoordinatesForPercent(r, cumulativeAngleSweep)
const [startX, startY] = getCoordinatesForPercent(
r,
cumulativeAngleSweep
);
cumulativeAngleSweep = (idx + 1) / colorSlices.length
cumulativeAngleSweep = (idx + 1) / colorSlices.length;
const [endX, endY] = getCoordinatesForPercent(r, cumulativeAngleSweep)
const [endX, endY] = getCoordinatesForPercent(
r,
cumulativeAngleSweep
);
// if the slices are less than 2, take the long arc
const largeArcFlag = (colorSlices.length === 1) ? 1 : 0
const largeArcFlag = colorSlices.length === 1 ? 1 : 0;
// create an array and join it just for code readability
const arc = [
`M ${startX} ${startY}`, // Move
`A ${r} ${r} 0 ${largeArcFlag} 1 ${endX} ${endY}`, // Arc
`L 0 0 `, // Line
`L ${startX} ${startY} Z` // Line
].join(' ')
"L 0 0 ", // Line
`L ${startX} ${startY} Z`, // Line
].join(" ");
const extraStyles = ({
const extraStyles = {
...styles,
fill: color
})
fill: color,
};
return (
<path
class='location-event-marker'
class="location-event-marker"
id={`arc_${idx}`}
d={arc}
style={extraStyles}
/>
)
);
})}
</React.Fragment>
)
</>
);
}
function renderLocation (location) {
function renderLocation(location) {
/**
{
events: [...],
@@ -129,53 +144,55 @@ function MapEvents ({
longitude: '32.2'
}
*/
if (!location.latitude || !location.longitude) return null
const { x, y } = projectPoint([location.latitude, location.longitude])
if (!location.latitude || !location.longitude) return null;
const { x, y } = projectPoint([location.latitude, location.longitude]);
// in narrative mode, only render events in narrative
// TODO: move this to a selector
if (narrative) {
const { steps } = narrative
const onlyIfInNarrative = e => steps.map(s => s.id).includes(e.id)
const eventsInNarrative = location.events.filter(onlyIfInNarrative)
const { steps } = narrative;
const onlyIfInNarrative = (e) => steps.map((s) => s.id).includes(e.id);
const eventsInNarrative = location.events.filter(onlyIfInNarrative);
if (eventsInNarrative.length <= 0) {
return null
return null;
}
}
const customStyles = styleLocation ? styleLocation(location) : null
const extraRender = () => (
<React.Fragment>
{customStyles[1]}
</React.Fragment>
)
const customStyles = styleLocation ? styleLocation(location) : null;
const extraRender = () => <>{customStyles[1]}</>;
const isSelected = selected.reduce((acc, event) => {
return acc || (event.latitude === location.latitude && event.longitude === location.longitude)
}, false)
return (
acc ||
(event.latitude === location.latitude &&
event.longitude === location.longitude)
);
}, false);
return (
<g
className={`location-event ${narrative ? 'no-hover' : ''}`}
className={`location-event ${narrative ? "no-hover" : ""}`}
transform={`translate(${x}, ${y})`}
onClick={(e) => handleEventSelect(e, location)}
>
{features.COLOR_BY_ASSOCIATION ? renderLocationSlicesByAssociation(location) : null}
{features.COLOR_BY_CATEGORY ? renderLocationSlicesByCategory(location) : null}
{features.COLOR_BY_ASSOCIATION
? renderLocationSlicesByAssociation(location)
: null}
{features.COLOR_BY_CATEGORY
? renderLocationSlicesByCategory(location)
: null}
{extraRender ? extraRender() : null}
{isSelected ? null : renderBorder()}
</g>
)
);
}
return (
<Portal node={svg}>
<g className='event-locations'>
{locations.map(renderLocation)}
</g>
<g className="event-locations">{locations.map(renderLocation)}</g>
</Portal>
)
);
}
export default MapEvents
export default MapEvents;

View File

@@ -1,206 +1,208 @@
import React from 'react'
import { Portal } from 'react-portal'
import React from "react";
import { Portal } from "react-portal";
// import { concatStatic } from 'rxjs/operator/concat'
// import { single } from 'rxjs/operator/single'
const defaultStyles = {
strokeOpacity: 1,
strokeWidth: 0,
strokeDasharray: 'none',
stroke: 'none'
}
strokeDasharray: "none",
stroke: "none",
};
function MapNarratives ({
function MapNarratives({
styles,
onSelectNarrative,
svg,
narrative,
narratives,
projectPoint,
features
features,
}) {
function getNarrativeStyle (narrativeId) {
const styleName = (narrativeId && narrativeId in styles)
? narrativeId
: 'default'
return styles[styleName]
function getNarrativeStyle(narrativeId) {
const styleName =
narrativeId && narrativeId in styles ? narrativeId : "default";
return styles[styleName];
}
const narrativesExist = narratives && narratives.length !== 0
const narrativesExist = narratives && narratives.length !== 0;
function hasNoLocation (step) {
return (step.latitude === '' || step.longitude === '')
function hasNoLocation(step) {
return step.latitude === "" || step.longitude === "";
}
function _renderNarrativeStepArrow (p1, p2, styles) {
const distance = Math.sqrt((p2.x - p1.x) * (p2.x - p1.x) + (p2.y - p1.y) * (p2.y - p1.y))
const theta = Math.atan2(p2.y - p1.y, p2.x - p1.x) // Angle of narrative step line
const alpha = Math.atan2(1, 2) // Angle of arrow overture
const edge = 10 // Arrow edge length
const offset = (distance < 24) ? distance / 2 : 24
function _renderNarrativeStepArrow(p1, p2, styles) {
const distance = Math.sqrt(
(p2.x - p1.x) * (p2.x - p1.x) + (p2.y - p1.y) * (p2.y - p1.y)
);
const theta = Math.atan2(p2.y - p1.y, p2.x - p1.x); // Angle of narrative step line
const alpha = Math.atan2(1, 2); // Angle of arrow overture
const edge = 10; // Arrow edge length
const offset = distance < 24 ? distance / 2 : 24;
// Arrow corners
const coord0 = {
x: p2.x - offset * Math.cos(theta),
y: p2.y - offset * Math.sin(theta)
}
y: p2.y - offset * Math.sin(theta),
};
const coord1 = {
x: coord0.x - edge * Math.cos(-theta - alpha),
y: coord0.y + edge * Math.sin(-theta - alpha)
}
y: coord0.y + edge * Math.sin(-theta - alpha),
};
const coord2 = {
x: coord0.x - edge * Math.cos(-theta + alpha),
y: coord0.y + edge * Math.sin(-theta + alpha)
}
y: coord0.y + edge * Math.sin(-theta + alpha),
};
return (<path
className='narrative-step-arrow'
d={`
return (
<path
className="narrative-step-arrow"
d={`
M ${coord0.x} ${coord0.y}
L ${coord1.x} ${coord1.y}
L ${coord2.x} ${coord2.y} Z
`}
style={{
...styles,
fillOpacity: styles.strokeOpacity,
fill: styles.stroke
}}
/>)
style={{
...styles,
fillOpacity: styles.strokeOpacity,
fill: styles.stroke,
}}
/>
);
}
function _renderNarrativeStep (p1, p2, styles) {
const { stroke, strokeWidth, strokeDasharray, strokeOpacity } = styles
function _renderNarrativeStep(p1, p2, styles) {
const { stroke, strokeWidth, strokeDasharray, strokeOpacity } = styles;
return (
<g>
<line
className='narrative-step'
className="narrative-step"
x1={p1.x}
x2={p2.x}
y1={p1.y}
y2={p2.y}
markerStart='none'
onClick={n => onSelectNarrative(n)}
markerStart="none"
onClick={(n) => onSelectNarrative(n)}
style={{
strokeWidth,
strokeDasharray,
strokeOpacity,
stroke
stroke,
}}
/>
{(stroke !== 'none')
? _renderNarrativeStepArrow(p1, p2, styles)
: ''
}
{stroke !== "none" ? _renderNarrativeStepArrow(p1, p2, styles) : ""}
</g>
)
);
}
function renderBetweenSteps (step1, step2, extraStyles) {
function renderBetweenSteps(step1, step2, extraStyles) {
// don't draw if one of the steps has no location, or not in narrative
if (hasNoLocation(step1) || hasNoLocation(step2)) {
return null
return null;
}
// don't draw if something else is up
if (!step1 || !step2) {
return null
return null;
}
const p1 = projectPoint([step1.latitude, step1.longitude])
const p2 = projectPoint([step2.latitude, step2.longitude])
const p1 = projectPoint([step1.latitude, step1.longitude]);
const p2 = projectPoint([step2.latitude, step2.longitude]);
return _renderNarrativeStep(p1, p2, {
...defaultStyles,
...(extraStyles || {})
})
...(extraStyles || {}),
});
}
function renderFullNarrative (n) {
function renderFullNarrative(n) {
if (n === null || n.id !== narrative.id) {
return null
return null;
}
const arrows = []
const arrows = [];
for (let idx = 0; idx < n.steps.length - 1; idx += 1) {
const step1 = n.steps[idx]
const step2 = n.steps[idx + 1]
arrows.push(renderBetweenSteps(step1, step2, getNarrativeStyle(n.id)))
const step1 = n.steps[idx];
const step2 = n.steps[idx + 1];
arrows.push(renderBetweenSteps(step1, step2, getNarrativeStyle(n.id)));
}
return arrows
return arrows;
}
function renderBetweenMarked (n) {
function renderBetweenMarked(n) {
// this function should only be called if features.NARRATIVE_STEP_STYLES
// is true, and thus there is a 'stepStyles' attributes in events
if (n === null || n.id !== narrative.id) {
return null
return null;
}
const arrows = []
const arrows = [];
let lastMarked = null
let lastMarked = null;
if (narrativesExist) {
for (let idx = 0; idx < n.steps.length; idx += 1) {
const step = n.steps[idx]
const step = n.steps[idx];
if (lastMarked) {
arrows.push(renderBetweenSteps(
lastMarked,
step,
n.withLines ? { strokeWidth: '1px', stroke: step.colour } : {})
)
arrows.push(
renderBetweenSteps(
lastMarked,
step,
n.withLines ? { strokeWidth: "1px", stroke: step.colour } : {}
)
);
}
lastMarked = step
lastMarked = step;
}
} else {
for (let idx = 0; idx < n.steps.length; idx += 1) {
const step = n.steps[idx]
const _idx = step.narratives.indexOf(n.id)
const stepStyle = step.narrative___stepStyles[_idx]
const step = n.steps[idx];
const _idx = step.narratives.indexOf(n.id);
const stepStyle = step.narrative___stepStyles[_idx];
if (stepStyle !== 'None') {
if (stepStyle !== "None") {
if (lastMarked) {
arrows.push(renderBetweenSteps(lastMarked, step, styles.stepStyles[stepStyle]))
arrows.push(
renderBetweenSteps(lastMarked, step, styles.stepStyles[stepStyle])
);
}
lastMarked = step
lastMarked = step;
}
}
}
return arrows
return arrows;
}
function renderNarrative (n) {
const narrativeId = `narrative-${n.id.replace(/ /g, '_')}`
function renderNarrative(n) {
const narrativeId = `narrative-${n.id.replace(/ /g, "_")}`;
const body = narrativesExist
? renderBetweenMarked(n)
: (features.NARRATIVE_STEP_STYLES
? renderBetweenMarked(n)
: renderFullNarrative(n))
: features.NARRATIVE_STEP_STYLES
? renderBetweenMarked(n)
: renderFullNarrative(n);
return (
<g id={narrativeId} className='narrative'>
<g id={narrativeId} className="narrative">
{body}
</g>
)
);
}
// don't render in explore mode
if (narrative === null) {
return null
return null;
}
return (
<Portal node={svg}>
<g className='narratives'>
{narratives.map(renderNarrative)}
</g>
<g className="narratives">{narratives.map(renderNarrative)}</g>
</Portal>
)
);
}
export default MapNarratives
export default MapNarratives;

View File

@@ -1,38 +1,38 @@
import React from 'react'
import { Portal } from 'react-portal'
import colors from '../../../common/global.js'
import React from "react";
import { Portal } from "react-portal";
import colors from "../../../common/global.js";
class MapSelectedEvents extends React.Component {
renderMarker (marker) {
const { x, y } = this.props.projectPoint([marker.latitude, marker.longitude])
const styles = this.props.styles
const r = marker.radius ? marker.radius + 5 : 24
renderMarker(marker) {
const { x, y } = this.props.projectPoint([
marker.latitude,
marker.longitude,
]);
const styles = this.props.styles;
const r = marker.radius ? marker.radius + 5 : 24;
return (
<g
className='location-marker'
transform={`translate(${x - r}, ${y})`}
>
<g className="location-marker" transform={`translate(${x - r}, ${y})`}>
<path
className='leaflet-interactive'
className="leaflet-interactive"
stroke={styles ? styles.stroke : colors.primaryHighlight}
stroke-opacity='1'
stroke-width={styles ? styles['stroke-width'] : 2}
stroke-linecap=''
stroke-linejoin='round'
stroke-dasharray={styles ? styles['stroke-dasharray'] : '2,2'}
fill='none'
stroke-opacity="1"
stroke-width={styles ? styles["stroke-width"] : 2}
stroke-linecap=""
stroke-linejoin="round"
stroke-dasharray={styles ? styles["stroke-dasharray"] : "2,2"}
fill="none"
d={`M0,0a${r},${r} 0 1,0 ${r * 2},0 a${r},${r} 0 1,0 -${r * 2},0 `}
/>
</g>
)
);
}
render () {
render() {
return (
<Portal node={this.props.svg}>
{this.props.selected.map(s => this.renderMarker(s))}
{this.props.selected.map((s) => this.renderMarker(s))}
</Portal>
)
);
}
}
export default MapSelectedEvents
export default MapSelectedEvents;

View File

@@ -1,49 +1,47 @@
import React from 'react'
import { Portal } from 'react-portal'
import React from "react";
import { Portal } from "react-portal";
function MapShapes ({ svg, shapes, projectPoint, styles }) {
function renderShape (shape) {
const lineCoords = []
const points = shape.points
.map(projectPoint)
function MapShapes({ svg, shapes, projectPoint, styles }) {
function renderShape(shape) {
const lineCoords = [];
const points = shape.points.map(projectPoint);
points.forEach((p1, idx) => {
if (idx < shape.points.length - 1) {
const p2 = points[idx + 1]
const p2 = points[idx + 1];
lineCoords.push({
x1: p1.x,
y1: p1.y,
x2: p2.x,
y2: p2.y
})
y2: p2.y,
});
}
})
});
return lineCoords.map(coords => {
const shapeStyles = (shape.name in styles)
? styles[shape.name]
: styles.default
return lineCoords.map((coords) => {
const shapeStyles =
shape.name in styles ? styles[shape.name] : styles.default;
return (
<line
id={`${shape.name}_style`}
markerStart='none'
markerStart="none"
{...coords}
style={shapeStyles}
/>
)
})
);
});
}
if (!shapes || !shapes.length) return null
if (!shapes || !shapes.length) return null;
return (
<Portal node={svg}>
<g id={`shapes-layer`} className='narrative'>
<g id="shapes-layer" className="narrative">
{shapes.map(renderShape)}
</g>
</Portal>
)
);
}
export default MapShapes
export default MapShapes;

View File

@@ -1,24 +1,25 @@
import React from 'react'
import React from "react";
function MapSites ({ sites, projectPoint }) {
function renderSite (site) {
const { x, y } = projectPoint([site.latitude, site.longitude])
function MapSites({ sites, projectPoint }) {
function renderSite(site) {
const { x, y } = projectPoint([site.latitude, site.longitude]);
return (<div
className='leaflet-tooltip site-label leaflet-zoom-animated leaflet-tooltip-top'
style={{ opacity: 1, transform: `translate3d(calc(${x}px - 50%), ${y - 25}px, 0px)` }}>
{site.site}
</div>
)
return (
<div
className="leaflet-tooltip site-label leaflet-zoom-animated leaflet-tooltip-top"
style={{
opacity: 1,
transform: `translate3d(calc(${x}px - 50%), ${y - 25}px, 0px)`,
}}
>
{site.site}
</div>
);
}
if (!sites || !sites.length) return null
if (!sites || !sites.length) return null;
return (
<div className='sites-layer'>
{sites.map(renderSite)}
</div>
)
return <div className="sites-layer">{sites.map(renderSite)}</div>;
}
export default MapSites
export default MapSites;

View File

@@ -1,4 +1,4 @@
import React from 'react'
import React from "react";
export default ({ isDisabled, direction, onClickHandler }) => {
return (
@@ -6,11 +6,9 @@ export default ({ isDisabled, direction, onClickHandler }) => {
className={`narrative-adjust ${direction}`}
onClick={!isDisabled ? onClickHandler : null}
>
<i
className={`material-icons ${isDisabled ? 'disabled' : ''}`}
>
<i className={`material-icons ${isDisabled ? "disabled" : ""}`}>
{`chevron_${direction}`}
</i>
</div>
)
}
);
};

View File

@@ -1,17 +1,17 @@
import React from 'react'
import { connect } from 'react-redux'
import { selectActiveNarrative } from '../../../selectors'
import React from "react";
import { connect } from "react-redux";
import { selectActiveNarrative } from "../../../selectors";
function NarrativeCard ({ narrative }) {
function NarrativeCard({ narrative }) {
// no display if no narrative
const { steps, current } = narrative
const { steps, current } = narrative;
if (steps[current]) {
return (
<div className='narrative-info'>
<div className='narrative-info-header'>
<div className='count-container'>
<div className='count'>
<div className="narrative-info">
<div className="narrative-info-header">
<div className="count-container">
<div className="count">
{current + 1}/{steps.length}
</div>
</div>
@@ -22,19 +22,19 @@ function NarrativeCard ({ narrative }) {
{/* <i className='material-icons left'>location_on</i> */}
{/* {_renderActions(current, steps)} */}
<div className='narrative-info-desc'>
<div className="narrative-info-desc">
<p>{narrative.description}</p>
</div>
</div>
)
);
} else {
return null
return null;
}
}
function mapStateToProps (state) {
function mapStateToProps(state) {
return {
narrative: selectActiveNarrative(state)
}
narrative: selectActiveNarrative(state),
};
}
export default connect(mapStateToProps)(NarrativeCard)
export default connect(mapStateToProps)(NarrativeCard);

View File

@@ -1,17 +1,12 @@
import React from 'react'
import React from "react";
export default ({ onClickHandler, closeMsg }) => {
return (
<div
className='narrative-close'
onClick={onClickHandler}
>
<button
className='side-menu-burg is-active'
>
<div className="narrative-close" onClick={onClickHandler}>
<button className="side-menu-burg is-active">
<span />
</button>
<div className='close-text'>{closeMsg}</div>
<div className="close-text">{closeMsg}</div>
</div>
)
}
);
};

View File

@@ -1,32 +1,32 @@
import React from 'react'
import Card from './Card'
import Adjust from './Adjust'
import Close from './Close'
import React from "react";
import Card from "./Card";
import Adjust from "./Adjust";
import Close from "./Close";
export default ({ narrative, methods }) => {
if (!narrative) return null
if (!narrative) return null;
const { current, steps } = narrative
const prevExists = current !== 0
const nextExists = current < steps.length - 1
const { current, steps } = narrative;
const prevExists = current !== 0;
const nextExists = current < steps.length - 1;
return (
<React.Fragment>
<>
<Card narrative={narrative} />
<Adjust
isDisabled={!prevExists}
direction='left'
direction="left"
onClickHandler={methods.onPrev}
/>
<Adjust
isDisabled={!nextExists}
direction='right'
direction="right"
onClickHandler={methods.onNext}
/>
<Close
onClickHandler={() => methods.onSelectNarrative(null)}
closeMsg='-- exit from narrative --'
closeMsg="-- exit from narrative --"
/>
</React.Fragment>
)
}
</>
);
};

View File

@@ -1,16 +1,19 @@
import React from 'react'
import React from "react";
const NoSource = ({ failedUrls }) => {
return (
<div className='no-source-container'>
<div className='no-source-row'>
<div className="no-source-container">
<div className="no-source-row">
<p>
<i className='material-icons no-source-icon'>error</i>
<i className="material-icons no-source-icon">error</i>
</p>
<p>
No media found, as the original media has not yet been uploaded to the
platform.
</p>
<p>No media found, as the original media has not yet been uploaded to the platform.</p>
</div>
</div>
)
}
);
};
export default NoSource
export default NoSource;

View File

@@ -1,7 +1,7 @@
import React from 'react'
import marked from 'marked'
import React from "react";
import marked from "marked";
const fontSize = window.innerWidth > 1000 ? 14 : 18
const fontSize = window.innerWidth > 1000 ? 14 : 18;
export default ({
content = [],
@@ -9,18 +9,30 @@ export default ({
isOpen = true,
onClose,
title,
theme = 'light',
theme = "light",
isMobile = false,
children
children,
}) => (
<div>
<div className={`infopopup ${isOpen ? '' : 'hidden'} ${theme === 'dark' ? 'dark' : 'light'} ${isMobile ? 'mobile' : ''}`} style={{ ...styles, fontSize }}>
<div className='legend-header'>
<button onClick={onClose} className='side-menu-burg over-white is-active'><span /></button>
<div
className={`infopopup ${isOpen ? "" : "hidden"} ${
theme === "dark" ? "dark" : "light"
} ${isMobile ? "mobile" : ""}`}
style={{ ...styles, fontSize }}
>
<div className="legend-header">
<button
onClick={onClose}
className="side-menu-burg over-white is-active"
>
<span />
</button>
<h2>{title}</h2>
</div>
{content.map(t => <div dangerouslySetInnerHTML={{ __html: marked(t) }} />)}
{content.map((t) => (
<div dangerouslySetInnerHTML={{ __html: marked(t) }} />
))}
{children}
</div>
</div>
)
);

View File

@@ -1,12 +1,12 @@
import React from 'react'
import React from "react";
const Spinner = ({ small }) => {
return (
<div className={`spinner ${small ? 'small' : ''}`}>
<div className='double-bounce-overlay' />
<div className='double-bounce' />
<div className={`spinner ${small ? "small" : ""}`}>
<div className="double-bounce-overlay" />
<div className="double-bounce" />
</div>
)
}
);
};
export default Spinner
export default Spinner;

View File

@@ -1,14 +1,14 @@
import React from 'react'
import React from "react";
const TimelineClip = ({ dims }) => (
<clipPath id='clip'>
<clipPath id="clip">
<rect
x={dims.marginLeft}
y='0'
y="0"
width={dims.width - dims.marginLeft - dims.width_controls}
height={dims.contentHeight}
/>
</clipPath>
)
);
export default TimelineClip
export default TimelineClip;

View File

@@ -1,4 +1,4 @@
import React from 'react'
import React from "react";
export default ({
highlights,
@@ -9,35 +9,35 @@ export default ({
height,
onSelect,
styleProps,
extraRender
extraRender,
}) => {
if (highlights.length === 0) {
return (
<rect
onClick={onSelect}
className='event'
className="event"
x={x}
y={y}
style={styleProps}
width={width}
height={height}
/>
)
);
}
const sectionHeight = height / highlights.length
const sectionHeight = height / highlights.length;
return (
<React.Fragment>
<>
{highlights.map((h, idx) => (
<rect
onClick={onSelect}
className='event'
className="event"
x={x}
y={y - sectionHeight + (idx * sectionHeight) + (sectionHeight / 2)}
y={y - sectionHeight + idx * sectionHeight + sectionHeight / 2}
style={{ ...styleProps, opacity: h ? 0.3 : 0.1 }}
width={width}
height={sectionHeight}
/>
))}
</React.Fragment>
)
}
</>
);
};

View File

@@ -1,4 +1,4 @@
import React from 'react'
import React from "react";
export default ({
category,
@@ -8,17 +8,17 @@ export default ({
r,
onSelect,
styleProps,
extraRender
extraRender,
}) => {
if (!y) return null
if (!y) return null;
return (
<circle
onClick={onSelect}
className='event'
className="event"
cx={x}
cy={y}
style={styleProps}
r={r}
/>
)
}
);
};

View File

@@ -1,18 +1,10 @@
import React from 'react'
import React from "react";
export default ({
x,
y,
r,
transform,
onSelect,
styleProps,
extraRender
}) => {
export default ({ x, y, r, transform, onSelect, styleProps, extraRender }) => {
return (
<rect
onClick={onSelect}
className='event'
className="event"
x={x}
y={y - r}
style={styleProps}
@@ -20,5 +12,5 @@ export default ({
height={r}
transform={`rotate(45, ${x}, ${y})`}
/>
)
}
);
};

View File

@@ -1,23 +1,17 @@
import React from 'react'
import React from "react";
export default ({
x,
y,
r,
transform,
onSelect,
styleProps,
extraRender
}) => {
const s = r * 2 / 3
export default ({ x, y, r, transform, onSelect, styleProps, extraRender }) => {
const s = (r * 2) / 3;
return (
<polygon
onClick={onSelect}
className='event'
className="event"
x={x}
y={y - r}
style={styleProps}
points={`${x},${y + s} ${x - s},${y - s} ${x + s},${y} ${x - s},${y} ${x + s},${y - s}`}
points={`${x},${y + s} ${x - s},${y - s} ${x + s},${y} ${x - s},${y} ${
x + s
},${y - s}`}
/>
)
}
);
};

View File

@@ -1,74 +1,89 @@
import React from 'react'
import DatetimeBar from './DatetimeBar'
import DatetimeSquare from './DatetimeSquare'
import DatetimeStar from './DatetimeStar'
import Project from './Project'
import ColoredMarkers from '../Map/ColoredMarkers.jsx'
import React from "react";
import DatetimeBar from "./DatetimeBar";
import DatetimeSquare from "./DatetimeSquare";
import DatetimeStar from "./DatetimeStar";
import Project from "./Project";
import ColoredMarkers from "../Map/ColoredMarkers.jsx";
import {
calcOpacity,
getEventCategories,
zipColorsToPercentages,
calculateColorPercentages,
isLatitude,
isLongitude } from '../../../common/utilities'
isLongitude,
} from "../../../common/utilities";
function renderDot (event, styles, props) {
const colorPercentages = calculateColorPercentages([event], props.coloringSet)
function renderDot(event, styles, props) {
const colorPercentages = calculateColorPercentages(
[event],
props.coloringSet
);
return (
<g
className={'timeline-event'}
className="timeline-event"
onClick={props.onSelect}
transform={`translate(${props.x}, ${props.y})`}
>
<ColoredMarkers
radius={props.eventRadius}
colorPercentMap={zipColorsToPercentages(props.filterColors, colorPercentages)}
colorPercentMap={zipColorsToPercentages(
props.filterColors,
colorPercentages
)}
styles={{
...styles
...styles,
}}
className={'event'}
className="event"
/>
</g>
)
);
}
function renderBar (event, styles, props) {
function renderBar(event, styles, props) {
const fillOpacity = props.features.GRAPH_NONLOCATED
? event.projectOffset >= 0 ? styles.opacity : 0.5
: calcOpacity(1)
? event.projectOffset >= 0
? styles.opacity
: 0.5
: calcOpacity(1);
return <DatetimeBar
onSelect={props.onSelect}
category={event.category}
events={[event]}
x={props.x}
y={props.dims.marginTop}
width={props.eventRadius / 4}
height={props.dims.trackHeight}
styleProps={{ ...styles, fillOpacity }}
highlights={props.highlights}
/>
return (
<DatetimeBar
onSelect={props.onSelect}
category={event.category}
events={[event]}
x={props.x}
y={props.dims.marginTop}
width={props.eventRadius / 4}
height={props.dims.trackHeight}
styleProps={{ ...styles, fillOpacity }}
highlights={props.highlights}
/>
);
}
function renderDiamond (event, styles, props) {
return <DatetimeSquare
onSelect={props.onSelect}
x={props.x}
y={props.y}
r={1.8 * props.eventRadius}
styleProps={styles}
/>
function renderDiamond(event, styles, props) {
return (
<DatetimeSquare
onSelect={props.onSelect}
x={props.x}
y={props.y}
r={1.8 * props.eventRadius}
styleProps={styles}
/>
);
}
function renderStar (event, styles, props) {
return <DatetimeStar
onSelect={props.onSelect}
x={props.x}
y={props.y}
r={1.8 * props.eventRadius}
styleProps={{ ...styles, fillRule: 'nonzero' }}
transform='rotate(90)'
/>
function renderStar(event, styles, props) {
return (
<DatetimeStar
onSelect={props.onSelect}
x={props.x}
y={props.y}
r={1.8 * props.eventRadius}
styleProps={{ ...styles, fillRule: "nonzero" }}
transform="rotate(90)"
/>
);
}
const TimelineEvents = ({
@@ -88,95 +103,105 @@ const TimelineEvents = ({
setNotLoading,
eventRadius,
filterColors,
coloringSet
coloringSet,
}) => {
const narIds = narrative ? narrative.steps.map(s => s.id) : []
const narIds = narrative ? narrative.steps.map((s) => s.id) : [];
function renderEvent (acc, event) {
function renderEvent(acc, event) {
if (narrative) {
if (!(narIds.includes(event.id))) {
return null
if (!narIds.includes(event.id)) {
return null;
}
}
const isDot = (isLatitude(event.latitude) && isLongitude(event.longitude)) || (features.GRAPH_NONLOCATED && event.projectOffset !== -1)
const isDot =
(isLatitude(event.latitude) && isLongitude(event.longitude)) ||
(features.GRAPH_NONLOCATED && event.projectOffset !== -1);
let renderShape = isDot ? renderDot : renderBar
let renderShape = isDot ? renderDot : renderBar;
if (event.shape) {
if (event.shape === 'bar') {
renderShape = renderBar
} else if (event.shape === 'diamond') {
renderShape = renderDiamond
} else if (event.shape === 'star') {
renderShape = renderStar
if (event.shape === "bar") {
renderShape = renderBar;
} else if (event.shape === "diamond") {
renderShape = renderDiamond;
} else if (event.shape === "star") {
renderShape = renderStar;
} else {
renderShape = renderDot
renderShape = renderDot;
}
}
// if an event has multiple categories, it should be rendered on each of
// those timelines: so we create as many event 'shadows' as there are
// categories
const evShadows = getEventCategories(event, categories).map(cat => {
const y = getY({ ...event, category: cat.id })
const evShadows = getEventCategories(event, categories).map((cat) => {
const y = getY({ ...event, category: cat.id });
let colour = event.colour ? event.colour : getCategoryColor(cat.id)
const colour = event.colour ? event.colour : getCategoryColor(cat.id);
const styles = {
fill: colour,
fillOpacity: y > 0 ? calcOpacity(1) : 0,
transition: `transform ${transitionDuration / 1000}s ease`
}
transition: `transform ${transitionDuration / 1000}s ease`,
};
return { y, styles }
})
return { y, styles };
});
function getRender (y, styles) {
function getRender(y, styles) {
return renderShape(event, styles, {
x: getDatetimeX(event.datetime),
y,
eventRadius,
onSelect: () => onSelect(event),
dims,
highlights: features.HIGHLIGHT_GROUPS ? getHighlights(event.filters[features.HIGHLIGHT_GROUPS.filterIndexIndicatingGroup]) : [],
highlights: features.HIGHLIGHT_GROUPS
? getHighlights(
event.filters[
features.HIGHLIGHT_GROUPS.filterIndexIndicatingGroup
]
)
: [],
features,
filterColors,
coloringSet
})
coloringSet,
});
}
if (evShadows.length === 0) {
acc.push(getRender(getY(event), { fill: getCategoryColor(null) }))
acc.push(getRender(getY(event), { fill: getCategoryColor(null) }));
} else {
evShadows.forEach(evShadow => {
acc.push(getRender(evShadow.y, evShadow.styles))
})
evShadows.forEach((evShadow) => {
acc.push(getRender(evShadow.y, evShadow.styles));
});
}
return acc
return acc;
}
let renderProjects = () => null
let renderProjects = () => null;
if (features.GRAPH_NONLOCATED) {
renderProjects = function () {
return <React.Fragment>
{Object.values(projects).map(project => <Project
{...project}
eventRadius={eventRadius}
onClick={() => console.log(project)}
getX={getDatetimeX}
dims={dims}
colour={getCategoryColor(project.category)}
/>)}
</React.Fragment>
}
return (
<>
{Object.values(projects).map((project) => (
<Project
{...project}
eventRadius={eventRadius}
onClick={() => console.log(project)}
getX={getDatetimeX}
dims={dims}
colour={getCategoryColor(project.category)}
/>
))}
</>
);
};
}
return (
<g
clipPath={'url(#clip)'}
>
<g clipPath="url(#clip)">
{renderProjects()}
{events.reduce(renderEvent, [])}
</g>
)
}
);
};
export default TimelineEvents
export default TimelineEvents;

View File

@@ -1,26 +1,36 @@
import React from 'react'
import React from "react";
const TimelineHandles = ({ dims, onMoveTime }) => {
const transform = 'scale(1.5,1.5)'
const size = 45
const transform = "scale(1.5,1.5)";
const size = 45;
return (
<g className='time-controls-inline'>
<g className="time-controls-inline">
<g
transform={`translate(${dims.marginLeft - 20}, ${dims.contentHeight - 10})`}
onClick={() => onMoveTime('backwards')}
transform={`translate(${dims.marginLeft - 20}, ${
dims.contentHeight - 10
})`}
onClick={() => onMoveTime("backwards")}
>
<circle r={size} />
<path d='M0,-7.847549217020565L6.796176979388489,3.9237746085102825L-6.796176979388489,3.9237746085102825Z' transform={`rotate(270) ${transform}`} />
<path
d="M0,-7.847549217020565L6.796176979388489,3.9237746085102825L-6.796176979388489,3.9237746085102825Z"
transform={`rotate(270) ${transform}`}
/>
</g>
<g
transform={`translate(${dims.width - dims.width_controls + 20}, ${dims.contentHeight - 10})`}
onClick={() => onMoveTime('forward')}
transform={`translate(${dims.width - dims.width_controls + 20}, ${
dims.contentHeight - 10
})`}
onClick={() => onMoveTime("forward")}
>
<circle r={size} />
<path d='M0,-7.847549217020565L6.796176979388489,3.9237746085102825L-6.796176979388489,3.9237746085102825Z' transform={`rotate(90) ${transform}`} />
<path
d="M0,-7.847549217020565L6.796176979388489,3.9237746085102825L-6.796176979388489,3.9237746085102825Z"
transform={`rotate(90) ${transform}`}
/>
</g>
</g>
)
}
);
};
export default TimelineHandles
export default TimelineHandles;

View File

@@ -1,20 +1,24 @@
import React from 'react'
import { makeNiceDate } from '../../../common/utilities'
import React from "react";
import { makeNiceDate } from "../../../common/utilities";
const TimelineHeader = ({ title, from, to, onClick, hideInfo }) => {
const d0 = from && makeNiceDate(from)
const d1 = to && makeNiceDate(to)
const d0 = from && makeNiceDate(from);
const d1 = to && makeNiceDate(to);
return (
<div className='timeline-header'>
<div className='timeline-toggle' onClick={() => onClick()}>
<p><i className='arrow-down' /></p>
<div className="timeline-header">
<div className="timeline-toggle" onClick={() => onClick()}>
<p>
<i className="arrow-down" />
</p>
</div>
<div className={`timeline-info ${hideInfo ? 'hidden' : ''}`}>
<div className={`timeline-info ${hideInfo ? "hidden" : ""}`}>
<p>{title}</p>
<p>{d0} - {d1}</p>
<p>
{d0} - {d1}
</p>
</div>
</div>
)
}
);
};
export default TimelineHeader
export default TimelineHeader;

View File

@@ -1,39 +1,35 @@
import React from 'react'
import React from "react";
const TimelineLabels = ({ dims, timelabels }) => {
return (
<g>
<line
class='axisBoundaries'
class="axisBoundaries"
x1={dims.marginLeft}
x2={dims.marginLeft}
y1='10'
y2='20'
y1="10"
y2="20"
/>
<line
class='axisBoundaries'
class="axisBoundaries"
x1={dims.width - dims.width_controls}
x2={dims.width - dims.width_controls}
y1='10'
y2='20'
y1="10"
y2="20"
/>
<text
class='timeLabel0 timeLabel'
x='5'
y='15'
>
<text class="timeLabel0 timeLabel" x="5" y="15">
{timelabels[0]}
</text>
<text
class='timelabelF timeLabel'
class="timelabelF timeLabel"
x={dims.width - dims.width_controls - 5}
y='135'
style={{ textAnchor: 'end' }}
y="135"
style={{ textAnchor: "end" }}
>
{timelabels[1]}
</text>
</g>
)
}
);
};
export default TimelineLabels
export default TimelineLabels;

View File

@@ -1,6 +1,10 @@
import React from 'react'
import colors from '../../../common/global'
import { getEventCategories, isLatitude, isLongitude } from '../../../common/utilities'
import React from "react";
import colors from "../../../common/global";
import {
getEventCategories,
isLatitude,
isLongitude,
} from "../../../common/utilities";
const TimelineMarkers = ({
styles,
@@ -11,79 +15,83 @@ const TimelineMarkers = ({
transitionDuration,
selected,
dims,
features
features,
}) => {
function renderMarker (acc, event) {
function renderCircle (y) {
return <circle
className='timeline-marker'
cx={0}
cy={0}
stroke={styles ? styles.stroke : colors.primaryHighlight}
stroke-opacity='1'
stroke-width={styles ? styles['stroke-width'] : 1}
stroke-linejoin='round'
stroke-dasharray={styles ? styles['stroke-dasharray'] : '2,2'}
style={{
'transform': `translate(${getEventX(event)}px, ${y}px)`,
'-webkit-transition': `transform ${transitionDuration / 1000}s ease`,
'-moz-transition': 'none',
'opacity': 1
}}
r={eventRadius * 2}
/>
function renderMarker(acc, event) {
function renderCircle(y) {
return (
<circle
className="timeline-marker"
cx={0}
cy={0}
stroke={styles ? styles.stroke : colors.primaryHighlight}
stroke-opacity="1"
stroke-width={styles ? styles["stroke-width"] : 1}
stroke-linejoin="round"
stroke-dasharray={styles ? styles["stroke-dasharray"] : "2,2"}
style={{
transform: `translate(${getEventX(event)}px, ${y}px)`,
"-webkit-transition": `transform ${
transitionDuration / 1000
}s ease`,
"-moz-transition": "none",
opacity: 1,
}}
r={eventRadius * 2}
/>
);
}
function renderBar () {
return <rect
className='timeline-marker'
x={0}
y={dims.marginTop}
width={eventRadius / 1.5}
height={dims.contentHeight - 55}
stroke={styles ? styles.stroke : colors.primaryHighlight}
stroke-opacity='1'
stroke-width={styles ? styles['stroke-width'] : 1}
stroke-dasharray={styles ? styles['stroke-dasharray'] : '2,2'}
style={{
'transform': `translate(${getEventX(event)}px)`,
'opacity': 0.7
}}
/>
function renderBar() {
return (
<rect
className="timeline-marker"
x={0}
y={dims.marginTop}
width={eventRadius / 1.5}
height={dims.contentHeight - 55}
stroke={styles ? styles.stroke : colors.primaryHighlight}
stroke-opacity="1"
stroke-width={styles ? styles["stroke-width"] : 1}
stroke-dasharray={styles ? styles["stroke-dasharray"] : "2,2"}
style={{
transform: `translate(${getEventX(event)}px)`,
opacity: 0.7,
}}
/>
);
}
const isDot = (isLatitude(event.latitude) && isLongitude(event.longitude)) || (features.GRAPH_NONLOCATED && event.projectOffset !== -1)
const evShadows = getEventCategories(event, categories).map(cat => getEventY({ ...event, category: cat.id }))
const isDot =
(isLatitude(event.latitude) && isLongitude(event.longitude)) ||
(features.GRAPH_NONLOCATED && event.projectOffset !== -1);
const evShadows = getEventCategories(event, categories).map((cat) =>
getEventY({ ...event, category: cat.id })
);
function renderMarkerForEvent (y) {
function renderMarkerForEvent(y) {
switch (event.shape) {
case 'circle':
case 'diamond':
case 'star':
acc.push(renderCircle(y))
break
case 'bar':
acc.push(renderBar(y))
break
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))
return isDot ? acc.push(renderCircle(y)) : acc.push(renderBar(y));
}
}
if (evShadows.length > 0) {
evShadows.forEach(renderMarkerForEvent)
evShadows.forEach(renderMarkerForEvent);
} else {
renderMarkerForEvent(getEventY(event))
renderMarkerForEvent(getEventY(event));
}
return acc
return acc;
}
return (
<g
clipPath={'url(#clip)'}
>
{selected.reduce(renderMarker, [])}
</g>
)
}
return <g clipPath="url(#clip)">{selected.reduce(renderMarker, [])}</g>;
};
export default TimelineMarkers
export default TimelineMarkers;

View File

@@ -1,4 +1,4 @@
import React from 'react'
import React from "react";
export default ({
offset,
@@ -10,17 +10,19 @@ export default ({
dims,
colour,
eventRadius,
onClick
onClick,
}) => {
const length = getX(end) - getX(start)
if (offset === undefined) return null
return <rect
onClick={onClick}
className='project'
x={getX(start)}
y={dims.marginTop + offset}
width={length}
style={{ fill: colour, fillOpacity: 0.2 }}
height={2 * eventRadius}
/>
}
const length = getX(end) - getX(start);
if (offset === undefined) return null;
return (
<rect
onClick={onClick}
className="project"
x={getX(start)}
y={dims.marginTop + offset}
width={length}
style={{ fill: colour, fillOpacity: 0.2 }}
height={2 * eventRadius}
/>
);
};

View File

@@ -1,45 +1,47 @@
import React from 'react'
import React from "react";
const DEFAULT_ZOOM_LEVELS = [
{ label: '20 years', duration: 10512000 },
{ label: '2 years', duration: 1051200 },
{ label: '3 months', duration: 129600 },
{ label: '3 days', duration: 4320 },
{ label: '12 hours', duration: 720 },
{ label: '1 hour', duration: 60 }
]
{ label: "20 years", duration: 10512000 },
{ label: "2 years", duration: 1051200 },
{ label: "3 months", duration: 129600 },
{ label: "3 days", duration: 4320 },
{ label: "12 hours", duration: 720 },
{ label: "1 hour", duration: 60 },
];
function zoomIsActive (duration, extent, max) {
function zoomIsActive(duration, extent, max) {
if (duration >= max && extent >= max) {
return true
return true;
}
return duration === extent
return duration === extent;
}
const TimelineZoomControls = ({ extent, zoomLevels, dims, onApplyZoom }) => {
function renderZoom (zoom, idx) {
const max = zoomLevels.reduce((acc, vl) => acc.duration < vl.duration ? vl : acc)
const isActive = zoomIsActive(zoom.duration, extent, max.duration)
function renderZoom(zoom, idx) {
const max = zoomLevels.reduce((acc, vl) =>
acc.duration < vl.duration ? vl : acc
);
const isActive = zoomIsActive(zoom.duration, extent, max.duration);
return (
<text
className={`zoom-level-button ${isActive ? 'active' : ''}`}
x='60'
y={(idx * 15) + 20}
className={`zoom-level-button ${isActive ? "active" : ""}`}
x="60"
y={idx * 15 + 20}
onClick={() => onApplyZoom(zoom)}
>
{zoom.label}
</text>
)
);
}
if (zoomLevels.length === 0) {
zoomLevels = DEFAULT_ZOOM_LEVELS
zoomLevels = DEFAULT_ZOOM_LEVELS;
}
return (
<g transform={`translate(${dims.width - dims.width_controls}, 0)`}>
{zoomLevels.map((z, idx) => renderZoom(z, idx))}
</g>
)
}
);
};
export default TimelineZoomControls
export default TimelineZoomControls;

View File

@@ -1,35 +1,47 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import store from './store/index.js'
import App from './components/App.jsx'
import React from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import store from "./store/index.js";
import App from "./components/App.jsx";
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('explore-app')
)
document.getElementById("explore-app")
);
// Expressions from https://exceptionshub.com/how-to-detect-safari-chrome-ie-firefox-and-opera-browser.html
/* eslint-disable */
// Opera 8.0+
const isOpera = (!!window.opr && !!opr.addons) || !!window.opera || navigator.userAgent.indexOf(' OPR/') >= 0
const isOpera =
(!!window.opr && !!opr.addons) ||
!!window.opera ||
navigator.userAgent.indexOf(" OPR/") >= 0;
// Firefox 1.0+
const isFirefox = typeof InstallTrigger !== 'undefined'
const isFirefox = typeof InstallTrigger !== "undefined";
// Safari 3.0+ "[object HTMLElementConstructor]"
const isSafari = /constructor/i.test(window.HTMLElement) || (function (p) { return p.toString() === '[object SafariRemoteNotification]' })(!window['safari'] || (typeof safari !== 'undefined' && safari.pushNotification))
const isSafari =
/constructor/i.test(window.HTMLElement) ||
(function (p) {
return p.toString() === "[object SafariRemoteNotification]";
})(
!window["safari"] ||
(typeof safari !== "undefined" && safari.pushNotification)
);
// Internet Explorer 6-11
const isIE = /* @cc_on!@ */false || !!document.documentMode
const isIE = /* @cc_on!@ */ false || !!document.documentMode;
// Edge 20+
const isEdge = !isIE && !!window.StyleMedia
const isEdge = !isIE && !!window.StyleMedia;
// Chrome 1+
const isChrome = !!window.chrome && !!window.chrome.webstore
const isChrome = !!window.chrome && !!window.chrome.webstore;
// Blink engine detection
const isBlink = (isChrome || isOpera) && !!window.CSS
const isBlink = (isChrome || isOpera) && !!window.CSS;
if (isEdge || isIE) {
alert('Please view this website in Opera for best viewing. It is untested in your browser.')
alert(
"Please view this website in Opera for best viewing. It is untested in your browser."
);
}
/* eslint-enable */

View File

@@ -1,6 +1,6 @@
import initial from '../store/initial.js'
import { ASSOCIATION_MODES } from '../common/constants'
import { toggleFlagAC } from '../common/utilities'
import initial from "../store/initial.js";
import { ASSOCIATION_MODES } from "../common/constants";
import { toggleFlagAC } from "../common/utilities";
import {
UPDATE_HIGHLIGHTED,
@@ -26,291 +26,298 @@ import {
SET_LOADING,
SET_NOT_LOADING,
SET_INITIAL_CATEGORIES,
UPDATE_SEARCH_QUERY
} from '../actions'
UPDATE_SEARCH_QUERY,
} from "../actions";
function updateHighlighted (appState, action) {
function updateHighlighted(appState, action) {
return Object.assign({}, appState, {
highlighted: action.highlighted
})
highlighted: action.highlighted,
});
}
function updateSelected (appState, action) {
function updateSelected(appState, action) {
return Object.assign({}, appState, {
selected: action.selected
})
selected: action.selected,
});
}
function updateColoringSet (appState, action) {
function updateColoringSet(appState, action) {
return {
...appState,
associations: {
...appState.associations,
coloringSet: action.coloringSet
}
}
coloringSet: action.coloringSet,
},
};
}
function updateNarrative (appState, action) {
let minTime = appState.timeline.range[0]
let maxTime = appState.timeline.range[1]
function updateNarrative(appState, action) {
let minTime = appState.timeline.range[0];
let maxTime = appState.timeline.range[1];
let cornerBound0 = [180, 180]
let cornerBound1 = [-180, -180]
const cornerBound0 = [180, 180];
const cornerBound1 = [-180, -180];
// Compute narrative time range and map bounds
if (action.narrative) {
// Forced to comment out min and max time changes, not sure why?
minTime = appState.timeline.rangeLimits[0]
maxTime = appState.timeline.rangeLimits[1]
minTime = appState.timeline.rangeLimits[0];
maxTime = appState.timeline.rangeLimits[1];
// Find max and mins coordinates of narrative events
action.narrative.steps.forEach(step => {
const stepTime = step.datetime
if (stepTime < minTime) minTime = stepTime
if (stepTime > maxTime) maxTime = stepTime
action.narrative.steps.forEach((step) => {
const stepTime = step.datetime;
if (stepTime < minTime) minTime = stepTime;
if (stepTime > maxTime) maxTime = stepTime;
if (!!step.longitude && !!step.latitude) {
if (+step.longitude < cornerBound0[1]) cornerBound0[1] = +step.longitude
if (+step.longitude > cornerBound1[1]) cornerBound1[1] = +step.longitude
if (+step.latitude < cornerBound0[0]) cornerBound0[0] = +step.latitude
if (+step.latitude > cornerBound1[0]) cornerBound1[0] = +step.latitude
if (+step.longitude < cornerBound0[1])
cornerBound0[1] = +step.longitude;
if (+step.longitude > cornerBound1[1])
cornerBound1[1] = +step.longitude;
if (+step.latitude < cornerBound0[0]) cornerBound0[0] = +step.latitude;
if (+step.latitude > cornerBound1[0]) cornerBound1[0] = +step.latitude;
}
})
});
// Adjust bounds to center around first event, while keeping visible all others
// Takes first event, finds max ditance with first attempt bounds, and use this max distance
// on the other side, both in latitude and longitude
const first = action.narrative.steps[0]
const first = action.narrative.steps[0];
if (!!first.longitude && !!first.latitude) {
const firstToLong0 = Math.abs(+first.longitude - cornerBound0[1])
const firstToLong1 = Math.abs(+first.longitude - cornerBound1[1])
const firstToLat0 = Math.abs(+first.latitude - cornerBound0[0])
const firstToLat1 = Math.abs(+first.latitude - cornerBound1[0])
const firstToLong0 = Math.abs(+first.longitude - cornerBound0[1]);
const firstToLong1 = Math.abs(+first.longitude - cornerBound1[1]);
const firstToLat0 = Math.abs(+first.latitude - cornerBound0[0]);
const firstToLat1 = Math.abs(+first.latitude - cornerBound1[0]);
if (firstToLong0 > firstToLong1) cornerBound1[1] = +first.longitude + firstToLong0
if (firstToLong0 < firstToLong1) cornerBound0[1] = +first.longitude - firstToLong1
if (firstToLat0 > firstToLat1) cornerBound1[0] = +first.latitude + firstToLat0
if (firstToLat0 < firstToLat1) cornerBound0[0] = +first.latitude - firstToLat1
if (firstToLong0 > firstToLong1)
cornerBound1[1] = +first.longitude + firstToLong0;
if (firstToLong0 < firstToLong1)
cornerBound0[1] = +first.longitude - firstToLong1;
if (firstToLat0 > firstToLat1)
cornerBound1[0] = +first.latitude + firstToLat0;
if (firstToLat0 < firstToLat1)
cornerBound0[0] = +first.latitude - firstToLat1;
}
// Add some buffer on both sides of the time extent
minTime = minTime - Math.abs((maxTime - minTime) / 10)
maxTime = maxTime + Math.abs((maxTime - minTime) / 10)
minTime = minTime - Math.abs((maxTime - minTime) / 10);
maxTime = maxTime + Math.abs((maxTime - minTime) / 10);
}
return {
...appState,
associations: {
...appState.associations,
narrative: action.narrative
narrative: action.narrative,
},
map: {
...appState.map,
bounds: (action.narrative) ? [cornerBound0, cornerBound1] : null
bounds: action.narrative ? [cornerBound0, cornerBound1] : null,
},
timeline: {
...appState.timeline,
range: [minTime, maxTime]
}
}
range: [minTime, maxTime],
},
};
}
function updateNarrativeStepIdx (appState, action) {
function updateNarrativeStepIdx(appState, action) {
return {
...appState,
narrativeState: {
current: action.idx
}
}
current: action.idx,
},
};
}
function toggleAssociations (appState, action) {
function toggleAssociations(appState, action) {
if (!(action.value instanceof Array)) {
action.value = [action.value]
action.value = [action.value];
}
const { association: associationType } = action
const { association: associationType } = action;
let newAssociations = appState.associations[associationType].slice(0)
action.value.forEach(vl => {
let newAssociations = appState.associations[associationType].slice(0);
action.value.forEach((vl) => {
if (newAssociations.includes(vl)) {
newAssociations = newAssociations.filter(s => s !== vl)
newAssociations = newAssociations.filter((s) => s !== vl);
} else {
newAssociations.push(vl)
newAssociations.push(vl);
}
})
});
return {
...appState,
associations: {
...appState.associations,
[associationType]: newAssociations
}
}
[associationType]: newAssociations,
},
};
}
function clearFilter (appState, action) {
function clearFilter(appState, action) {
return {
...appState,
filters: {
...appState.filters,
[action.filter]: []
}
}
[action.filter]: [],
},
};
}
function updateTimeRange (appState, action) { // XXX
function updateTimeRange(appState, action) {
// XXX
return {
...appState,
timeline: {
...appState.timeline,
range: action.timerange
}
}
range: action.timerange,
},
};
}
function updateDimensions (appState, action) {
function updateDimensions(appState, action) {
return {
...appState,
timeline: {
...appState.timeline,
dimensions: {
...appState.timeline.dimensions,
...action.dims
}
}
}
...action.dims,
},
},
};
}
function toggleLanguage (appState, action) {
let otherLanguage = (appState.language === 'es-MX') ? 'en-US' : 'es-MX'
function toggleLanguage(appState, action) {
const otherLanguage = appState.language === "es-MX" ? "en-US" : "es-MX";
return Object.assign({}, appState, {
language: action.language || otherLanguage
})
language: action.language || otherLanguage,
});
}
function updateSource (appState, action) {
function updateSource(appState, action) {
return {
...appState,
source: action.source
}
source: action.source,
};
}
function fetchError (state, action) {
function fetchError(state, action) {
return {
...state,
error: action.message,
notifications: [{ type: 'error', message: action.message }]
}
notifications: [{ type: "error", message: action.message }],
};
}
const toggleSites = toggleFlagAC('isShowingSites')
const toggleFetchingDomain = toggleFlagAC('isFetchingDomain')
const toggleFetchingSources = toggleFlagAC('isFetchingSources')
const toggleInfoPopup = toggleFlagAC('isInfopopup')
const toggleIntroPopup = toggleFlagAC('isIntropopup')
const toggleNotifications = toggleFlagAC('isNotification')
const toggleCover = toggleFlagAC('isCover')
const toggleSites = toggleFlagAC("isShowingSites");
const toggleFetchingDomain = toggleFlagAC("isFetchingDomain");
const toggleFetchingSources = toggleFlagAC("isFetchingSources");
const toggleInfoPopup = toggleFlagAC("isInfopopup");
const toggleIntroPopup = toggleFlagAC("isIntropopup");
const toggleNotifications = toggleFlagAC("isNotification");
const toggleCover = toggleFlagAC("isCover");
function fetchSourceError (appState, action) {
function fetchSourceError(appState, action) {
return {
...appState,
errors: {
...appState.errors,
source: action.msg
}
}
source: action.msg,
},
};
}
function setLoading (appState) {
function setLoading(appState) {
return {
...appState,
loading: true
}
loading: true,
};
}
function setNotLoading (appState) {
function setNotLoading(appState) {
return {
...appState,
loading: false
}
loading: false,
};
}
function setInitialCategories (appState, action) {
function setInitialCategories(appState, action) {
const categories = action.values.reduce((acc, val) => {
if (val.mode === ASSOCIATION_MODES.CATEGORY) acc.push(val.id)
return acc
}, [])
if (val.mode === ASSOCIATION_MODES.CATEGORY) acc.push(val.id);
return acc;
}, []);
return {
...appState,
associations: {
...appState.associations,
categories: categories
}
}
categories: categories,
},
};
}
function updateSearchQuery (appState, action) {
function updateSearchQuery(appState, action) {
return {
...appState,
searchQuery: action.searchQuery
}
searchQuery: action.searchQuery,
};
}
function app (appState = initial.app, action) {
function app(appState = initial.app, action) {
switch (action.type) {
case UPDATE_HIGHLIGHTED:
return updateHighlighted(appState, action)
return updateHighlighted(appState, action);
case UPDATE_SELECTED:
return updateSelected(appState, action)
return updateSelected(appState, action);
case UPDATE_COLORING_SET:
return updateColoringSet(appState, action)
return updateColoringSet(appState, action);
case CLEAR_FILTER:
return clearFilter(appState, action)
return clearFilter(appState, action);
case TOGGLE_ASSOCIATIONS:
return toggleAssociations(appState, action)
return toggleAssociations(appState, action);
case UPDATE_TIMERANGE:
return updateTimeRange(appState, action)
return updateTimeRange(appState, action);
case UPDATE_DIMENSIONS:
return updateDimensions(appState, action)
return updateDimensions(appState, action);
case UPDATE_NARRATIVE:
return updateNarrative(appState, action)
return updateNarrative(appState, action);
case UPDATE_NARRATIVE_STEP_IDX:
return updateNarrativeStepIdx(appState, action)
return updateNarrativeStepIdx(appState, action);
case UPDATE_SOURCE:
return updateSource(appState, action)
return updateSource(appState, action);
/* toggles */
case TOGGLE_LANGUAGE:
return toggleLanguage(appState, action)
return toggleLanguage(appState, action);
case TOGGLE_SITES:
return toggleSites(appState)
return toggleSites(appState);
case TOGGLE_FETCHING_DOMAIN:
return toggleFetchingDomain(appState)
return toggleFetchingDomain(appState);
case TOGGLE_FETCHING_SOURCES:
return toggleFetchingSources(appState)
return toggleFetchingSources(appState);
case TOGGLE_INFOPOPUP:
return toggleInfoPopup(appState)
return toggleInfoPopup(appState);
case TOGGLE_INTROPOPUP:
return toggleIntroPopup(appState)
return toggleIntroPopup(appState);
case TOGGLE_NOTIFICATIONS:
return toggleNotifications(appState)
return toggleNotifications(appState);
case TOGGLE_COVER:
return toggleCover(appState)
return toggleCover(appState);
/* errors */
case FETCH_ERROR:
return fetchError(appState, action)
return fetchError(appState, action);
case FETCH_SOURCE_ERROR:
return fetchSourceError(appState, action)
return fetchSourceError(appState, action);
case SET_LOADING:
return setLoading(appState)
return setLoading(appState);
case SET_NOT_LOADING:
return setNotLoading(appState)
return setNotLoading(appState);
case SET_INITIAL_CATEGORIES:
return setInitialCategories(appState, action)
return setInitialCategories(appState, action);
case UPDATE_SEARCH_QUERY:
return updateSearchQuery(appState, action)
return updateSearchQuery(appState, action);
default:
return appState
return appState;
}
}
export default app
export default app;

View File

@@ -1,31 +1,34 @@
import initial from '../store/initial.js'
import initial from "../store/initial.js";
import { UPDATE_DOMAIN, MARK_NOTIFICATIONS_READ } from '../actions'
import { validateDomain } from './validate/validators.js'
import { UPDATE_DOMAIN, MARK_NOTIFICATIONS_READ } from "../actions";
import { validateDomain } from "./validate/validators.js";
function updateDomain (domainState, action) {
function updateDomain(domainState, action) {
return {
...domainState,
...validateDomain(action.payload.domain, action.payload.features)
}
...validateDomain(action.payload.domain, action.payload.features),
};
}
function markNotificationsRead (domainState, action) {
function markNotificationsRead(domainState, action) {
return {
...domainState,
notifications: domainState.notifications.map(n => ({ ...n, isRead: true }))
}
notifications: domainState.notifications.map((n) => ({
...n,
isRead: true,
})),
};
}
function domain (domainState = initial.domain, action) {
function domain(domainState = initial.domain, action) {
switch (action.type) {
case UPDATE_DOMAIN:
return updateDomain(domainState, action)
return updateDomain(domainState, action);
case MARK_NOTIFICATIONS_READ:
return markNotificationsRead(domainState, action)
return markNotificationsRead(domainState, action);
default:
return domainState
return domainState;
}
}
export default domain
export default domain;

View File

@@ -1,7 +1,7 @@
import initial from '../store/initial.js'
import initial from "../store/initial.js";
function features (featureState = initial.features, action) {
return featureState
function features(featureState = initial.features, action) {
return featureState;
}
export default features
export default features;

View File

@@ -1,13 +1,13 @@
import { combineReducers } from 'redux'
import { combineReducers } from "redux";
import domain from './domain.js'
import app from './app.js'
import ui from './ui.js'
import features from './features.js'
import domain from "./domain.js";
import app from "./app.js";
import ui from "./ui.js";
import features from "./features.js";
export default combineReducers({
app,
domain,
ui,
features
})
features,
});

View File

@@ -1,9 +1,9 @@
import initial from '../store/initial.js'
import initial from "../store/initial.js";
import {} from '../actions'
import {} from "../actions";
function ui (uiState = initial.ui, action) {
return uiState
function ui(uiState = initial.ui, action) {
return uiState;
}
export default ui
export default ui;

View File

@@ -1,10 +1,10 @@
import Joi from 'joi'
import Joi from "joi";
const associationsSchema = Joi.object().keys({
id: Joi.string().allow('').required(),
desc: Joi.string().allow(''),
mode: Joi.string().allow('').required(),
filter_paths: Joi.array()
})
id: Joi.string().allow("").required(),
desc: Joi.string().allow(""),
mode: Joi.string().allow("").required(),
filter_paths: Joi.array(),
});
export default associationsSchema
export default associationsSchema;

Some files were not shown because too many files have changed in this diff Show More