22 |
25 |
26 | This platform is unsuitable for mobile. Please revisit on a desktop.
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/src/common/constants.js:
--------------------------------------------------------------------------------
1 | export const ASSOCIATION_MODES = {
2 | CATEGORY: "CATEGORY",
3 | NARRATIVE: "NARRATIVE",
4 | FILTER: "FILTER",
5 | };
6 |
7 | export const SHAPE = "SHAPE";
8 |
9 | export const DEFAULT_TAB_ICONS = {
10 | CATEGORY: "widgets",
11 | NARRATIVE: "timeline",
12 | FILTER: "filter_list",
13 | SHAPE: "change_history",
14 | };
15 |
16 | export const AVAILABLE_SHAPES = {
17 | STAR: "STAR",
18 | DIAMOND: "DIAMOND",
19 | PENTAGON: "PENTAGON",
20 | SQUARE: "SQUARE",
21 | DOT: "DOT",
22 | BAR: "BAR",
23 | TRIANGLE: "TRIANGLE",
24 | };
25 |
26 | export const POLYGON_CLIP_PATH = {
27 | STAR:
28 | "polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)",
29 | DIAMOND: "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)",
30 | PENTAGON: "polygon(50% 0%, 100% 38%, 82% 100%, 18% 100%, 0% 38%)",
31 | TRIANGLE: "polygon(50% 0%, 0% 100%, 100% 100%)",
32 | };
33 |
34 | export const DEFAULT_CHECKBOX_COLOR = "#ffffff";
35 |
--------------------------------------------------------------------------------
/src/components/atoms/Controls.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const OverlayControls = ({ viewIdx, paths, onShiftHandler }) => {
4 | const backArrow =
5 | viewIdx !== 0 ? (
6 | methods.onSelectNarrative(null)}
28 | closeMsg="-- exit from narrative --"
29 | />
30 | >
31 | );
32 | };
33 |
34 | export default NarrativeControls;
35 |
--------------------------------------------------------------------------------
/src/components/atoms/Checkbox.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { DEFAULT_CHECKBOX_COLOR } from "../../common/constants";
3 |
4 | const Checkbox = ({ label, isActive, onClickCheckbox, color, styleProps }) => {
5 | const checkboxColor = color ? color : DEFAULT_CHECKBOX_COLOR;
6 | const baseStyles = {
7 | checkboxStyles: {
8 | background: isActive ? checkboxColor : "none",
9 | border: `1px solid ${checkboxColor}`,
10 | },
11 | };
12 | const containerStyles = styleProps ? styleProps.containerStyles : {};
13 | const checkboxStyles = styleProps
14 | ? styleProps.checkboxStyles
15 | : baseStyles.checkboxStyles;
16 | return (
17 |
18 |
{label}
19 |
24 |
25 | );
26 | };
27 |
28 | export default Checkbox;
29 |
--------------------------------------------------------------------------------
/src/scss/tabs.scss:
--------------------------------------------------------------------------------
1 | .react-tabs {
2 | padding-top: 0;
3 | box-sizing: border-box;
4 | height: 100%;
5 |
6 | [role="tablist"] {
7 | padding: 0;
8 | }
9 |
10 | [role="tab"] {
11 | font-size: $xlarge;
12 | width: 33%;
13 | background: none;
14 | color: $midwhite;
15 | outline: none;
16 | float: left;
17 | cursor: pointer;
18 | text-align: center;
19 | height: 40px;
20 | line-height: 40px;
21 | border-bottom: 1px solid rgba(255, 255, 255, 0.4);
22 | list-style-type: none;
23 | box-sizing: border-box;
24 | &:hover {
25 | color: $offwhite;
26 | }
27 | }
28 |
29 | [role="tab"][aria-selected="true"] {
30 | font-weight: 700;
31 | border-radius: 0;
32 | border: 0;
33 | color: $offwhite;
34 | border: 1px solid;
35 | box-sizing: border-box;
36 | text-align: center;
37 | border: 1px solid rgba(255, 255, 255, 0.4);
38 | border-bottom: 0;
39 | }
40 |
41 | .react-innertabpanel {
42 | box-sizing: border-box;
43 | padding-top: 20px;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/components/time/atoms/DatetimeBar.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const DatetimeBar = ({
4 | highlights,
5 | events,
6 | x,
7 | y,
8 | width,
9 | height,
10 | onSelect,
11 | styleProps,
12 | extraRender,
13 | }) => {
14 | if (highlights.length === 0) {
15 | return (
16 |
25 | );
26 | }
27 | const sectionHeight = height / highlights.length;
28 | return (
29 | <>
30 | {highlights.map((h, idx) => (
31 |
40 | ))}
41 | >
42 | );
43 | };
44 |
45 | export default DatetimeBar;
46 |
--------------------------------------------------------------------------------
/src/components/controls/atoms/PanelTree.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Checkbox from "../../atoms/Checkbox";
3 | import { ASSOCIATION_MODES } from "../../../common/constants";
4 |
5 | const PanelTree = ({ data, activeValues, onSelect, type }) => {
6 | // If the parent panel is of type 'CATEGORY': filter on title. If panel is 'SHAPE': filter on id
7 | const onSelectionType = type === ASSOCIATION_MODES.CATEGORY ? "title" : "id";
8 | return (
9 |
10 | {data.map((val) => {
11 | return (
12 |
17 | onSelect(val[onSelectionType])}
21 | styleProps={val.styles}
22 | />
23 |
24 | );
25 | })}
26 |
27 | );
28 | };
29 |
30 | export default PanelTree;
31 |
--------------------------------------------------------------------------------
/src/components/time/atoms/Handles.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const TimelineHandles = ({ dims, onMoveTime }) => {
4 | const transform = "scale(1.5,1.5)";
5 | const size = 45;
6 | const handleOffset = dims.contentHeight / 2;
7 | return (
8 |
9 | onMoveTime("backwards")}
12 | >
13 |
14 |
18 |
19 | onMoveTime("forward")}
24 | >
25 |
26 |
30 |
31 |
32 | );
33 | };
34 |
35 | export default TimelineHandles;
36 |
--------------------------------------------------------------------------------
/src/components/controls/atoms/NarrativeCard.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { connect } from "react-redux";
3 | import { selectActiveNarrative } from "../../../selectors";
4 |
5 | function NarrativeCard({ narrative }) {
6 | // no display if no narrative
7 | const { steps, current } = narrative;
8 |
9 | if (steps[current]) {
10 | return (
11 |
12 |
13 |
14 |
15 | {current + 1}/{steps.length}
16 |
17 |
18 |
19 |
{narrative.label}
20 |
21 |
22 |
23 | {/*
location_on */}
24 | {/* {_renderActions(current, steps)} */}
25 |
26 |
{narrative.description}
27 |
28 |
29 | );
30 | } else {
31 | return null;
32 | }
33 | }
34 |
35 | function mapStateToProps(state) {
36 | return {
37 | narrative: selectActiveNarrative(state),
38 | };
39 | }
40 | export default connect(mapStateToProps)(NarrativeCard);
41 |
--------------------------------------------------------------------------------
/src/components/atoms/Md.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 | import marked from "marked";
4 |
5 | class Md extends React.Component {
6 | constructor(props) {
7 | super(props);
8 | this.state = { md: null, error: null };
9 | }
10 |
11 | componentDidMount() {
12 | fetch(this.props.path)
13 | .then((resp) => resp.text())
14 | .then((text) => {
15 | if (text.length <= 0) {
16 | throw new Error();
17 | }
18 |
19 | this.setState({ md: marked(text) });
20 | })
21 | .catch(() => {
22 | this.setState({ error: true });
23 | });
24 | }
25 |
26 | render() {
27 | if (this.state.md && !this.state.error) {
28 | return (
29 |
33 | );
34 | } else if (this.state.error) {
35 | return this.props.unloader || Error: couldn't load source
;
36 | } else {
37 | return this.props.loader;
38 | }
39 | }
40 | }
41 |
42 | Md.propTypes = {
43 | loader: PropTypes.func,
44 | unloader: PropTypes.func.isRequired,
45 | path: PropTypes.string.isRequired,
46 | };
47 |
48 | export default Md;
49 |
--------------------------------------------------------------------------------
/src/components/atoms/CoeventIcon.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const CoeventIcon = ({ isEnabled, toggleMapViews }) => {
4 | return (
5 |
40 | );
41 | };
42 |
43 | export default CoeventIcon;
44 |
--------------------------------------------------------------------------------
/src/components/space/carto/atoms/Regions.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Portal } from "react-portal";
3 |
4 | function MapRegions({ svg, regions, projectPoint, styles }) {
5 | function renderRegion(region) {
6 | const lineCoords = [];
7 | const points = region.points.map(projectPoint);
8 |
9 | points.forEach((p1, idx) => {
10 | if (idx < region.points.length - 1) {
11 | const p2 = points[idx + 1];
12 | lineCoords.push({
13 | x1: p1.x,
14 | y1: p1.y,
15 | x2: p2.x,
16 | y2: p2.y,
17 | });
18 | }
19 | });
20 |
21 | return lineCoords.map((coords) => {
22 | const regionstyles =
23 | region.name in styles ? styles[region.name] : styles.default;
24 |
25 | return (
26 |
32 | );
33 | });
34 | }
35 |
36 | if (!regions || !regions.length) return null;
37 |
38 | return (
39 |
40 |
41 | {regions.map(renderRegion)}
42 |
43 |
44 | );
45 | }
46 |
47 | export default MapRegions;
48 |
--------------------------------------------------------------------------------
/src/components/space/carto/atoms/SatelliteOverlayToggle.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import copy from "../../../../common/data/copy.json";
3 | import { language } from "../../../../common/utilities";
4 | import mapImg from "../../../../assets/satelliteoverlaytoggle/map.png";
5 | import satImg from "../../../../assets/satelliteoverlaytoggle/sat.png";
6 |
7 | const SatelliteOverlayToggle = ({
8 | switchToSatellite,
9 | reset,
10 | isUsingSatellite,
11 | }) => {
12 | return (
13 |
14 | {isUsingSatellite ? (
15 |
22 | ) : (
23 |
30 | )}
31 |
32 | );
33 | };
34 |
35 | export default SatelliteOverlayToggle;
36 |
--------------------------------------------------------------------------------
/src/scss/header.scss:
--------------------------------------------------------------------------------
1 | .header {
2 | background: #000000;
3 | position: fixed;
4 | padding: 10px;
5 | z-index: 10;
6 | top: 10px;
7 | right: 10px;
8 | height: 40px;
9 | width: 240px;
10 | box-sizing: border-box;
11 | text-overflow: ellipsis;
12 | box-shadow: 0 19px 38px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.22);
13 | cursor: pointer;
14 |
15 | .header-title {
16 | a {
17 | color: darken($offwhite, 5%);
18 | font-size: $xlarge;
19 | letter-spacing: 0.1em;
20 | float: left;
21 | text-transform: uppercase;
22 | }
23 |
24 | p {
25 | margin: 0;
26 | }
27 | }
28 |
29 | .side-menu-burg {
30 | right: 10px;
31 | span,
32 | span::before,
33 | span::after {
34 | background: $midwhite;
35 | }
36 | }
37 |
38 | &:hover {
39 | .side-menu-burg {
40 | span {
41 | transition: 0.2s ease;
42 | background: $offwhite;
43 | }
44 | span::before {
45 | transition: 0.2s ease;
46 | top: -6px;
47 | background: $offwhite;
48 | }
49 |
50 | span::after {
51 | transition: 0.2s ease;
52 | bottom: -6px;
53 | background: $offwhite;
54 | }
55 | }
56 | .header-title a {
57 | transition: 0.2s ease;
58 | color: $offwhite;
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/components/controls/atoms/Text.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 |
3 | const CardText = ({ title, value, hoverValue = null }) => {
4 | const [showHover, setShowHover] = useState(false);
5 |
6 | return (
7 |
8 | {title ?
{title}
: null}
9 |
14 |
hoverValue && setShowHover(true)}
16 | onMouseOut={() => hoverValue && setShowHover(false)}
17 | >
18 | {showHover ? (
19 |
25 | {hoverValue}
26 |
27 | ) : (
28 |
36 | {value}
37 |
38 | )}
39 |
40 | {/* {!showHover && value} */}
41 |
42 |
43 | );
44 | };
45 |
46 | export default CardText;
47 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Environment
7 | -----------
8 |
9 | * Your version (in package.json) or git commit hash
10 | * Your operating system and version:
11 |
12 |
13 |
14 | Steps to reproduce (for bugs only)
15 | -----------------------------
16 |
17 |
18 |
19 | 1.
20 | 2.
21 | 3.
22 |
23 | Current Behavior
24 | ----------------
25 |
26 |
27 |
28 |
29 | Expected Behavior
30 | -----------------
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/config/jest/fileTransform.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const path = require("path");
4 | const camelcase = require("camelcase");
5 |
6 | // This is a custom Jest transformer turning file imports into filenames.
7 | // http://facebook.github.io/jest/docs/en/webpack.html
8 |
9 | module.exports = {
10 | process(src, filename) {
11 | const assetFilename = JSON.stringify(path.basename(filename));
12 |
13 | if (filename.match(/\.svg$/)) {
14 | // Based on how SVGR generates a component name:
15 | // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6
16 | const pascalCaseFilename = camelcase(path.parse(filename).name, {
17 | pascalCase: true,
18 | });
19 | const componentName = `Svg${pascalCaseFilename}`;
20 | return `const React = require('react');
21 | module.exports = {
22 | __esModule: true,
23 | default: ${assetFilename},
24 | ReactComponent: React.forwardRef(function ${componentName}(props, ref) {
25 | return {
26 | $$typeof: Symbol.for('react.element'),
27 | type: 'svg',
28 | ref: ref,
29 | key: null,
30 | props: Object.assign({}, props, {
31 | children: ${assetFilename}
32 | })
33 | };
34 | }),
35 | };`;
36 | }
37 |
38 | return `module.exports = ${assetFilename};`;
39 | },
40 | };
41 |
--------------------------------------------------------------------------------
/src/components/space/carto/atoms/SelectedEvents.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Portal } from "react-portal";
3 | import colors from "../../../../common/global";
4 | import hash from "object-hash";
5 |
6 | class MapSelectedEvents extends React.Component {
7 | renderMarker(marker) {
8 | const { x, y } = this.props.projectPoint([
9 | marker.latitude,
10 | marker.longitude,
11 | ]);
12 | const styles = this.props.styles;
13 | const r = marker.radius ? marker.radius + 5 : 24;
14 | return (
15 |
20 |
31 |
32 | );
33 | }
34 |
35 | render() {
36 | return (
37 |
38 | {this.props.selected.map((s) => this.renderMarker(s))}
39 |
40 | );
41 | }
42 | }
43 | export default MapSelectedEvents;
44 |
--------------------------------------------------------------------------------
/src/components/controls/BottomActions.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import SitesIcon from "../atoms/SitesIcon";
4 | import CoverIcon from "../atoms/CoverIcon";
5 | // import InfoIcon from "../atoms/InfoIcon";
6 |
7 | function BottomActions(props) {
8 | function renderToggles() {
9 | return (
10 | <>
11 |
12 | {props.features.USE_SITES ? (
13 |
17 | ) : null}
18 |
19 | {/* ,
20 |
21 |
25 |
26 | , */}
27 |
28 | {props.features.USE_COVER ? (
29 |
30 | ) : null}
31 |
32 |
33 | >
34 | );
35 | }
36 |
37 | return {renderToggles()}
;
38 | }
39 |
40 | export default BottomActions;
41 |
--------------------------------------------------------------------------------
/src/components/time/atoms/ZoomControls.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const DEFAULT_ZOOM_LEVELS = [
4 | { label: "20 years", duration: 10512000 },
5 | { label: "2 years", duration: 1051200 },
6 | { label: "3 months", duration: 129600 },
7 | { label: "3 days", duration: 4320 },
8 | { label: "12 hours", duration: 720 },
9 | { label: "1 hour", duration: 60 },
10 | ];
11 |
12 | function zoomIsActive(duration, extent, max) {
13 | if (duration >= max && extent >= max) {
14 | return true;
15 | }
16 | return duration === extent;
17 | }
18 |
19 | const TimelineZoomControls = ({ extent, zoomLevels, dims, onApplyZoom }) => {
20 | function renderZoom(zoom, idx) {
21 | const max = zoomLevels.reduce((acc, vl) =>
22 | acc.duration < vl.duration ? vl : acc
23 | );
24 | const isActive = zoomIsActive(zoom.duration, extent, max.duration);
25 | return (
26 | onApplyZoom(zoom)}
31 | key={idx}
32 | >
33 | {zoom.label}
34 |
35 | );
36 | }
37 |
38 | if (zoomLevels.length === 0) {
39 | zoomLevels = DEFAULT_ZOOM_LEVELS;
40 | }
41 | return (
42 |
43 | {zoomLevels.map((z, idx) => renderZoom(z, idx))}
44 |
45 | );
46 | };
47 |
48 | export default TimelineZoomControls;
49 |
--------------------------------------------------------------------------------
/src/components/controls/FullScreenToggle.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import screenfull from "screenfull";
3 | import { ToolbarButton } from "./atoms/ToolbarButton";
4 | import copy from "../../common/data/copy.json";
5 |
6 | export class FullscreenToggle extends React.Component {
7 | constructor(props) {
8 | super(props);
9 |
10 | this.onFullscreenStateChange = this.onFullscreenStateChange.bind(this);
11 |
12 | this.state = {
13 | isFullscreen: screenfull.isFullscreen,
14 | };
15 | }
16 |
17 | componentDidMount() {
18 | screenfull.on("change", this.onFullscreenStateChange);
19 | }
20 |
21 | componentWillUnmount() {
22 | screenfull.off("change", this.onFullscreenStateChange);
23 | }
24 |
25 | onFullscreenStateChange(evt) {
26 | this.setState({ isFullscreen: screenfull.isFullscreen });
27 | }
28 |
29 | onToggleFullscreen() {
30 | screenfull.toggle().catch(console.warn);
31 | }
32 |
33 | render() {
34 | if (!screenfull.isEnabled) return null;
35 |
36 | const { language } = this.props;
37 | const { isFullscreen } = this.state;
38 |
39 | return (
40 |
50 | );
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/index.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import { Provider } from "react-redux";
4 | import store from "./store";
5 | import App from "./components/App";
6 |
7 | ReactDOM.render(
8 |
9 |
10 | ,
11 | document.getElementById("explore-app")
12 | );
13 |
14 | // Expressions from https://exceptionshub.com/how-to-detect-safari-chrome-ie-firefox-and-opera-browser.html
15 |
16 | /* eslint-disable */
17 | // Opera 8.0+
18 | const isOpera =
19 | (!!window.opr && !!opr.addons) ||
20 | !!window.opera ||
21 | navigator.userAgent.indexOf(" OPR/") >= 0;
22 | // Firefox 1.0+
23 | const isFirefox = typeof InstallTrigger !== "undefined";
24 | // Safari 3.0+ "[object HTMLElementConstructor]"
25 | const isSafari =
26 | /constructor/i.test(window.HTMLElement) ||
27 | (function (p) {
28 | return p.toString() === "[object SafariRemoteNotification]";
29 | })(
30 | !window["safari"] ||
31 | (typeof safari !== "undefined" && safari.pushNotification)
32 | );
33 | // Internet Explorer 6-11
34 | const isIE = /* @cc_on!@ */ false || !!document.documentMode;
35 | // Edge 20+
36 | const isEdge = !isIE && !!window.StyleMedia;
37 | // Chrome 1+
38 | const isChrome = !!window.chrome && !!window.chrome.webstore;
39 | // Blink engine detection
40 | const isBlink = (isChrome || isOpera) && !!window.CSS;
41 |
42 | if (isEdge || isIE) {
43 | alert(
44 | "Please view this website in Opera for best viewing. It is untested in your browser."
45 | );
46 | }
47 | /* eslint-enable */
48 |
--------------------------------------------------------------------------------
/scripts/test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | // Do this as the first thing so that any code reading it knows the right env.
4 | process.env.BABEL_ENV = 'test';
5 | process.env.NODE_ENV = 'test';
6 | process.env.PUBLIC_URL = '';
7 |
8 | // Makes the script crash on unhandled rejections instead of silently
9 | // ignoring them. In the future, promise rejections that are not handled will
10 | // terminate the Node.js process with a non-zero exit code.
11 | process.on('unhandledRejection', err => {
12 | throw err;
13 | });
14 |
15 | // Ensure environment variables are read.
16 | require('../config/env');
17 |
18 |
19 | const jest = require('jest');
20 | const execSync = require('child_process').execSync;
21 | let argv = process.argv.slice(2);
22 |
23 | function isInGitRepository() {
24 | try {
25 | execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' });
26 | return true;
27 | } catch (e) {
28 | return false;
29 | }
30 | }
31 |
32 | function isInMercurialRepository() {
33 | try {
34 | execSync('hg --cwd . root', { stdio: 'ignore' });
35 | return true;
36 | } catch (e) {
37 | return false;
38 | }
39 | }
40 |
41 | // Watch unless on CI or explicitly running all tests
42 | if (
43 | !process.env.CI &&
44 | argv.indexOf('--watchAll') === -1 &&
45 | argv.indexOf('--watchAll=false') === -1
46 | ) {
47 | // https://github.com/facebook/create-react-app/issues/5210
48 | const hasSourceControl = isInGitRepository() || isInMercurialRepository();
49 | argv.push(hasSourceControl ? '--watch' : '--watchAll');
50 | }
51 |
52 |
53 | jest.run(argv);
54 |
--------------------------------------------------------------------------------
/docs/mapbox.md:
--------------------------------------------------------------------------------
1 |
2 | # Timemap and custom Mapbox maps
3 |
4 | You can use satellite images and maps from [https://www.mapbox.com/](https://www.mapbox.com/) to customise the map in Timemap.
5 |
6 | Sign up for an account at [https://www.mapbox.com/](https://www.mapbox.com/) and use Mapbox's studio to create a custom map. To use the map you need to configure Timemap with the following:
7 |
8 | ## Token
9 |
10 | To access your Mapbox account Timemap needs an access token that you create under your Mapbox account.
11 |
12 | Sign in to Mapbox and then navigate to:
13 |
14 | Account > Access Tokens
15 |
16 | * Create a Token - add a token and optionally restrict it to the url of your timemap instance.
17 | * Add it to Timemap - copy the token and then add it to Timemap's `config.js` at the top level:
18 |
19 | ```
20 | MAPBOX_TOKEN: 'mapbox_token',
21 | ```
22 |
23 | Timemap can now access your account but you need to associate any maps you want to use with that account using Mapbox Studio. Once you have done that you can then reference the Mapbox Map id.
24 |
25 | ## Mapbox Map Id
26 |
27 | To reference a map you have created in Mapbox Studio you need the map's id which is available under 'share' >
28 |
29 | * Style URL (which looks like this: `mapbox://styles/your-mapbox-account-name/x5678-map-id`)
30 |
31 | Now go back to `config.js` and under the UI settings add (don't copy the 'mapbox://styles/' bit):
32 |
33 | ```
34 | ui: {
35 | tiles: 'your-mapbox-account-name/x5678-map-id',
36 | ```
37 |
38 | If you restart your time map instance you should now see the Mapbox map.
39 |
--------------------------------------------------------------------------------
/test/server_process.js:
--------------------------------------------------------------------------------
1 | import test from 'ava'
2 | const childProcess = require('child_process')
3 | const http = require('http')
4 |
5 | const SERVER_LAUNCH_WAIT_TIME = 10 * 1000
6 |
7 | let serverProc = null
8 | let serverExited = false
9 |
10 | test.before.cb(t => {
11 | console.log('launching server...')
12 | serverProc = childProcess.spawn('yarn', ['dev'], {
13 | cwd: '.',
14 | stdio: 'ignore'
15 | })
16 |
17 | serverProc.on('exit', function (code, signal) {
18 | serverExited = true
19 | })
20 |
21 | setTimeout(t.end, SERVER_LAUNCH_WAIT_TIME)
22 | })
23 |
24 | test.after(function () {
25 | console.log('killing server...')
26 | serverProc.kill('SIGKILL')
27 | })
28 |
29 | test('should launch', t => {
30 | t.false(serverExited)
31 | })
32 |
33 | const urls = [
34 | '/',
35 | 'js/index.bundle.js'
36 | ]
37 |
38 | urls.forEach(function (url) {
39 | test.cb('should respond to request for "' + url + '"', t => {
40 | http.get({
41 | hostname: 'localhost',
42 | port: 8080,
43 | path: '/',
44 | agent: false
45 | }, function (res) {
46 | let resultData = ''
47 |
48 | if (res.statusCode !== 200) {
49 | t.fail('Server response was not 200.')
50 | } else {
51 | res.on('data', function (data) { resultData += data })
52 |
53 | res.on('end', function () {
54 | if (resultData.length > 0) {
55 | t.pass()
56 | } else {
57 | t.fail('Server returned no data.')
58 | }
59 | })
60 | }
61 |
62 | t.end()
63 | })
64 | })
65 | })
66 |
--------------------------------------------------------------------------------
/src/scss/popup.scss:
--------------------------------------------------------------------------------
1 | .popup {
2 | box-sizing: border-box;
3 | margin: 0;
4 | padding: 15px;
5 | border: 0;
6 | opacity: 0;
7 | border-radius: 2px;
8 | transition: 0.2 ease;
9 | background: rgba(0, 0, 0, 0.9);
10 | transition: 0.4s ease;
11 | box-shadow: 0 19px 38px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.22);
12 |
13 | &:hover {
14 | transition: 0.4s ease;
15 | box-shadow: 0 29px 38px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.22);
16 | }
17 |
18 | .card-tophalf {
19 | height: 100px;
20 |
21 | .left {
22 | float: left;
23 | width: 120px;
24 | padding-right: 5px;
25 | box-sizing: border-box;
26 | border-right: 1px dotted $midwhite;
27 | }
28 | .right {
29 | float: left;
30 | width: 225px;
31 | padding-left: 5px;
32 | height: 90px;
33 | overflow: hidden;
34 | }
35 | }
36 |
37 | .filter,
38 | p.see-more {
39 | cursor: pointer;
40 |
41 | &:hover {
42 | color: $yellow;
43 | }
44 | }
45 |
46 | p {
47 | margin: 5px 0 0 0;
48 | }
49 |
50 | .timestamp {
51 | text-transform: uppercase;
52 | font-size: $xlarge;
53 | margin-top: 0;
54 | }
55 |
56 | .location {
57 | font-size: $normal;
58 | color: $offwhite;
59 | }
60 |
61 | .estimated-timestamp {
62 | margin-top: 3px;
63 | margin-left: 3px;
64 | font-size: $xsmall;
65 | color: $midwhite;
66 | text-transform: lowercase;
67 | }
68 |
69 | .summary {
70 | max-height: 200px;
71 | text-overflow: ellipsis;
72 | overflow: scroll;
73 | font-weight: 500;
74 | }
75 |
76 | .source {
77 | text-align: right;
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/components/atoms/ColoredMarkers.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { getCoordinatesForPercent } from "../../common/utilities";
3 |
4 | function ColoredMarkers({ radius, colorPercentMap, styles, className }) {
5 | let cumulativeAngleSweep = 0;
6 | const colors = Object.keys(colorPercentMap);
7 |
8 | return (
9 | <>
10 | {colors.map((color, idx) => {
11 | const colorPercent = colorPercentMap[color];
12 |
13 | const [startX, startY] = getCoordinatesForPercent(
14 | radius,
15 | cumulativeAngleSweep
16 | );
17 |
18 | cumulativeAngleSweep += colorPercent;
19 |
20 | const [endX, endY] = getCoordinatesForPercent(
21 | radius,
22 | cumulativeAngleSweep
23 | );
24 | // if the slices are less than 2, take the long arc
25 | const largeArcFlag = colors.length === 1 || colorPercent > 0.5 ? 1 : 0;
26 |
27 | // create an array and join it just for code readability
28 | const arc = [
29 | `M ${startX} ${startY}`, // Move
30 | `A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endX} ${endY}`, // Arc
31 | "L 0 0 ", // Line
32 | `L ${startX} ${startY} Z`, // Line
33 | ].join(" ");
34 |
35 | const extraStyles = {
36 | ...styles,
37 | fill: color,
38 | };
39 |
40 | return (
41 |
48 | );
49 | })}
50 | >
51 | );
52 | }
53 |
54 | export default ColoredMarkers;
55 |
--------------------------------------------------------------------------------
/src/reducers/validate/eventSchema.js:
--------------------------------------------------------------------------------
1 | import Joi from "joi";
2 |
3 | function joiFromCustom(custom) {
4 | const output = {};
5 | custom.forEach((field) => {
6 | if (field.kind === "text" || field.kind === "link") {
7 | output[field.key] = Joi.string().allow("");
8 | }
9 | if (field.kind === "list") {
10 | output[field.key] = Joi.array().allow("");
11 | }
12 | });
13 | return output;
14 | }
15 |
16 | function createEventSchema(custom) {
17 | return Joi.object()
18 | .keys({
19 | id: Joi.string().allow(""),
20 | civId: Joi.string().allow(""),
21 | description: Joi.string().allow("").required(),
22 | date: Joi.string().allow(""),
23 | time: Joi.string().allow(""),
24 | time_precision: Joi.string().allow(""),
25 |
26 | /* map */
27 | location: Joi.string().allow(""),
28 | latitude: Joi.string().allow(""),
29 | longitude: Joi.string().allow(""),
30 | /* space */
31 | x: Joi.string().allow(""),
32 | y: Joi.string().allow(""),
33 | z: Joi.string().allow(""),
34 |
35 | type: Joi.string().allow(""),
36 | category: Joi.string().allow(""),
37 | category_full: Joi.string().allow(""),
38 | associations: Joi.array().default([]),
39 | sources: Joi.array(),
40 | comments: Joi.string().allow(""),
41 | time_display: Joi.string().allow(""),
42 | // nested
43 | narrative___stepStyles: Joi.array(),
44 | shape: Joi.string().allow(""),
45 | colour: Joi.string().allow(""),
46 | ...joiFromCustom(custom),
47 | })
48 | .and("latitude", "longitude")
49 | .or("date", "latitude");
50 | }
51 |
52 | export default createEventSchema;
53 |
--------------------------------------------------------------------------------
/src/scss/notification.scss:
--------------------------------------------------------------------------------
1 | @import "burger";
2 |
3 | .notification-wrapper {
4 | top: 60px;
5 | right: 60px;
6 | width: 400px;
7 | height: auto;
8 | position: absolute;
9 | display: flex;
10 | flex-direction: column;
11 | }
12 |
13 | .notification {
14 | width: 100%;
15 | min-height: 40px;
16 | box-shadow: 10px -10px 38px rgba(0, 0, 0, 0.3),
17 | 10px 15px 12px rgba(0, 0, 0, 0.22);
18 | color: $darkgrey;
19 | background: $offwhite;
20 | border-radius: 5px;
21 | border: 3px solid $offwhite;
22 | padding: 20px;
23 | margin-bottom: 10px;
24 | box-sizing: border-box;
25 | font-size: $large;
26 | transition: opacity 0.5s ease 0.1s, z-index 0.1s ease 0s;
27 | opacity: 1;
28 | z-index: $overheader;
29 | cursor: pointer;
30 |
31 | &:hover {
32 | background: lighten($offwhite, 5%);
33 | transition: background-color 0.4s;
34 | }
35 |
36 | &.hidden {
37 | transition: 0.5s ease;
38 | opacity: 0;
39 | }
40 |
41 | .side-menu-burg {
42 | position: absolute;
43 | right: 8px;
44 | top: 10px;
45 | }
46 |
47 | .message {
48 | display: inline-block;
49 |
50 | &.error {
51 | color: red;
52 | }
53 | &.warning {
54 | color: orange;
55 | }
56 | &.good {
57 | color: green;
58 | }
59 | &.neutral {
60 | color: $darkgrey;
61 | }
62 | }
63 |
64 | .details {
65 | overflow: hidden;
66 | display: flex;
67 | flex-direction: column;
68 | border-radius: 3px;
69 | margin-top: 10px;
70 | padding: 10px;
71 | background: $darkgrey;
72 | color: $offwhite;
73 | font-family: monospace;
74 |
75 | &.true {
76 | height: auto;
77 | transition: height 0.4s, margin 0.4s;
78 | }
79 |
80 | &.false {
81 | height: 0;
82 | padding: 0;
83 | margin: 0;
84 | transition: height 0.4s, margin 0.4s;
85 | }
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/components/space/carto/atoms/__tests__/SatelliteOverlayToggle.spec.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { fireEvent, render, screen } from "@testing-library/react";
3 | import SatelliteOverlayToggle from "../SatelliteOverlayToggle";
4 | import "@testing-library/jest-dom";
5 |
6 | describe("", () => {
7 | it("shows the option to switch to satellite by default", () => {
8 | render(
9 |
10 | );
11 | expect(screen.getByRole("button", { name: /sat/i })).toBeTruthy();
12 | });
13 |
14 | it("shows the option to switch to map when satellite selected", () => {
15 | render(
16 |
21 | );
22 | expect(screen.getByRole("button", { name: /map/i })).toBeTruthy();
23 | });
24 |
25 | it("calls the reset function when switching to the default overlay", () => {
26 | const mockReset = jest.fn();
27 | const mockSat = jest.fn();
28 | render(
29 |
34 | );
35 | const btn = screen.getByRole("button", { name: /map/i });
36 | fireEvent.click(btn);
37 | expect(mockReset).toHaveBeenCalledTimes(1);
38 | expect(mockSat).not.toHaveBeenCalled();
39 | });
40 |
41 | it("calls the switchToSatellite function when switching to the satellite overlay", () => {
42 | const mockReset = jest.fn();
43 | const mockSat = jest.fn();
44 | render(
45 |
46 | );
47 | const btn = screen.getByRole("button", { name: /sat/i });
48 | fireEvent.click(btn);
49 | expect(mockSat).toHaveBeenCalledTimes(1);
50 | expect(mockReset).not.toHaveBeenCalled();
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/src/scss/_variables.scss:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: "GT-Zirkon";
3 | src: url(../assets/fonts/timemapfont.woff); // a Lato woff by default
4 | }
5 |
6 | $event_default: red;
7 |
8 | $offwhite: #efefef;
9 | $offwhite-transparent: rgba(239, 239, 239, 0.9);
10 | $lightwhite: #dfdfdf;
11 | $midwhite: #a0a0a0;
12 | $darkwhite: darken($midwhite, 15%);
13 | $yellow: #eb443e; // #ffd800;
14 | $red: rgb(233, 0, 19);
15 | $green: rgb(61, 241, 79);
16 | $midgrey: rgb(44, 44, 44);
17 | $darkgrey: #232323;
18 | $black: #000000;
19 | $black-transparent: rgba(0, 0, 0, 0.7);
20 |
21 | // Category colors
22 | $default: red;
23 | $alpha: #00ff00;
24 | $beta: #ff00ff;
25 | $other: yellow;
26 |
27 | .default {
28 | background: $default;
29 | }
30 | .other {
31 | background: $other;
32 | }
33 | .alpha {
34 | background: $alpha;
35 | }
36 | .beta {
37 | background: $beta;
38 | }
39 |
40 | $mainfont: "GT-Zirkon", "Lato", Helvetica, sans-serif;
41 |
42 | // Font sizes
43 | $xsmall: 10px; //0.7em;
44 | $small: 11px; //0.9em;
45 | $normal: 12px; //1em;
46 | $large: 14px; //1.1em;
47 | $xlarge: 16px; //1.2em;
48 | $xxlarge: 20px; //1.4em;
49 | $xxxlarge: 32px;
50 |
51 | // z-index levels
52 | $final-level: 10000;
53 | $loading-overlay: 500;
54 | $overheader: 100;
55 | $header: 10;
56 | $map-overlay: 2;
57 | $map: 1;
58 | $scene: 1;
59 | $hidden: -1;
60 | $timeline: 13;
61 |
62 | // platform-specific sizes
63 | $infopopup-width: 400px;
64 | $infopopup-left: 122px;
65 | $infopopup-bottom: 180px;
66 | $card-width: 500px;
67 | $card-right: 2px;
68 | $narrative-info-height: 205px;
69 | $narrative-info-desc-height: 153px;
70 | $timeline-height: 250px;
71 | $toolbar-width: 110px;
72 |
73 | $panel-width: 1000px;
74 | $panel-height: 1000px;
75 | $vimeo-width: $panel-width - 100;
76 | $vimeo-height: $panel-height / 2;
77 | $banner-height: 100px;
78 | $padding: 20px;
79 | $header-inset: 10px;
80 |
81 | // CSS variables (for React access)
82 | :root {
83 | --toolbar-width: 110px;
84 | --error-red: #eb443e;
85 | }
86 |
--------------------------------------------------------------------------------
/src/components/controls/atoms/Button.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 |
4 | /**
5 | * Primary UI component for user interaction
6 | */
7 | export const Button = ({
8 | primary,
9 | backgroundColor,
10 | borderRadius,
11 | size,
12 | label,
13 | normalCursor,
14 | ...props
15 | }) => {
16 | const mode = primary ? "button--primary" : "button--secondary";
17 | return (
18 |
31 | );
32 | };
33 |
34 | Button.propTypes = {
35 | /**
36 | * Is this the principal call to action on the page?
37 | */
38 | primary: PropTypes.bool,
39 | /**
40 | * What background color to use
41 | */
42 | backgroundColor: PropTypes.string,
43 | /**
44 | * How much rounded are they?
45 | */
46 | borderRadius: PropTypes.string,
47 | /**
48 | * How large should the button be?
49 | */
50 | size: PropTypes.oneOf(["small", "medium", "large"]),
51 | /**
52 | * Button contents
53 | */
54 | label: PropTypes.string.isRequired,
55 | /**
56 | * Optional click handler
57 | */
58 | onClick: PropTypes.func,
59 | };
60 |
61 | Button.defaultProps = {
62 | backgroundColor: "red",
63 | borderRadius: "0%",
64 | primary: false,
65 | size: "medium",
66 | onClick: undefined,
67 | };
68 |
69 | const CardButton = ({
70 | text,
71 | color = "#000",
72 | onClick = () => {},
73 | normalCursor,
74 | }) => (
75 |
84 | );
85 |
86 | export default CardButton;
87 |
--------------------------------------------------------------------------------
/src/components/Notification.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default class Notification extends React.Component {
4 | constructor(props) {
5 | super();
6 | this.state = {
7 | isExtended: false,
8 | };
9 | }
10 |
11 | toggleDetails() {
12 | this.setState({ isExtended: !this.state.isExtended });
13 | }
14 |
15 | renderItems(items) {
16 | if (!items) return "";
17 | return (
18 |
19 | {items.map((item, idx) => {
20 | if (item.error) {
21 | return
{item.error.message}
;
22 | }
23 | return null;
24 | })}
25 |
26 | );
27 | }
28 |
29 | renderNotificationContent(notification) {
30 | const { type, message, items } = notification;
31 |
32 | return (
33 |
34 |
{message}
35 |
36 | {items !== null ? this.renderItems(items) : ""}
37 |
38 |
39 | );
40 | }
41 |
42 | render() {
43 | if (!this.props.notifications) return null;
44 | const notificationsToRender = this.props.notifications.filter(
45 | (n) => !("isRead" in n && n.isRead)
46 | );
47 | if (notificationsToRender.length > 0) {
48 | return (
49 |
50 | {this.props.notifications.map((notification, idx) => {
51 | return (
52 |
this.toggleDetails()}
55 | key={idx}
56 | >
57 |
63 | {this.renderNotificationContent(notification)}
64 |
65 | );
66 | })}
67 |
68 | );
69 | }
70 | return ;
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/components/controls/atoms/SearchRow.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const SearchRow = ({ query, eventObj, onSearchRowClick }) => {
4 | const { description, location, date } = eventObj;
5 | function getHighlightedText(text, highlight) {
6 | // Split text on highlight term, include term itself into parts, ignore case
7 | const parts = text.split(new RegExp(`(${highlight})`, "gi"));
8 | return (
9 |
10 | {parts.map((part) =>
11 | part.toLowerCase() === highlight.toLowerCase() ? (
12 |
13 | {part}
14 |
15 | ) : (
16 | part
17 | )
18 | )}
19 |
20 | );
21 | }
22 |
23 | function getShortDescription(text, searchQuery) {
24 | const regexp = new RegExp(
25 | `(([^ ]* ){0,6}[a-zA-Z]*${searchQuery.toLowerCase()}[a-zA-Z]*( [^ ]*){0,5})`,
26 | "gm"
27 | );
28 | const parts = text.toLowerCase().match(regexp);
29 | for (let x = 0; x < (parts ? parts.length : 0); x++) {
30 | parts[x] = "..." + parts[x];
31 | }
32 | const firstLine = [text.match("(([^ ]* ){0,10})", "m")[0]];
33 | return parts || firstLine;
34 | }
35 |
36 | return (
37 | onSearchRowClick([eventObj])}>
38 |
39 |
40 |
event
41 |
{getHighlightedText(date, query)}
42 |
43 |
44 |
location_on
45 |
{getHighlightedText(location, query)}
46 |
47 |
48 |
49 | {getShortDescription(description, query).map((match) => {
50 | return (
51 |
52 | {getHighlightedText(match, query)}...
53 |
54 |
55 | );
56 | })}
57 |
58 |
59 | );
60 | };
61 |
62 | export default SearchRow;
63 |
--------------------------------------------------------------------------------
/config/getHttpsConfig.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const fs = require('fs');
4 | const path = require('path');
5 | const crypto = require('crypto');
6 | const chalk = require('react-dev-utils/chalk');
7 | const paths = require('./paths');
8 |
9 | // Ensure the certificate and key provided are valid and if not
10 | // throw an easy to debug error
11 | function validateKeyAndCerts({ cert, key, keyFile, crtFile }) {
12 | let encrypted;
13 | try {
14 | // publicEncrypt will throw an error with an invalid cert
15 | encrypted = crypto.publicEncrypt(cert, Buffer.from('test'));
16 | } catch (err) {
17 | throw new Error(
18 | `The certificate "${chalk.yellow(crtFile)}" is invalid.\n${err.message}`
19 | );
20 | }
21 |
22 | try {
23 | // privateDecrypt will throw an error with an invalid key
24 | crypto.privateDecrypt(key, encrypted);
25 | } catch (err) {
26 | throw new Error(
27 | `The certificate key "${chalk.yellow(keyFile)}" is invalid.\n${
28 | err.message
29 | }`
30 | );
31 | }
32 | }
33 |
34 | // Read file and throw an error if it doesn't exist
35 | function readEnvFile(file, type) {
36 | if (!fs.existsSync(file)) {
37 | throw new Error(
38 | `You specified ${chalk.cyan(
39 | type
40 | )} in your env, but the file "${chalk.yellow(file)}" can't be found.`
41 | );
42 | }
43 | return fs.readFileSync(file);
44 | }
45 |
46 | // Get the https config
47 | // Return cert files if provided in env, otherwise just true or false
48 | function getHttpsConfig() {
49 | const { SSL_CRT_FILE, SSL_KEY_FILE, HTTPS } = process.env;
50 | const isHttps = HTTPS === 'true';
51 |
52 | if (isHttps && SSL_CRT_FILE && SSL_KEY_FILE) {
53 | const crtFile = path.resolve(paths.appPath, SSL_CRT_FILE);
54 | const keyFile = path.resolve(paths.appPath, SSL_KEY_FILE);
55 | const config = {
56 | cert: readEnvFile(crtFile, 'SSL_CRT_FILE'),
57 | key: readEnvFile(keyFile, 'SSL_KEY_FILE'),
58 | };
59 |
60 | validateKeyAndCerts({ ...config, keyFile, crtFile });
61 | return config;
62 | }
63 | return isHttps;
64 | }
65 |
66 | module.exports = getHttpsConfig;
67 |
--------------------------------------------------------------------------------
/src/scss/loading.scss:
--------------------------------------------------------------------------------
1 | .loading-overlay {
2 | font-weight: 300;
3 | width: 100%;
4 | height: 100%;
5 | position: absolute;
6 | background: rgba(0, 0, 0, 0.9);
7 | transition: 0.4s ease;
8 | z-index: $loading-overlay;
9 | opacity: 1;
10 |
11 | .loading-wrapper {
12 | position: fixed;
13 | left: 50%;
14 | top: 40%;
15 | text-align: center;
16 | width: 100%;
17 | margin: 0 0 0 -50%;
18 | height: 100%;
19 | opacity: 1;
20 |
21 | span {
22 | color: $offwhite;
23 | letter-spacing: 0.1em;
24 | text-transform: uppercase;
25 | }
26 | }
27 |
28 | &.hidden {
29 | transition: opacity 0.4s ease, z-index 0.1s 0.4s;
30 | opacity: 0;
31 | z-index: $hidden;
32 | }
33 | }
34 |
35 | /*
36 | https://github.com/tobiasahlin/SpinKit/blob/master/LICENSE
37 | */
38 | .spinner {
39 | width: 40px;
40 | height: 40px;
41 |
42 | position: relative;
43 | margin: 10px auto;
44 |
45 | &.small {
46 | width: 15px;
47 | height: 15px;
48 | margin: 5px 20px 5px 10px;
49 | }
50 | }
51 |
52 | .double-bounce,
53 | .double-bounce-overlay {
54 | width: 100%;
55 | height: 100%;
56 | border-radius: 50%;
57 | background-color: $offwhite;
58 | opacity: 0.6;
59 | position: absolute;
60 | top: 0;
61 | left: 0;
62 |
63 | -webkit-animation: sk-bounce 3s infinite ease-in-out;
64 | animation: sk-bounce 3s infinite ease-in-out;
65 | }
66 |
67 | .double-bounce-overlay {
68 | -webkit-animation-delay: -1s;
69 | animation-delay: -1s;
70 | background-color: black;
71 | }
72 |
73 | @-webkit-keyframes sk-bounce {
74 | 0%,
75 | 100% {
76 | -webkit-transform: scale(0.3);
77 | }
78 | 50% {
79 | -webkit-transform: scale(1);
80 | }
81 | }
82 |
83 | @keyframes sk-bounce {
84 | 0%,
85 | 100% {
86 | transform: scale(0.3);
87 | -webkit-transform: scale(0.3);
88 | }
89 | 50% {
90 | transform: scale(1);
91 | -webkit-transform: scale(1);
92 | }
93 | }
94 |
95 | .fixedTooSmallMessage {
96 | position: absolute;
97 | top: 0;
98 | color: white;
99 | padding: 10px;
100 | }
101 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Civilian Harm in Ukraine
7 |
8 |
9 |
10 |
11 |
12 |
22 |
23 |
24 |
25 |
26 |
43 |
44 |
47 |
48 | This platform is unsuitable for mobile. Please revisit on a desktop.
49 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/src/scss/search.scss:
--------------------------------------------------------------------------------
1 | #search-bar-icon-container {
2 | position: absolute;
3 | background-color: black;
4 | color: #a0a0a0;
5 | border: #a0a0a0 solid 0.1px;
6 | top: 10px;
7 | margin-left: 10px;
8 | height: 24px;
9 | padding: 10px;
10 | &:hover {
11 | cursor: pointer;
12 | color: white;
13 | }
14 | }
15 |
16 | .search-bar-overlay {
17 | background-color: black;
18 | height: 100vh;
19 | width: 400px;
20 | position: absolute;
21 | transition: 0.2s ease;
22 | }
23 |
24 | .search-bar-input {
25 | width: 300px;
26 | margin: 20px;
27 | line-height: 40px;
28 | font-size: 15px;
29 | color: gray;
30 | padding-left: 15px;
31 | background: black;
32 | border: 1px solid #a0a0a0;
33 | &:focus {
34 | outline: none;
35 | }
36 | }
37 |
38 | #close-search-overlay {
39 | color: #a0a0a0;
40 | vertical-align: middle;
41 | font-size: 30px;
42 | transition: 0.2s ease;
43 | &:hover {
44 | color: white;
45 | cursor: pointer;
46 | }
47 | }
48 |
49 | .folded {
50 | left: -400px;
51 | transition: 0.2s ease;
52 | }
53 |
54 | .search-outer-container {
55 | position: absolute;
56 | left: 110px;
57 | &.narrative-mode {
58 | left: 0;
59 | }
60 | }
61 |
62 | .search-row {
63 | color: black;
64 | padding-left: 15px;
65 | padding-right: 15px;
66 | padding-top: 10px;
67 | padding-bottom: 10px;
68 | background-color: #dfdfdf;
69 | transition: background-color 0.4s;
70 | border-bottom: 1px black solid;
71 | border-top: 1px black solid;
72 | font-size: 14px;
73 | opacity: 0.9;
74 | &:hover {
75 | transition: background-color 0.4s;
76 | background-color: white;
77 | cursor: pointer;
78 | }
79 | }
80 |
81 | .search-row > p {
82 | margin: 0;
83 | }
84 |
85 | .search-results {
86 | height: calc(100% - 332px);
87 | overflow: auto;
88 | }
89 |
90 | div.location-date-container {
91 | margin-top: 10px;
92 | margin-bottom: 10px;
93 | }
94 |
95 | div.location-date-container > div {
96 | width: 50%;
97 | display: inline-block;
98 | vertical-align: top;
99 | }
100 |
101 | div.location-date-container > div > p {
102 | display: inline;
103 | line-height: 17px;
104 | vertical-align: top;
105 | }
106 |
107 | div.location-date-container > div > i {
108 | font-size: 12px;
109 | margin-right: 5px;
110 | }
111 |
--------------------------------------------------------------------------------
/src/components/time/Axis.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import * as d3 from "d3";
3 | import { setD3Locale } from "../../common/utilities";
4 |
5 | const TEXT_HEIGHT = 15;
6 | setD3Locale(d3);
7 | class TimelineAxis extends React.Component {
8 | constructor() {
9 | super();
10 | this.xAxis0Ref = React.createRef();
11 | this.xAxis1Ref = React.createRef();
12 | this.state = {
13 | isInitialized: false,
14 | };
15 | }
16 |
17 | componentDidUpdate() {
18 | let fstFmt, sndFmt;
19 |
20 | // 10yrs
21 | if (this.props.extent > 5256000) {
22 | fstFmt = "%Y";
23 | sndFmt = "";
24 | // 1yr
25 | } else if (this.props.extent > 43200) {
26 | sndFmt = "%d %b";
27 | fstFmt = "";
28 | } else {
29 | sndFmt = "%d %b";
30 | // fstFmt = "%H:%M";
31 | fstFmt = "";
32 | }
33 |
34 | const { marginTop, contentHeight } = this.props.dims;
35 | if (this.props.scaleX) {
36 | this.x0 = d3
37 | .axisBottom(this.props.scaleX)
38 | .ticks(this.props.ticks)
39 | .tickPadding(0)
40 | .tickSize(contentHeight - TEXT_HEIGHT - marginTop)
41 | .tickFormat(d3.timeFormat(fstFmt));
42 |
43 | this.x1 = d3
44 | .axisBottom(this.props.scaleX)
45 | .ticks(this.props.ticks)
46 | .tickPadding(marginTop)
47 | .tickSize(0)
48 | .tickFormat(d3.timeFormat(sndFmt));
49 |
50 | if (!this.state.isInitialized) this.setState({ isInitialized: true });
51 | }
52 |
53 | if (this.state.isInitialized) {
54 | d3.select(this.xAxis0Ref.current)
55 | .transition()
56 | .duration(this.props.transitionDuration)
57 | .call(this.x0);
58 |
59 | d3.select(this.xAxis1Ref.current)
60 | .transition()
61 | .duration(this.props.transitionDuration)
62 | .call(this.x1);
63 | }
64 | }
65 |
66 | render() {
67 | return (
68 | <>
69 |
75 |
81 | >
82 | );
83 | }
84 | }
85 |
86 | export default TimelineAxis;
87 |
--------------------------------------------------------------------------------
/src/scss/_burger.scss:
--------------------------------------------------------------------------------
1 | // Burger transition
2 | .side-menu-burg {
3 | position: absolute;
4 | overflow: hidden;
5 | float: right;
6 | margin: 0;
7 | padding: 0;
8 | width: 20px;
9 | height: 20px;
10 | appearance: none;
11 | box-shadow: none;
12 | border-radius: none;
13 | border: none;
14 | cursor: pointer;
15 | background: none;
16 |
17 | &.hidden {
18 | display: none;
19 | }
20 |
21 | span {
22 | display: block;
23 | position: absolute;
24 | top: 9px;
25 | left: 0px;
26 | right: 0px;
27 | height: 2px;
28 | background: $offwhite;
29 | border-radius: 4px;
30 | }
31 |
32 | span::before,
33 | span::after {
34 | position: absolute;
35 | display: block;
36 | left: 0;
37 | width: 100%;
38 | height: 2px;
39 | background: $offwhite;
40 | border-radius: 4px;
41 | content: "";
42 | transition-duration: 0.2s, 0.2s;
43 | transition-delay: 0.2s, 0s;
44 | }
45 |
46 | span::before {
47 | transition-property: top, transform;
48 | top: -8px;
49 | }
50 |
51 | span::after {
52 | transition-property: bottom, transform;
53 | bottom: -8px;
54 | }
55 |
56 | &:hover {
57 | span::before {
58 | top: -6px;
59 | }
60 |
61 | span::after {
62 | bottom: -6px;
63 | }
64 | }
65 |
66 | &.is-active {
67 | span {
68 | background: $midwhite;
69 | transform: rotate(45deg);
70 | transition-delay: 0s, 0.2s;
71 | }
72 |
73 | span::before,
74 | span::after {
75 | background: $midwhite;
76 | transition-delay: 0s, 0.2s;
77 | }
78 |
79 | span::before {
80 | top: 0;
81 | transform: rotate(0deg);
82 | -webkit-transform: rotate(0deg);
83 | }
84 |
85 | span::after {
86 | bottom: 0;
87 | transform: rotate(-90deg);
88 | -webkit-transform: rotate(-90deg);
89 | }
90 |
91 | &:hover {
92 | span,
93 | span::before,
94 | span::after {
95 | transition: 0.2s ease;
96 | background: $offwhite;
97 | }
98 | }
99 |
100 | &.over-white:hover {
101 | span,
102 | span:before,
103 | span:after {
104 | transition: 0.2s ease;
105 | background: $darkgrey;
106 | }
107 | }
108 | }
109 | }
110 |
111 | .side-menu-burg:focus {
112 | outline: none;
113 | }
114 |
--------------------------------------------------------------------------------
/src/scss/infopopup.scss:
--------------------------------------------------------------------------------
1 | @import "burger";
2 |
3 | .infopopup {
4 | width: $infopopup-width;
5 | box-shadow: 10px -10px 38px rgba(0, 0, 0, 0.3),
6 | 10px 15px 12px rgba(0, 0, 0, 0.22);
7 | color: $darkgrey;
8 | position: absolute;
9 | background: $offwhite-transparent;
10 | bottom: $timeline-height;
11 | left: $toolbar-width;
12 | border: 3px solid $offwhite;
13 | border-radius: 1px;
14 | padding: 20px;
15 | box-sizing: border-box;
16 | font-size: $large;
17 | transition: opacity 0.5s ease 0.1s, z-index 0.1s ease 0s;
18 | opacity: 1;
19 | z-index: $overheader;
20 |
21 | &.hidden {
22 | transition: 0.5s ease;
23 | opacity: 0;
24 | }
25 |
26 | .side-menu-burg {
27 | position: absolute;
28 | right: 8px;
29 | top: 10px;
30 | &.light {
31 | &.is-active span:after,
32 | &.is-active span:before {
33 | background: black;
34 | }
35 | }
36 | }
37 |
38 | &.dark {
39 | // background: $black-transparent;
40 | background: rgba(0, 0, 0, 0.8);
41 | color: white;
42 | }
43 |
44 | iframe {
45 | flex: 1;
46 | width: 100%;
47 | min-height: 400px;
48 | }
49 |
50 | @media (max-height: 1000px) {
51 | iframe {
52 | min-height: 230px;
53 | }
54 | }
55 |
56 | &.mobile {
57 | border: none;
58 | padding: 5vmin;
59 | .side-menu-burg {
60 | display: none;
61 | }
62 | }
63 |
64 | .legend {
65 | display: flex;
66 | flex-direction: column;
67 | }
68 |
69 | .legend-header {
70 | display: flex;
71 | flex-direction: row;
72 | justify-content: center;
73 | h2 {
74 | display: flex;
75 | font-size: 12pt;
76 | letter-spacing: 2px;
77 | margin: 0;
78 | }
79 | }
80 |
81 | .legend-container {
82 | height: 100%;
83 | display: flex;
84 | flex-direction: row;
85 |
86 | .legend-item {
87 | display: flex;
88 | justify-content: center;
89 | align-items: center;
90 | &.one {
91 | flex: 1;
92 | }
93 | &.three {
94 | flex: 5;
95 | }
96 | }
97 | }
98 |
99 | .legend-section {
100 | height: 25px;
101 | display: flex;
102 | align-items: center;
103 |
104 | svg {
105 | width: 60px;
106 | float: left;
107 | display: inline-block;
108 | }
109 |
110 | .legend-labels {
111 | display: flex;
112 | }
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/src/components/time/Categories.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import * as d3 from "d3";
3 |
4 | class TimelineCategories extends React.Component {
5 | constructor(props) {
6 | super(props);
7 | this.grabRef = React.createRef();
8 | this.state = {
9 | isInitialized: false,
10 | };
11 | }
12 |
13 | componentDidUpdate() {
14 | if (!this.state.isInitialized) {
15 | const drag = d3
16 | .drag()
17 | .on("start", this.props.onDragStart)
18 | .on("drag", this.props.onDrag)
19 | .on("end", this.props.onDragEnd);
20 |
21 | d3.select(this.grabRef.current).call(drag);
22 |
23 | this.setState({ isInitialized: true });
24 | }
25 | }
26 |
27 | renderCategory(cat, idx) {
28 | const { features, dims } = this.props;
29 | const strokeWidth = 1; // dims.trackHeight / (this.props.categories.length + 1)
30 | if (
31 | features.GRAPH_NONLOCATED &&
32 | features.GRAPH_NONLOCATED.categories &&
33 | features.GRAPH_NONLOCATED.categories.includes(cat)
34 | ) {
35 | return null;
36 | }
37 |
38 | return (
39 | <>
40 |
46 |
47 |
48 |
53 |
54 | {cat}
55 |
56 |
57 | >
58 | );
59 | }
60 |
61 | render() {
62 | const { dims, categories, fallbackLabel } = this.props;
63 | const categoriesExist = categories && categories.length > 0;
64 | const renderedCategories = categoriesExist
65 | ? categories.map((cat, idx) => this.renderCategory(cat, idx))
66 | : this.renderCategory(fallbackLabel, 0);
67 |
68 | return (
69 |
70 | {renderedCategories}
71 |
79 |
80 | );
81 | }
82 | }
83 |
84 | export default TimelineCategories;
85 |
--------------------------------------------------------------------------------
/config/paths.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const path = require('path');
4 | const fs = require('fs');
5 | const getPublicUrlOrPath = require('react-dev-utils/getPublicUrlOrPath');
6 |
7 | // Make sure any symlinks in the project folder are resolved:
8 | // https://github.com/facebook/create-react-app/issues/637
9 | const appDirectory = fs.realpathSync(process.cwd());
10 | const resolveApp = relativePath => path.resolve(appDirectory, relativePath);
11 |
12 | // We use `PUBLIC_URL` environment variable or "homepage" field to infer
13 | // "public path" at which the app is served.
14 | // webpack needs to know it to put the right