Full Code of bellingcat/ukraine-timemap for AI

main f6cc37306c81 cached
158 files
372.5 KB
100.0k tokens
406 symbols
1 requests
Download .txt
Showing preview only (410K chars total). Download the full file or copy to clipboard to get everything.
Repository: bellingcat/ukraine-timemap
Branch: main
Commit: f6cc37306c81
Files: 158
Total size: 372.5 KB

Directory structure:
gitextract_fsuxlpf2/

├── .dockerignore
├── .eslintrc.js
├── .github/
│   ├── ISSUE_TEMPLATE.md
│   └── workflows/
│       ├── cd.yml
│       └── ci.yml
├── .gitignore
├── .prettierrc
├── .travis.yml
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE.md
├── README.md
├── config.js
├── docs/
│   ├── configuration.md
│   └── custom-covers.md
├── example.config.js
├── index.html
├── package.json
├── public/
│   ├── CNAME
│   └── index.html
├── src/
│   ├── actions/
│   │   └── index.js
│   ├── common/
│   │   ├── constants.js
│   │   ├── data/
│   │   │   ├── copy.json
│   │   │   └── es-MX.json
│   │   ├── global.js
│   │   └── utilities.js
│   ├── components/
│   │   ├── App.jsx
│   │   ├── InfoPopup.jsx
│   │   ├── Layout.jsx
│   │   ├── Notification.jsx
│   │   ├── Portal.jsx
│   │   ├── TemplateCover.jsx
│   │   ├── Toolbar.jsx
│   │   ├── atoms/
│   │   │   ├── Checkbox.jsx
│   │   │   ├── CoeventIcon.jsx
│   │   │   ├── ColoredMarkers.jsx
│   │   │   ├── Content.jsx
│   │   │   ├── Controls.jsx
│   │   │   ├── CoverIcon.jsx
│   │   │   ├── InfoIcon.jsx
│   │   │   ├── Loading.jsx
│   │   │   ├── Md.jsx
│   │   │   ├── Media.jsx
│   │   │   ├── NoSource.jsx
│   │   │   ├── Popup.jsx
│   │   │   ├── RefreshIcon.jsx
│   │   │   ├── RouteIcon.jsx
│   │   │   ├── SitesIcon.jsx
│   │   │   ├── Spinner.jsx
│   │   │   └── StaticPage.jsx
│   │   ├── controls/
│   │   │   ├── BottomActions.jsx
│   │   │   ├── Card.jsx
│   │   │   ├── CardStack.jsx
│   │   │   ├── CategoriesListPanel.jsx
│   │   │   ├── DownloadButton.jsx
│   │   │   ├── DownloadPanel.jsx
│   │   │   ├── FilterListPanel.jsx
│   │   │   ├── FullScreenToggle.jsx
│   │   │   ├── NarrativeControls.jsx
│   │   │   ├── Search.jsx
│   │   │   ├── ShapesListPanel.jsx
│   │   │   └── atoms/
│   │   │       ├── Button.jsx
│   │   │       ├── Caret.jsx
│   │   │       ├── CustomField.jsx
│   │   │       ├── Media.jsx
│   │   │       ├── NarrativeAdjust.jsx
│   │   │       ├── NarrativeCard.jsx
│   │   │       ├── NarrativeClose.jsx
│   │   │       ├── PanelTree.jsx
│   │   │       ├── SearchRow.jsx
│   │   │       ├── TelegramEmbed.jsx
│   │   │       ├── Text.jsx
│   │   │       ├── Time.jsx
│   │   │       ├── ToolbarButton.jsx
│   │   │       └── TwitterTweet.jsx
│   │   ├── space/
│   │   │   ├── Space.jsx
│   │   │   └── carto/
│   │   │       ├── Map.jsx
│   │   │       └── atoms/
│   │   │           ├── Clusters.jsx
│   │   │           ├── DefsMarkers.jsx
│   │   │           ├── Events.jsx
│   │   │           ├── Narratives.jsx
│   │   │           ├── Regions.jsx
│   │   │           ├── SatelliteOverlayToggle.jsx
│   │   │           ├── SelectedEvents.jsx
│   │   │           ├── Sites.jsx
│   │   │           └── __tests__/
│   │   │               └── SatelliteOverlayToggle.spec.jsx
│   │   └── time/
│   │       ├── Axis.jsx
│   │       ├── Categories.jsx
│   │       ├── Timeline.jsx
│   │       └── atoms/
│   │           ├── Clip.jsx
│   │           ├── DatetimeBar.jsx
│   │           ├── DatetimeDot.jsx
│   │           ├── DatetimePentagon.jsx
│   │           ├── DatetimeSquare.jsx
│   │           ├── DatetimeStar.jsx
│   │           ├── DatetimeTriangle.jsx
│   │           ├── Events.jsx
│   │           ├── Handles.jsx
│   │           ├── Header.jsx
│   │           ├── Labels.jsx
│   │           ├── Markers.jsx
│   │           ├── Project.jsx
│   │           └── ZoomControls.jsx
│   ├── index.jsx
│   ├── reducers/
│   │   ├── __tests__/
│   │   │   ├── index.spec.js
│   │   │   └── ui.spec.js
│   │   ├── app.js
│   │   ├── domain.js
│   │   ├── features.js
│   │   ├── index.js
│   │   ├── root.js
│   │   ├── ui.js
│   │   └── validate/
│   │       ├── associationsSchema.js
│   │       ├── eventSchema.js
│   │       ├── regionSchema.js
│   │       ├── shapeSchema.js
│   │       ├── siteSchema.js
│   │       ├── sourceSchema.js
│   │       └── validators.js
│   ├── scss/
│   │   ├── _burger.scss
│   │   ├── _icons.scss
│   │   ├── _variables.scss
│   │   ├── button.scss
│   │   ├── card.scss
│   │   ├── cardstack.scss
│   │   ├── common.scss
│   │   ├── cover.scss
│   │   ├── header.scss
│   │   ├── infopopup.scss
│   │   ├── loading.scss
│   │   ├── main.scss
│   │   ├── map.scss
│   │   ├── mediaplayer.scss
│   │   ├── narrativecard.scss
│   │   ├── notification.scss
│   │   ├── overlay.scss
│   │   ├── popup.scss
│   │   ├── satelliteoverlaytoggle.scss
│   │   ├── search.scss
│   │   ├── tabs.scss
│   │   ├── timeline.scss
│   │   ├── toolbar.scss
│   │   └── video.scss
│   ├── selectors/
│   │   ├── __tests__/
│   │   │   └── timeline.spec.js
│   │   ├── helpers.js
│   │   └── index.js
│   ├── store/
│   │   ├── index.js
│   │   ├── initial.js
│   │   └── plugins/
│   │       └── urlState/
│   │           ├── applyUrlState.js
│   │           ├── index.js
│   │           ├── middleware.js
│   │           ├── schema.js
│   │           └── urlState.js
│   └── test/
│       └── App.test.jsx
├── test/
│   ├── __mocks__/
│   │   ├── fileMock.js
│   │   └── styleMock.js
│   └── setup.js
└── vite.config.js

================================================
FILE CONTENTS
================================================

================================================
FILE: .dockerignore
================================================
node_modules/
build/
example.config.js


================================================
FILE: .eslintrc.js
================================================
module.exports = {
  root: true,
  plugins: [],

  parserOptions: {
    sourceType: "module",
    ecmaFeatures: {
      jsx: true,
    },
  },
  settings: {
    react: {
      version: "detect",
    },
  },
  extends: [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:react/jsx-runtime",
    "plugin:react-hooks/recommended",
    "prettier",
  ],
  env: {
    browser: true,
    es2022: true,
    jest: true,
  },
  rules: {
    "react/prop-types": 0,
  }
};


================================================
FILE: .github/ISSUE_TEMPLATE.md
================================================
<!--- Hi, and thanks for contributing! -->

<!--- Before opening a new issue, please search our existing issues to see if anyone else has had the-->
<!--- same issue as you. Make sure to provide a general summary of the issue in the Title above! -->

Environment
-----------

* Your version (in package.json) or git commit hash
* Your operating system and version:

<!--- Include any other relevant details about your environment and installation, including: configuration details, the url(s) being accessed, etc.. -->

Steps to reproduce (for bugs only)
-----------------------------
<!--- If describing a bug, tell us what happens when the steps to reproduce are performed -->
<!--- If possible, provide a curl command line and resulting output -->

1.
2.
3.

Current Behavior
----------------

<!--- If describing a bug, tell us what happens when the steps to reproduce are performed. -->
<!--- If you're suggesting a change, describe the current behavior and why it needs improvement -->

Expected Behavior
-----------------

<!--- If you're describing a bug, tell us what _should_ happen -->
<!--- If you're suggesting a change/improvement, tell us how it _should_ work -->

<!--- Thanks again for contributing! -->


================================================
FILE: .github/workflows/cd.yml
================================================
name: CD
on:
  push:
    branches: [ develop ]
#  pull_request:
#    branches: [ develop ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Trigger CD build
        uses: peter-evans/repository-dispatch@v1
        with:
          token: ${{ secrets.CI_DISPATCH_TOKEN }}
          repository: forensic-architecture/configs
          event-type: remote-build
          client-payload: '{"runtime_args": "timemap", "branch": "${GITHUB_REF##*/}"}'



================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
  push:
    branches: [ develop ]
  pull_request:
    branches: [ develop ]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
        with:
          ref: ${{ github.head_ref }}
      - uses: actions/setup-node@v2-beta
        with:
          node-version: '12'

      - run: npm install
      - run: cp example.config.js config.js
      - run: npm test
        env:
          CI: true
      - run: npm run lint
        env:
          CI: true


================================================
FILE: .gitignore
================================================
.idea/
build/
node_modules/

dev.config.js
!config/webpack*.config.js
!config/getHttpsConfig.js


tags
tags.lock
tags.temp

.eslintcache

src/\.DS_Store
src/assets/fonts

\.DS_Store

tags


================================================
FILE: .prettierrc
================================================


================================================
FILE: .travis.yml
================================================
language: node_js
node_js:
  - "stable"
cache:
  directories:
    - node_modules
before_script:
  - cp example.config.js config.js
install:
  - npm install
script:
  - npm run lint
  - npm run build
  - npm run test


================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to timemap 

Hello! Thanks for being part of the Bellingcat Tech Community 💪 We really appreciate your ideas, thoughts, and involvement. Read on for guidance on how to contribute to this project 🏆

Contributions to this project are released to the public under the project's open source license.

Please note that this project is released with a [Contributor Code of Conduct](https://github.com/bellingcat/.github/blob/main/CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms.

## What do I need to know to help?
### Javascript / React / Redux
In order to contribute code upstream, you'll likely need to have a sense of ES6
Javascript, React, and Redux. If these terms are new to you, or not as familiar
as you might like, here's a good tutorial to get you up to speed:

- [Building a voting app with Redux and React](https://teropa.info/blog/2015/09/10/full-stack-redux-tutorial.html)

### Node JS and Docker
Timemap doesn't actually use these technologies; but the main way of getting up
and running with a data provider for timemap,
[datasheet-server](https://github.com/bellingcat/datasheet-server),
does, and so they're helpful to know.

## Do I need to be an experienced JS developer? 
Contributing can of course be about contributing code, but it can also take
many other forms. A great amount of work that remains to be done to make
timemap a usable community tool doesn't involve writing any code. The following
are all very welcome contributions:

- Writing, updating or correcting documentation
- Fixing an open issue
- Requesting a feature
- Reporting a bug

If you're new to this project, you could check the issues that are tagged
["good first issue"](https://github.com/bellingcat/ukraine-timemap/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22).

These are a range of the issues that have come up in conversation for which we
would welcome community contributions. These are, however, by no means
exhaustive! If you see a gap or have an idea, please open up an issue to
discuss it with timemap's maintainers.

## How do I make a contribution? 

1. Make sure you have a [GitHub account](https://github.com/signup/free)
2. Fork the repository on GitHub. This is necessary so that you can push your
    changes, as you can't do this directly on our repo.
3. Get set up with a local instance of timemap and datasheet-server. The easiest
    way to do this is by reading [this blog post on the forensic architecture website](https://forensic-architecture.org/investigation/timemap-for-cartographic-platforms).
4. [Join the Bellingcat Discord server](https://discord.gg/bellingcat), this is our main community hub. Check out the #tool-and-sites and #tech-support channels for support with the ukraine-timemap.
   Also consider joining the [Forensic Architecture Discord](https://discord.gg/PjHKHJD5KX), the #timemap and #support channels are the two best channels to ask questions about setting timemap up.

Once you're set up with a local copy of timemap and datasheet-server, you can
start modifying code and making changes. 

When you're ready to submit a contribution, you can do it by making a pull
request from a branch on your forked copy of timemap to this repository. You
can do this with the following steps:
1. Push the changes to a remote repository. If the changes you have made
   address a bug, you should name it `bug/{briefdesc}`, where `{briefdesc}` is
   a hyphen-separated description of your change. If instead you are
   contributing changes as a feature request, name it `feature/{briefdesc`}. If
   in doubt, prefix your branch with `feature/`.
2. Submit a pull request to the `develop` branch of `forensic-architecture/timemap`.
3. Wait for the pull request to be reviewed by a maintainer.
4. Make changes to the pull request if the reviewing maintainer recommends
   them.
5. Celebrate your success once your pull request is merged!

## How do I validate my changes?
We are still working on a set of tests. Right now, it is enough to confirm that
the application runs as expected with `npm run dev`. If your changes introduce
other issues, a maintainer will flag it in stage 3 of the submission process
above.

## Credits 
This contributing guide is based on the guidelines of both the 
[SuperCollider contributing guide](https://raw.githubusercontent.com/supercollider/supercollider/develop/CONTRIBUTING.md),
and the [nteract contributing
guide](https://github.com/nteract/nteract/blob/master/CONTRIBUTING.md) (two
excellent open source projects!).

Thanks to [Scott Carver](https://github.com/scztt) for advice on how to put
a guide together.


================================================
FILE: Dockerfile
================================================
FROM mhart/alpine-node:10.11

LABEL authors="Lachlan Kermode <lk@forensic-architecture.org>"

# Install app dependencies
COPY package.json /www/package.json
RUN cd /www; yarn

# Copy app source
COPY . /www
WORKDIR /www
RUN yarn build

# files available to copy at /www/build


================================================
FILE: LICENSE.md
================================================
Do No Harm License

**Preamble**

Most software today is developed with little to no thought of how it will be used, or the consequences for our society and planet.

As software developers, we engineer the infrastructure of the 21st century. We recognise that our infrastructure has great power to shape the world and the lives of those we share it with, and we choose to consciously take responsibility for the social and environmental impacts of what we build.

We envisage a world free from injustice, inequality, and the reckless destruction of lives and our planet. We reject slavery in all its forms, whether by force, indebtedness, or by algorithms that hack human vulnerabilities. We seek a world where humankind is at peace with our neighbours, nature, and ourselves. We want our work to enrich the physical, mental and spiritual wellbeing of all society.

We build software to further this vision of a just world, or at the very least, to not put that vision further from reach.

**Terms**

*Copyright* (c) 2019 Forensic Architecture. All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.

3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.

4. This software must not be used by any organisation, website, product or service that:

   a) lobbies for, promotes, or derives a majority of income from actions that support or contribute to:
      * sex trafficking
      * human trafficking
      * slavery
      * indentured servitude
      * gambling
      * tobacco
      * adversely addictive behaviours
      * nuclear energy
      * warfare
      * weapons manufacturing
      * war crimes
      * violence (except when required to protect public safety)
      * burning of forests
      * deforestation
      * hate speech or discrimination based on age, gender, gender identity, race, sexuality, religion, nationality

   b) lobbies against, or derives a majority of income from actions that discourage or frustrate:
      * peace
      * access to the rights set out in the Universal Declaration of Human Rights and the Convention on the Rights of the Child
      * peaceful assembly and association (including worker associations)
      * a safe environment or action to curtail the use of fossil fuels or prevent climate change
      * democratic processes

5. All redistribution of source code or binary form, including any modifications must be under these terms. You must inform recipients that the code is governed by these conditions, and how they can obtain a copy of this license. You may not attempt to alter the conditions of who may/may not use this software.

We define:

**Forests** to be 0.5 or more hectares of trees that were either planted more than 50 years ago or were not planted by humans or human made equipment.

**Deforestation** to be the clearing, burning or destruction of 0.5 or more hectares of forests within a 1 year period.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

**Attribution**

Do No Harm License [Contributor Covenant][homepage], (pre 1.0),
available at https://github.com/raisely/NoHarm

[homepage]: https://github.com/raisely/NoHarm



================================================
FILE: README.md
================================================
<h1 align="center">Civilian Harm in Ukraine TimeMap</h1>

<h2 align="center">
	Explore it in <a href="https://ukraine.bellingcat.com/">ukraine.bellingcat.com</a>
	<br/>
	Download/integrate the data from <a href="https://bellingcat-embeds.ams3.cdn.digitaloceanspaces.com/production/ukr/timemap/api.json">here</a> <small>(regularly updated dataset)</small>
</h2>

<h3 align="center">
Read Bellingcat's article about this project in 
<a href="https://www.bellingcat.com/news/2022/03/17/hospitals-bombed-and-apartments-destroyed-mapping-incidents-of-civilian-harm-in-ukraine/">English (UK)</a>,
<a href="https://ru.bellingcat.com/novosti/2022/03/18/hospitals-bombed-and-apartments-destroyed-mapping-incidents-of-civilian-harm-in-ukraine-ru/">Русский (Россия)</a>
</h3>

<p align="center">
<strong>
	TimeMap is a tool for exploration, monitoring and classification of incidents in time and space, originally forked from <a href="https://github.com/forensic-architecture/timemap">forensic-architecture/timemap</a>.
</strong>
</p>
<br>
<br>

![ukraine.bellingcat.com timemap preview](docs/example-timemap.png)

## Development
* `npm install` to setup
* adjust any local configs in [config.js](config.js)
* `CONFIG=config.js npm run dev` or `npm run dev` if the file is named config.js
* For more info visit the [original repo](https://github.com/forensic-architecture/timemap)


## Deployment
This project is now living in github pages and the API has switched to auto-updated S3 files.
Access it at https://bellingcat-embeds.ams3.cdn.digitaloceanspaces.com/production/ukr/timemap/api.json

Release with `npm run deploy`. 

## Contributing
Please read our [Contribution Guide](./CONTRIBUTING.md) and check our [Issues Page](https://github.com/bellingcat/ukraine-timemap/issues) for desired contributions, and feel free to suggest your own. 

## Configurations

<details>
<summary>Documentation of <a href="config.js">config.js</a> </summary>

* `SERVER_ROOT` - points to the API base address
* `XXXX_EXT` - points to the respective JSONs of the data, for events, sources, and associations
* `API_DATA` - S3 file address that can be downloaded or integrated into external apps/visualizations
* `DATE_FMT` and `TIME_FMT` - how to consume the events' date/time from the API
* `store.app.map` - configures the initial map view and the UX limits
* `store.app.cluster` - configures how clusters/bubbles are grouped into larger clusters, larger `radius` means bigger cluster bubbles
* `store.app.timeline` - configure timeline ranges, zoom level options, and default range
* `store.app.intro` - the intro panel that shows on start
* `store.app.cover` - configuration for the full page cover, the `description` is a list of markdown entities, can also contain html
* `store.ui.colors` and `store.ui.maxNumOfColors` are applied to filters, as they are selected

Easiest way to deploy the static files is through 
* `nvm use 16`
* `npm run build` (rather: `CI=false npm run build`)
* copy the files to your server, for example to `/var/www/html`

</details>


================================================
FILE: config.js
================================================
const one_day = 1440;

const config = {
  title: "ukraine",
  display_title: "Civilian Harm\nin Ukraine",
  SERVER_ROOT: "https://bellingcat-embeds.ams3.cdn.digitaloceanspaces.com/production/ukr",
  EVENTS_EXT: "/timemap/events.json",
  SOURCES_EXT: "/timemap/sources.json",
  ASSOCIATIONS_EXT: "/timemap/associations.json",
  API_DATA: "https://bellingcat-embeds.ams3.cdn.digitaloceanspaces.com/production/ukr/timemap/api.json",
  // MEDIA_EXT: "/api/media",
  DATE_FMT: "M/D/YYYY",
  TIME_FMT: "HH:mm",

  store: {
    app: {
      debug: true,
      map: {
        // anchor: [49.02421913, 31.43836003],
        anchor: [48.3326259, 33.19951447],
        maxZoom: 18,
        minZoom: 4,
        startZoom: 6,
        // maxBounds: []
      },
      cluster: { radius: 50, minZoom: 5, maxZoom: 12 },
      associations: {
        defaultCategory: "Weapon System",
      },
      timeline: {
        dimensions: {
          height: 90,
          contentHeight: 90,
        },
        zoomLevels: [
          // { label: "Zoom to 2 weeks", duration: 14 * one_day },
          // { label: "Zoom to 1 month", duration: 31 * one_day },
          // { label: "Zoom to 6 months", duration: 6 * 31 * one_day },
          { label: "Zoom to 1 year", duration: 12 * 31 * one_day },
          { label: "Zoom to 2 years", duration: 2 * 12 * 31 * one_day },
          { label: "Zoom to 5 years", duration: 5 * 12 * 31 * one_day },
        ],
        range: {
          /**
           * Initial date range shown on map load.
           * Use [start, end] (strings in ISO 8601 format) for a fixed range.
           * Use undefined for a dynamic initial range based on the browser time.
           */
          initial: ["2022-02-01T00:00:00.000Z", "2025-08-31T23:59:59.999Z"],
          /** The number of days to show when using a dynamic initial range */
          initialDaysShown: 31*12,
          limits: {
            /** Required. The lower bound of the range that can be accessed on the map. (ISO 8601) */
            lower: "2022-02-01T00:00:00.000Z",
            /**
             * The upper bound of the range that can be accessed on the map.
             * Defaults to current browser time if undefined.
             */
            upper: "2025-08-14T23:59:59.999Z",
          },
        },
      },
      intro: [
        '<div class="two-columns"><div class="two-columns_column"><figure><img style="width: 100%; display:block;" src="https://bellingcat-embeds.ams3.cdn.digitaloceanspaces.com/ukraine-timemap/cover01-s.jpg" frameborder="0"><figcaption>Image: Vyacheslav Madiyevskyy/Reuters</figcaption></figure></div><div class="two-columns_column"><figure><img style="width: 100%; display:block;" src="https://bellingcat-embeds.ams3.cdn.digitaloceanspaces.com/ukraine-timemap/cover02-s.jpg" frameborder="0"><figcaption>Image: Järva Teataja/Scanpix Baltics via Reuters</figcaption></figure></div></div>',
        'This map plots out and highlights incidents that have resulted in potential civilian impact or harm since Russia began its invasion of Ukraine. The incidents detailed have been collected by Bellingcat researchers. Included in the map are instances where civilian areas and infrastructure have been damaged or destroyed, where the presence of civilian injuries are visible and/or there is the presence of immobile civilian bodies. Collection for the incidences contained in this map began on February 24, 2022. Users can explore incidents by date and location. We intend this to be a living project that will continue to be updated as long as the conflict persists. For more detailed information about the entries included in this map, please refer to our methodology and explainer article which can be read <a href="https://www.bellingcat.com/news/2022/03/17/hospitals-bombed-and-apartments-destroyed-mapping-incidents-of-civilian-harm-in-ukraine/" >here</a>.',
        '<p><b>Editor\'s note</b>: An error in our archiving system between October 21 and November 7 led to some incidents being published on our TimeMap before they were fully verified. We have fixed this issue and are working to verify all extra incidents.</p>',
      ],

      flags: { isInfopoup: false, isCover: false },
      cover: {
        title: "About and Methodology",
        exploreButton: "BACK TO THE PLATFORM",
        description: [
          "## Scope of Research",
          "This database, organised on Forensic Architecture's [TimeMap](https://github.com/forensic-architecture/timemap) platform and customised for this project, is focused on incidents in Ukraine that have resulted in potential civilian harm. These include: incidents where rockets or missiles struck civilian areas, where attacks have resulted in the destruction of civilian infrastructure, where the presence of civilian injuries are visible and/or the presence of immobile civilian bodies. This database began collection on February 24, 2022 and intends to be a living document that will continue to be updated as long as the conflict persists. While we are attempting to collect as many incidents as possible, we cannot possibly guarantee to collect them all nor will we be able to corroborate the locations of all the incidents we collect. Those we do not corroborate the originality or exact location of will not be shown on the map. Therefore, this map is not an exhaustive list of civilian harm in Ukraine but rather a representation of all incidents which we have been able to collect and of which we have been able to determine the exact locations. ",
          "## Open Source Footage",
          "The links in this map are all open source, meaning they are connected to an open link posted online. These sources were collected by Bellingcat researchers and placed in a database from where they are also being archived locally. After collection, our Global Authentication Project members have determined the location of each of these events <small>(you can read more about the Global Authentication Project and its makeup below)</small>. Bellingcat staff then cross-referenced these coordinates to ensure their accuracy. The resolution of these geolocations is within 150 metres of where the incident occurred but the public coordinates viewable on the map have been slightly obscured in order to protect the identity of the creators. Because this footage is open source, the users who uploaded the content are not directly affiliated to Bellingcat or our partners. Any opinions that may be contained within the posts are therefore not those of Bellingcat or our partners. Any claims contained within the posts have also not necessarily been confirmed or verified by Bellingcat, particularly in relation to which party may have been responsible for the incidents detailed.",
          "## Verification Level",
          "The data being collected is checked for originality, basic manipulation, and location by Bellingcat investigators. This level of verification is intended to indicate where incidents took place, when and where there are reasonable visual indications of civilian harm. Our investigation plan for the collection of this material and its uses are informed by the [Berkeley Protocol on Digital Open Source Investigations](https://www.ohchr.org/en/publications/policy-and-methodological-publications/berkeley-protocol-digital-open-source). These incidents are also being collected and archived at a [forensic level](https://mnemonic.org/en/our-work) for potential evidentiary use in the future. That level of in-depth analysis and verification will take many months and our goal with this map is to transparently report on the current situation in Ukraine, as it is happening, for public interest. To be clear, these two processes will be separate.",
          "## Descriptions",
          "Each incident is accompanied with source links, the exact location determined by our Global Authentication Project and Bellingcat researchers, as well as a brief description of the incident based on what is visually present. The descriptions indicate what is clearly visible but do not attempt to make assumptions about the exact number of casualties or which party to the conflict is responsible due to those factors being difficult to fully determine from short, visual imagery alone.",
          "## Filters",
          "On the left hand side of the map, a user can toggle between different kinds of areas impacted. We are characterising the areas as residential, industrial, administrative, healthcare, school/childcare, military, commercial, religious, or undefined. Decisions on these classifications are  based on  visual evidence in the footage and what the area is reportedly used as. We cannot fully exclude or exhaustively search for the potential of military use in some of these areas.",
          "## Source Links/Embedding",
          "We have chosen to embed the social media links directly onto the platform.  Should any be deleted by the uploader, they will still be visible on the map, but data on the post, user and footage will no longer be presented publicly. Where sensitive footage posted by individuals might allow them or their location to be identified, we have sought to preemptively take steps to anonymise these users.",
          "## Privacy concerns and respect for the dead ",
          "This footage is graphic and contains distressing scenes of war and conflict. Many of the areas represented are, at time of writing, also under attack both physically and through online attempts to discredit or harm users posting this content. For these reasons, we have chosen not to share certain posts that might indicate the direct identity of any of the persons filming. We have also filtered out posts that contain images where an immobile body is closely filmed and their identity might be ascertained out of respect for them and their close ones.",
          "## A Note on Bellingcat's Global Authentication Project",
          "The Global Authentication Project consists of a wide community of open source researchers assisting in Bellingcat research through structured tasks and feedback. Our aim is to authenticate events taking place around the world and fill in the gaps of knowledge that exist, particularly in situations where there are vast quantities of data. In creating a community for those interested in open source research, we are fostering Bellingcat's original aim of solving problems **together**, to diversify our investigations and promote the use of these skills. For this dataset, we are working with many individuals who have Ukrainian language skills and others with local contextual knowledge of the events and places seen on the map. Other participants include individuals skilled in geolocation and chronolocation, with all contributions being vetted by Bellingcat researchers. As we expand the Global Authentication Project in the coming months, more information will be available on our website and Twitter.",
          "## Feedback",
          "This map will continue to change and be updated for the duration of this conflict. We welcome feedback on our methodology,  data collection and take transparency seriously. Should you have any direct feedback about the platform, please indicate it on this [form](https://forms.gle/cV2YAojBoh6h4T3XA).",
        ],
      },
      toolbar: {
        panels: {
          categories: {
            // TRUE: {
            //   icon: "public",
            //   label: "Verified",
            //   description: "todo",
            // },
            // FALSE: {
            //   icon: "public",
            //   label: "Unverified",
            //   description: "todo",
            // }
          },
        },
      },
      spotlights: {},
    },
    ui: {
      coloring: {
        mode: "STATIC",
        maxNumOfColors: 9,
        defaultColor: "#dfdfdf",
        colors: [
          "#7E57C2",
          "#F57C00",
          "#FFEB3B",
          "#D34F73",
          "#08B2E3",
          "#A1887F",
          "#90A4AE",
          "#E57373",
          "#80CBC4",
        ],
      },
      card: {
        layout: {
          template: "sourced",
        },
      },
      carto: {
        eventRadius: 8,
      },
      timeline: {
        eventRadius: 9,
      },
      tiles: {
        current: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}",
        default: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}",
        satellite: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
      },
    },
    features: {
      USE_CATEGORIES: false,
      CATEGORIES_AS_FILTERS: false,
      COLOR_BY_CATEGORY: false,
      COLOR_BY_ASSOCIATION: true,
      USE_ASSOCIATIONS: true,
      USE_FULLSCREEN: true,
      USE_DOWNLOAD: true,
      USE_SOURCES: true,
      USE_SPOTLIGHTS: false,
      USE_SHAPES: false,
      USE_COVER: true,
      USE_INTRO: false,
      USE_SATELLITE_OVERLAY_TOGGLE: true,
      USE_SEARCH: false,
      USE_SITES: false,
      ZOOM_TO_TIMEFRAME_ON_TIMELINE_CLICK: one_day,
      FETCH_EXTERNAL_MEDIA: false,
      USE_MEDIA_CACHE: false,
      GRAPH_NONLOCATED: false,
      NARRATIVE_STEP_STYLES: false,
      CUSTOM_EVENT_FIELDS: [],
    },
  },
};

export default config;


================================================
FILE: docs/configuration.md
================================================
# Configuration

**NOTE: WIP. These settings are currently slightly out of date.**

In order to make timemap interesting, you need to configure it to read events. When loaded in a browser, timemap queries HTTP endpoint, expecting from them well-defined JSON objects. There are certain endpoints, such as `events`, that are required, while others , such as `filters`, are optional; when provided, they enhance a timemap instance with additional features and capabilities related to the additional data.

The URLs for these endpoints, as well as other configurable settings in your timemap instance, are read from the `config.js` that you created in step 3 of the setup above. The example contains sensible defaults. This section covers each option in more detail: 

| Option  | Description | Type | Nullable |
| ------- | ----------- | ---- | -------- |
| title | Title of the application, display in the toolbar | String | No |
| SERVER_ROOT | Base URI for the server | String | No |
| EVENT_EXT | Endpoint for events, which will be concatenated with SERVER_ROOT | String | No |
| EVENT_DESC_ROOT | Endpoint for additional metadata for each individual event, concatenated to SERVER_ROOT | String | Yes |
| CATEGORY_EXT | Endpoint for categories, concatenated with SERVER_ROOT | String | Yes |
| NARRATIVE_EXT | Endpoint for narratives, concatenated with SERVER_ROOT | String | No |
| FILTER_TREE_EXT | Endpoint for filters, concatenated with SERVER_ROOT | String | Yes |
| SITES_EXT | Endpoint for sites, concatenated with SERVER_ROOT | String | Yes |
| MAP_ANCHOR | Geographic coordinates for original map anchor | Array of numbers | No |
| features.USE_ASSOCIATIONS | Enable / Disable filters | boolean | No |
| features.USE_SITES | Enable / Disable sites | boolean | No |

In this configuration file you'll need to replace the required endpoints with functioning ones. Additionally, you'll want to initialize your application set in `MAP_ANCHOR`, as a (lat, long) pair, which determines the specific location at which the application will center itself on start.

### Data requirements

This section outlines the data requirements for each HTTP endpoint.

The sum total of data that is fetched asynchronously in a timemap instance is
referred to as the application `domain`. The base endpoint for the domain-- and
the paths to required and optional endpoints-- are configured through
a `config.js` file in timemap's root folder (explained in the next section).

#### Required endpoints

1. **Events**: incidents mapped in time and space are called `events`. They must include the following fields:

```json
[
  {
    "desc":"SOME DESCRIPTION TEXT",
    "date":"8/23/2011",
    "time":"18:30",
    "location":"LOCATION_NAME",
    "lat":"17.810358",
    "long":"-18.2251664",
    "source":"",
    "filters": "",
    "category": ""
  }
]
```


2. **Categories**: events must be grouped in `categories`. **All `events` must contain one (and only one) `category`.** An event's category determines how it is displayed in the both the timeline and the map. (Category styling is configurable, but by default each category has an associated color, and a separate timeline for events in it.) Categories are designed to aggregate incidents according to some kind of categorical distinction, which will differ depending on your dataset. For example, categories may correspond to population groups, actions committed by particular persons. Categories should probably not be coded according to locality or temporality, as these axes are already represented.

```json
[
  {
    "category":"Category 00",
    "category_label":"Category Label",
    "group":"category_group00",
    "group_label":"Events"
  }
]
```

#### Optional endpoints

3. **Filters**: `events` can be filterged by multiple `filters`. These will further characterize the event, and allow to select or deselect based on them. Filters are or can be distributed in a tree-like hierarchy, and each node on the tree can be a filter, including those who are not leafs.

```json
{
   "key":"filters",
   "children": {
      "filter0": {
         "key": "filter0 ",
         "children": {
            "filter00": {
               "key": "filter00",
               "children": {
                 "filter001": {
                    "key": "filter001",
                    "children": {}
                 }
               }
            },
            "filter01": {
               "key": "filter01",
               "children": {}
            }
         }
      },
      "filter1": {
         "key": "filter1",
         "children": {
            "filter10": {
               "key": "filter10",
               "children": {}
            }
         }
      }
   }
}
```

4. **Sites**: sites are labels on the map, aiming to highlight particularly relevant locations that should not be a function of time or filters.

```json
[
  {
    "id":"1",
    "description":"SITE_DESCRIPTION",
    "site":"SITE_LABEL",
    "latitude":"17.810358",
    "longitude":"-18.2251664"
  }
]
```




================================================
FILE: docs/custom-covers.md
================================================
## Using Timemap with a Custom Cover

By default, instances of Timemap use no cover. By setting the `USE_COVER` flag
to true in [config.js][], however, you can use explanatory text and videos that are 
displayed when your instance is first loaded.

The structure you need to specify in `app.cover` in the Redux store overrides
in config.js is outlined below:

```js
const theCover = {
  // The video that plays in the background of the cover. Will otherwise be plain black
  bgVideo: "https://url-for-background-video.mp4",
  // Titles sit at the top of the screen
  title: "My Custom Timemap Instance",
  subtitle: "Mapping Events in my personal open source investigation",
  subsubtitle: "January 2020",
  // The main text on the cover. This string is markdown, so you can include links and styles.
  description: "A brief description of what the platform shows. [Links](https://forensic-architecture.org) can be written in markdown.",
    // Header videos sit above the 'EXPLORE' button, and can include a basic description, as well as translations.
    headerVideos: [
    {
      buttonTitle: "ABOUT",
      desc: "This film details the investigation's methodology and findings at a high level.",
      file: ""https://url-to-video.mp4,
      poster: "https://url-for-thumbnail.png",
      title: "About the Investigation",
      translations: [
        {
          code: "ITA",
          desc: "Italian translation",
          paths: ["https://url-to-video.mp4"],
          title: "Translated Title",
        },
        {
          code: "RUS",
          desc: "Russian translation",
          paths: ["https://url-to-video.mp4"],
          title: "Translated Title",
        }
      ]
    },
    {
      buttonTitle: "HOW TO USE",
      desc: "This step-by-step guide explores the way that the platform arranges and presents information.",
      file: ""https://url-to-video.mp4,
      poster: "https://url-for-thumbnail.png",
      title: "How to Use the Platform"
    }
  ],
  // These videos sit at the bottom of the page, beneath the rest of the
  // content. The max length of the list is 4 for stylistic reasons, any later
  // indices will not be shown.
  videos: [
    {
      buttonTitle: "VERIFICATION:",
      buttonSubtitle: "How we verified data",
      desc: "This video shows how we verified the data.",
      file: "https://url-to-video.mp4",
      poster: "https://url-for-thumbnail.png",
      title: "Verifying data in this investigation"
    },
    {
      buttonTitle: "VERIFICATION:",
      buttonSubtitle: "How we verified data",
      desc: "This video shows how we verified the data.",
      file: "https://url-to-video.mp4",
      poster: "https://url-for-thumbnail.png",
      title: "Verifying data in this investigation"
    },
    {
      buttonTitle: "VERIFICATION:",
      buttonSubtitle: "How we verified data",
      desc: "This video shows how we verified the data.",
      file: "https://url-to-video.mp4",
      poster: "https://url-for-thumbnail.png",
      title: "Verifying data in this investigation"
    },
    {
      buttonTitle: "VERIFICATION:",
      buttonSubtitle: "How we verified data",
      desc: "This video shows how we verified the data.",
      file: "https://url-to-video.mp4",
      poster: "https://url-for-thumbnail.png",
      title: "Verifying data in this investigation"
    }
  ]
}

module.exports = {
  // ... other values in config.js
  store: {
    app: {
      cover: theCover,
      // ...
    }
    // ... 
  }
}
```


================================================
FILE: example.config.js
================================================
const config = {
  title: 'example',
  display_title: 'example',
  SERVER_ROOT: 'http://localhost:4040',
  EVENTS_EXT: '/api/timemap_data/export_events/deeprows',
  ASSOCIATIONS_EXT: '/api/timemap_data/export_associations/deeprows',
  SOURCES_EXT: '/api/timemap_data/export_sources/deepids',
  SITES_EXT: '',
  SHAPES_EXT: '',
  DATE_FMT: 'MM/DD/YYYY',
  TIME_FMT: 'hh:mm',
  store: {
    app: {
      map: {
        anchor: [31.356397, 34.784818]
      }
    },
    features: {
      COLOR_BY_ASSOCIATION: true,
      USE_ASSOCIATIONS: true,
      USE_SOURCES: true,
      USE_COVER: false,
      GRAPH_NONLOCATED: false,
      HIGHLIGHT_GROUPS: false
    }
  }
}

export default config;


================================================
FILE: index.html
================================================
<!doctype html>
<html lang="en">

<head>
  <meta charset="utf-8">
  <title>Civilian Harm in Ukraine Timemap - Bellingcat</title>
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">

  <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <script defer data-domain="ukraine.bellingcat.com" src="https://plausible.io/js/script.js"></script>
  <!-- Styles are always go to the head, never the body -->
  <style>
    @media (hover: none) {
      #id {
        display: none;
      }

      #nodisplay {
        display: block;
      }
    }

    @media (hover: hover) {
      #nodisplay {
        display: none;
      }
    }
  </style>
</head>

<body>
  <div class="page">
    <div id="explore-app"></div>
    <div id="nodisplay">
      If you see this message wait up to 30s, otherwise please revisit on a desktop device.
    </div>
  </div>
  <script type="module" src="/src/index.jsx"></script>
</body>

</html>


================================================
FILE: package.json
================================================
{
  "name": "timemap",
  "version": "0.1.0",
  "description": "",
  "homepage": "https://bellingcat.github.io/ukraine-timemap",
  "private": true,
  "engines": {
    "node": "18"
  },
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "dev:wsl": "vite --host",
    "test": "vitest",
    "eslint": "eslint src --ext jsx",
    "lint": "prettier --check \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"",
    "lint:fix": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"",
    "predeploy": "vite build",
    "deploy": "gh-pages -d build"
  },
  "dependencies": {
    "@json2csv/plainjs": "^6.1.2",
    "d3": "^7.4.2",
    "dayjs": "^1.11.0",
    "joi": "^17.1.1",
    "leaflet": "^1.0.3",
    "marked": "^4.2.5",
    "object-hash": "^3.0.0",
    "ramda": "^0.28.0",
    "react": "^18.0.0",
    "react-device-detect": "^2.2.2",
    "react-dom": "^18.0.0",
    "react-image": "^4.0.3",
    "react-redux": "^8.0.5",
    "react-tabs": "^6.0.0",
    "redux": "^4.0.0",
    "redux-thunk": "^2.2.0",
    "reselect": "^4.1.7",
    "screenfull": "^6.0.2",
    "scriptjs": "^2.5.9",
    "supercluster": "^7.1.5",
    "video-react": "^0.16.0"
  },
  "devDependencies": {
    "@testing-library/jest-dom": "^5.11.6",
    "@testing-library/react": "^13.4.0",
    "@vitejs/plugin-react": "^3.0.1",
    "eslint": "^8.31.0",
    "eslint-config-prettier": "^8.6.0",
    "eslint-plugin-react": "^7.32.0",
    "eslint-plugin-react-hooks": "^4.6.0",
    "gh-pages": "^6.0.0",
    "husky": "^8.0.3",
    "jest-date-mock": "^1.0.8",
    "lint-staged": "^13.1.0",
    "prettier": "^2.2.1",
    "sass": "^1.57.1",
    "vite": "^4.0.4",
    "vitest": "^0.27.1"
  },
  "lint-staged": {
    "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}": [
      "prettier --write"
    ]
  },
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  }
}


================================================
FILE: public/CNAME
================================================
ukraine.bellingcat.com

================================================
FILE: public/index.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8" />
    <title>Civilian Harm in Ukraine</title>
    <meta name="Description" CONTENT="This map plots out and highlights incidents that have resulted in potential civilian impact or harm since Russia began its invasion of Ukraine. The incidents detailed have been collected by Bellingcat researchers. Included in the map are instances where civilian areas and infrastructure have been damaged or destroyed, where the presence of civilian injuries are visible and/or there is the presence of immobile civilian bodies. Collection for the incidences contained in this map began on February 24, 2022. Users can explore incidents by date and location. We intend this to be a living project that will continue to be updated as long as the conflict persists. For more detailed information about the entries included in this map, please refer to our methodology and explainer article.">
    <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
    <script defer data-domain="ukraine.bellingcat.com" src="https://plausible.io/js/script.js"></script>
    <meta name="viewport" content="width=device-width, initial-scale=1">
</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">
            If you see this message wait up to 30s, otherwise please revisit on a desktop device.
        </div>
    </div>
</body>

</html>

================================================
FILE: src/actions/index.js
================================================
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 REGIONS_URL = urlFromEnv("REGIONS_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.`;

export function fetchDomain() {
  const notifications = [];

  function handleError(message) {
    notifications.push({
      message,
      type: "error",
    });
    return [];
  }

  return (dispatch, getState) => {
    const features = getState().features;
    dispatch(toggleFetchingDomain());

    // let configPromise = Promise.resolve([])
    // if (features.USE_REMOTE_CONFIG) {
    //   configPromise = fetch(CONFIG_URL)
    //     .then(response => response.json())
    //     .catch(() => handleError("Couldn't find data at the config URL you specified."))
    // }

    // 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"))
      )
    ).then((results) => results.flatMap((t) => t));

    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"
          )
        );
      } else {
        associationsPromise = fetch(ASSOCIATIONS_URL)
          .then((response) => response.json())
          .catch(() => handleError(domainMsg("associations")));
      }
    }

    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"
          )
        );
      } else {
        sourcesPromise = fetch(SOURCES_URL)
          .then((response) => response.json())
          .catch(() => handleError(domainMsg("sources")));
      }
    }

    let sitesPromise = Promise.resolve([]);
    if (features.USE_SITES) {
      sitesPromise = fetch(SITES_URL)
        .then((response) => response.json())
        .catch(() => handleError(domainMsg("sites")));
    }

    let regionsPromise = Promise.resolve([]);
    if (features.USE_REGIONS) {
      regionsPromise = fetch(REGIONS_URL)
        .then((response) => response.json())
        .catch(() => handleError(domainMsg("regions")));
    }

    let shapesPromise = Promise.resolve([]);
    if (features.USE_SHAPES) {
      shapesPromise = fetch(SHAPES_URL)
        .then((response) => response.json())
        .catch(() => handleError(domainMsg("shapes")));
    }

    return Promise.all([
      eventPromise,
      associationsPromise,
      sourcesPromise,
      sitesPromise,
      regionsPromise,
      shapesPromise,
    ])
      .then((response) => {
        const result = {
          events: response[0],
          associations: response[1],
          sources: response[2],
          sites: response[3],
          regions: response[4],
          shapes: response[5],
          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"
          );
        }
        dispatch(toggleFetchingDomain());
        dispatch(setInitialCategories(result.associations));
        dispatch(setInitialShapes(result.shapes));
        return result;
      })
      .catch((err) => {
        dispatch(fetchError(err.message));
        dispatch(toggleFetchingDomain());
        // TODO: handle this appropriately in React hierarchy
        alert(err.message);
      });
  };
}

export const FETCH_ERROR = "FETCH_ERROR";
export function fetchError(message) {
  return {
    type: FETCH_ERROR,
    message,
  };
}

export const UPDATE_DOMAIN = "UPDATE_DOMAIN";
export function updateDomain(payload) {
  return {
    type: UPDATE_DOMAIN,
    payload,
  };
}

export function fetchSource(source) {
  return (dispatch) => {
    if (!SOURCES_URL) {
      dispatch(fetchSourceError("No source extension specified."));
    } else {
      dispatch(toggleFetchingSources());

      fetch(`${SOURCES_URL}`)
        .then((response) => {
          if (!response.ok) {
            throw new Error(
              "No sources are available at the URL specified in the config specified."
            );
          } else {
            return response.json();
          }
        })
        .catch((err) => {
          dispatch(fetchSourceError(err.message));
          dispatch(toggleFetchingSources());
        });
    }
  };
}

export const UPDATE_HIGHLIGHTED = "UPDATE_HIGHLIGHTED";
export function updateHighlighted(highlighted) {
  return {
    type: UPDATE_HIGHLIGHTED,
    highlighted: highlighted,
  };
}

export const UPDATE_SELECTED = "UPDATE_SELECTED";
export function updateSelected(selected) {
  return {
    type: UPDATE_SELECTED,
    selected: selected,
  };
}

export const UPDATE_DISTRICT = "UPDATE_DISTRICT";
export function updateDistrict(district) {
  return {
    type: UPDATE_DISTRICT,
    district,
  };
}

export const CLEAR_FILTER = "CLEAR_FILTER";
export function clearFilter(filter) {
  return {
    type: CLEAR_FILTER,
    filter,
  };
}

export const TOGGLE_ASSOCIATIONS = "TOGGLE_ASSOCIATIONS";
export function toggleAssociations(association, value, shouldColor) {
  return {
    type: TOGGLE_ASSOCIATIONS,
    association,
    value,
    shouldColor,
  };
}

export const TOGGLE_SHAPES = "TOGGLE_SHAPES";
export function toggleShapes(shape) {
  return {
    type: TOGGLE_SHAPES,
    shape,
  };
}

export const SET_LOADING = "SET_LOADING";
export function setLoading() {
  return {
    type: SET_LOADING,
  };
}

export const SET_NOT_LOADING = "SET_NOT_LOADING";
export function setNotLoading() {
  return {
    type: SET_NOT_LOADING,
  };
}

export const SET_INITIAL_CATEGORIES = "SET_INITIAL_CATEGORIES";
export function setInitialCategories(values) {
  return {
    type: SET_INITIAL_CATEGORIES,
    values,
  };
}

export const SET_INITIAL_SHAPES = "SET_INITIAL_SHAPES";
export function setInitialShapes(values) {
  return {
    type: SET_INITIAL_SHAPES,
    values,
  };
}

export const UPDATE_TIMERANGE = "UPDATE_TIMERANGE";
export function updateTimeRange(timerange) {
  return {
    type: UPDATE_TIMERANGE,
    timerange,
  };
}

export const UPDATE_DIMENSIONS = "UPDATE_DIMENSIONS";
export function updateDimensions(dims) {
  return {
    type: UPDATE_DIMENSIONS,
    dims,
  };
}

export const UPDATE_NARRATIVE = "UPDATE_NARRATIVE";
export function updateNarrative(narrative) {
  return {
    type: UPDATE_NARRATIVE,
    narrative,
  };
}

export const UPDATE_NARRATIVE_STEP_IDX = "UPDATE_NARRATIVE_STEP_IDX";
export function updateNarrativeStepIdx(idx) {
  return {
    type: UPDATE_NARRATIVE_STEP_IDX,
    idx,
  };
}

export const UPDATE_SOURCE = "UPDATE_SOURCE";
export function updateSource(source) {
  return {
    type: UPDATE_SOURCE,
    source,
  };
}

export const UPDATE_COLORING_SET = "UPDATE_COLORING_SET";
export function updateColoringSet(coloringSet) {
  return {
    type: UPDATE_COLORING_SET,
    coloringSet,
  };
}

export const UPDATE_TICKS = "UPDATE_TICKS";
export function updateTicks(ticks) {
  return {
    type: UPDATE_TICKS,
    ticks,
  };
}

// UI

export const TOGGLE_SITES = "TOGGLE_SITES";
export function toggleSites() {
  return {
    type: TOGGLE_SITES,
  };
}

export const TOGGLE_FETCHING_DOMAIN = "TOGGLE_FETCHING_DOMAIN";
export function toggleFetchingDomain() {
  return {
    type: TOGGLE_FETCHING_DOMAIN,
  };
}

export const TOGGLE_FETCHING_SOURCES = "TOGGLE_FETCHING_SOURCES";
export function toggleFetchingSources() {
  return {
    type: TOGGLE_FETCHING_SOURCES,
  };
}

export const TOGGLE_LANGUAGE = "TOGGLE_LANGUAGE";
export function toggleLanguage(language) {
  return {
    type: TOGGLE_LANGUAGE,
    language,
  };
}

export const CLOSE_TOOLBAR = "CLOSE_TOOLBAR";
export function closeToolbar() {
  return {
    type: CLOSE_TOOLBAR,
  };
}

export const TOGGLE_INFOPOPUP = "TOGGLE_INFOPOPUP";
export function toggleInfoPopup() {
  return {
    type: TOGGLE_INFOPOPUP,
  };
}

export const TOGGLE_INTROPOPUP = "TOGGLE_INTROPOPUP";
export function toggleIntroPopup() {
  return {
    type: TOGGLE_INTROPOPUP,
  };
}

export const TOGGLE_NOTIFICATIONS = "TOGGLE_NOTIFICATIONS";
export function toggleNotifications() {
  return {
    type: TOGGLE_NOTIFICATIONS,
  };
}

export const MARK_NOTIFICATIONS_READ = "MARK_NOTIFICATIONS_READ";
export function markNotificationsRead() {
  return {
    type: MARK_NOTIFICATIONS_READ,
  };
}

export const TOGGLE_COVER = "TOGGLE_COVER";
export function toggleCover() {
  return {
    type: TOGGLE_COVER,
  };
}

export const TOGGLE_TILE_OVERLAY = "TOGGLE_TILE_OVERLAY";
export function toggleTileOverlay() {
  return {
    type: TOGGLE_TILE_OVERLAY,
  };
}

export const UPDATE_SEARCH_QUERY = "UPDATE_SEARCH_QUERY";
export function updateSearchQuery(searchQuery) {
  return {
    type: UPDATE_SEARCH_QUERY,
    searchQuery,
  };
}

// ERRORS

export const FETCH_SOURCE_ERROR = "FETCH_SOURCE_ERROR";
export function fetchSourceError(msg) {
  return {
    type: FETCH_SOURCE_ERROR,
    msg,
  };
}

export const TOGGLE_SATELLITE_VIEW = "TOGGLE_SATELLITE_VIEW";
export function toggleSatelliteView() {
  return {
    type: TOGGLE_SATELLITE_VIEW,
  };
}

export const REHYDRATE_STATE = "REHYDRATE_STATE";
export function rehydrateState() {
  return {
    type: REHYDRATE_STATE,
  };
}

export const UPDATE_MAP_VIEW = "UPDATE_MAP_VIEW";
export function updateMapView(lat, lng, zoom) {
  return {
    type: UPDATE_MAP_VIEW,
    lat,
    lng,
    zoom,
  };
}


================================================
FILE: src/common/constants.js
================================================
export const ASSOCIATION_MODES = {
  CATEGORY: "CATEGORY",
  NARRATIVE: "NARRATIVE",
  FILTER: "FILTER",
};

export const SHAPE = "SHAPE";

export const DEFAULT_TAB_ICONS = {
  CATEGORY: "widgets",
  NARRATIVE: "timeline",
  FILTER: "filter_list",
  SHAPE: "change_history",
  DOWNLOAD: "download",
};

export const AVAILABLE_SHAPES = {
  STAR: "STAR",
  DIAMOND: "DIAMOND",
  PENTAGON: "PENTAGON",
  SQUARE: "SQUARE",
  DOT: "DOT",
  BAR: "BAR",
  TRIANGLE: "TRIANGLE",
};

export const POLYGON_CLIP_PATH = {
  STAR: "polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)",
  DIAMOND: "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)",
  PENTAGON: "polygon(50% 0%, 100% 38%, 82% 100%, 18% 100%, 0% 38%)",
  TRIANGLE: "polygon(50% 0%, 0% 100%, 100% 100%)",
};

export const DEFAULT_CHECKBOX_COLOR = "#ffffff";


================================================
FILE: src/common/data/copy.json
================================================
{
  "es-MX": {
    "tiles": {
      "default": "Mapa",
      "satellite": "Sat"
    },
    "loading": "Cargando...",
    "legend": {
      "view2d": {
        "paragraphs": [
          "Seleccionando una serie de filtros verá aparecer eventos en el mapa y en la línea del tiempo.",
          "Cada evento estará coloreado según la persona que dio el testimonio del evento."
        ],
        "colors": [
          {
            "class": "category_group00",
            "label": "Categoría Grupo 00"
          },
          {
            "class": "category_group01",
            "label": "Categoría Grupo 01"
          },
          {
            "class": "category_group02",
            "label": "Categoría Grupo 02"
          },
          {
            "class": "category_group03",
            "label": "Categoría Grupo 03"
          },
          {
            "class": "other",
            "label": "Otras categorías"
          }
        ]
      },
      "default": {
        "header": "Ayudas para explorar la plataforma",
        "intro": [
          "Cada **punto** representa un **evento en los datos** (o cada incidente). Al hacer clic en cada punto se ven los detalles del evento. Pero si le da clic en un **grupo** de puntos, verá cuantos eventos hay en ese grupo.",
          "Puede acercarse en el mapa *(zoom)* haciendo *scroll* con el ratón o haciendo clic en un grupo de puntos.",
          "Puede usar **filtros** para segmentar los datos. En el mapa sólo vemos los puntos relacionados con cada filtro seleccionado. Cuando no hay filtros seleccionados, vemos todos los puntos de la base de datos en el mapa.",
          "Al seleccionar más de un filtro se introducen diferentes colores para diferenciarlos. Esto permite comparar los tipos de incidentes tanto en el mapa, como en la línea de tiempo. Esto sirve con un máximo de 6 filtros-colores.",
          "Con el teclado puede usar las flechas de la derecha e izquierda para moverse entre eventos. También puede hacer clic y arrastrar la línea de tiempo hacia los lados para modificar el rango de tiempo."
        ],
        "notation": "Cuando un circulo combina colores significa que hay varios eventos en esa misma ubicación.",
        "arrows": "Usar las flechas izquierda/derecha en el teclado para moverse entre eventos cronológicamente."
      }
    },
    "toolbar": {
      "title": "Título",
      "filters": "Filtros",
      "explore_by_filter__title": "Explorar por filtros",
      "explore_by_filter__description": "Al seleccionar filtros, puede ver los eventos que tienen esa categoria. Para ver todos los eventos puede quitar todas las selecciones (o seleccionarlos todos).",
      "panels": {
        "mentions": {
          "title": "Personas",
          "overview": "Seleccionar los nombres de personas mostrará eventos en los que esta persona o organización ha sido mencionada, incluyendo el propio testimonio. Entre paréntesis encontrará el número de menciones. Ej. (34)."
        },
        "categories": {
          "title": "Testimonios",
          "overview": "Seleccionar el nombre de una persona mostrará los eventos descritos por su testimonio. Entre paréntesis encontrará el número de eventos descritos. Ej. (34)."
        },
        "search": {
          "title": "Directorio de etiquetas",
          "placeholder": "Búsqueda"
        }
      }
    },
    "timeline": {
      "zoomLevels": [
        {
          "label": "20 años",
          "duration": 10512000
        },
        {
          "label": "2 años",
          "duration": 1051200
        },
        {
          "label": "3 meses",
          "duration": 129600
        },
        {
          "label": "3 días",
          "duration": 4320
        },
        {
          "label": "12 horas",
          "duration": 720
        },
        {
          "label": "1 hora",
          "duration": 60
        }
      ],
      "labels_title": "Testimonios",
      "labels": [
        "Testimonio Grupo 00",
        "Testimonio Grupo 01",
        "Testimonio Grupo 02",
        "Testimonio Grupo 03",
        "Otras categorias"
      ],
      "info": "%n eventos ocurridos entre",
      "default_categories_label": "Eventos"
    },
    "cardstack": {
      "date_title": "Fecha incidente",
      "location_title": "Ubicación",
      "summary_title": "Resumen",
      "header": "eventos seleccionados",
      "unknown_location": "Ubicación desconocida",
      "unknown_time": "Día y hora desconocida",
      "timestamp": "Día y hora",
      "estimated": "aproximado",
      "location": "Ubicación",
      "incident_type": "Tipo de acción",
      "description": "Hechos",
      "people": "Personas en el evento",
      "sources": "Fuentes",
      "category": "Según el testimonio de",
      "communication": "Comunicación",
      "transmitter": "Transmisor",
      "receiver": "Receptor",
      "warning": "(!) HECHOS CUESTIONADOS"
    }
  },
  "en-US": {
    "tiles": {
      "default": "Map",
      "satellite": "Sat"
    },
    "loading": "Loading...",
    "legend": {
      "view2d": {
        "paragraphs": [
          "Selecting a series of filters, you will be able to explore events on the map of Iguala and on the timeline.",
          "Each event is colored according the person that gave category of the event."
        ],
        "colors": [
          {
            "class": "category_group00",
            "label": "Category Group 00"
          },
          {
            "class": "category_group01",
            "label": "Category Group 01"
          },
          {
            "class": "category_group02",
            "label": "Category Group 02"
          },
          {
            "class": "category_group03",
            "label": "Category Group 03"
          },
          {
            "class": "other",
            "label": "Other categories"
          }
        ]
      },
      "default": {
        "header": "Navigating the Platform",
        "intro": [
          "Each small **dot** represents a **datapoint**, or incident. Click on a dot to see details. Hover over a larger ‘**cluster**’ dot to see how many events it represents.",
          "Zoom in either with a mouse-scroll or by clicking a ‘cluster’ dot.",
          "Use **filters** and **categories** to segment the data. Selecting certain filters and categories will show only the datapoints that relate to them. If no filters or categories are selected, all the datapoints are displayed.",
          "Selecting more than one filter will introduce colour-coded datapoints, which allow you to compare types of incident across time and space. This feature works up to a maximum of six filters.",
          "Once you have clicked on an event, use the left and right arrows to move back and forward day by day. You can also click and drag anywhere on the timeline. Use the handles on the right to select a date range."
        ],
        "notation": "Combinations of colours within a circle indicate multiple events in a single location.",
        "arrows": "Use the left/right arrows on the keboard to move back and forth between events in time."
      }
    },
    "toolbar": {
      "title": "TITLE",
      "panels": {
        "mentions": {
          "title": "Mentions",
          "overview": "Selecting the names of people/organisation will show events in which these have been mentioned in their own testimony and by others. The number in the parentheses shows how many events contain a mention of a person or organisation, e.g. (34)"
        },
        "categories": {
          "title": "Testimonies",
          "overview": "Selecting the name of a person will show the events only according to a person’s category or category. The number in the parentheses show how many events are contained in each category, e.g. (34)."
        },
        "search": {
          "title": "Directory of filters",
          "placeholder": "Search"
        }
      },
      "narratives": "Narratives",
      "narratives_label": "Narratives",
      "explore_by_narrative__title": "Explore events by narrative",
      "explore_by_narrative__description": "Follow a path through the data, from one key event to the next.",
      "filters": "Filters",
      "filters_label": "Filters",
      "explore_by_filter__title": "Explore by filter",
      "explore_by_filter__description": "'Filters' refer to the types of incident. Select multiple filters to introduce colour-coding, up to a maximum of four filters.<br><br><span class='hint'>If no filters are selected, all datapoints are displayed.</span>",
      "categories": "Categories",
      "categories_label": "Categories",
      "explore_by_category__title": "Explore events by category",
      "explore_by_category__description": "",
      "shapes": "Shapes",
      "shapes_label": "Shapes",
      "explore_by_shapes__title": "Explore events by shape breakdown",
      "explore_by_shape__description": "Shapes map to a given type of event that appears on the timeline.<br><br>Select the shape marker to toggle this type of event on / off",
      "fullscreen_enter": "Fullscreen",
      "fullscreen_exit": "Exit Fullscreen",
      "download": {
        "button": "Download",
        "panel": {
          "title": "Download events",
          "description": "Export the most recent available events in different formats.",
          "formats": {
            "api": {
              "label": "API",
              "description": "An API endpoint where you can always fetch the entire dataset in JSON format with tools like curl. Useful for integrating the data in other services and visualizaitons."
            },
            "csv": {
              "label": "CSV",
              "description": "CSV file where sources and filters are concatenated into a single column each due to data structure limitations."
            },
            "json": {
              "label": "JSON",
              "description": "JSON file where each event is a structured object containing nested arrays of sources and filters."
            }
          }
        }
      }
    },
    "timeline": {
      "labels_title": "Testimonies",
      "labels": [
        "Testimony Group 00",
        "Testimony Group 01",
        "Testimony Group 02",
        "Testimony Group 03",
        "Other"
      ],
      "info": "Showing <span>%n events</span> that occurred between",
      "reset": "reset dates",
      "default_categories_label": ""
    },
    "cardstack": {
      "header": "selected events",
      "timestamp": "Day and time",
      "unknown_location": "Unknown location",
      "estimated": "estimated",
      "unknown_time": "Unknown time",
      "location": "Localization",
      "incident_type": "Type of action",
      "description": "Summary",
      "filters": "Filters",
      "nofilters": "No known filters for this event.",
      "sources": "Sources",
      "unknown_source": "The information for this source could not be retrieved.",
      "category": "Category",
      "communication": "Communication",
      "transmitter": "Transmitter",
      "receiver": "Receiver",
      "warning": "(!) Highly questioned"
    }
  }
}


================================================
FILE: src/common/data/es-MX.json
================================================
{
  "dateTime": "%x, %X",
  "date": "%d/%m/%Y",
  "time": "%-I:%M:%S %p",
  "periods": ["AM", "PM"],
  "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"
  ]
}


================================================
FILE: src/common/global.js
================================================
export const colors = {
  fa_red: "#eb443e",
  yellow: "#ffd800",
  black: "#000",
  white: "#fff",
};

const exports = {
  fallbackEventColor: colors.fa_red,
  darkBackground: colors.black,
  primaryHighlight: colors.fa_red,
  secondaryHighlight: colors.white,
};

export default exports;


================================================
FILE: src/common/utilities.js
================================================
import config from "../../config";
import customParseFormat from "dayjs/plugin/customParseFormat";
import dayjs from "dayjs";
import hash from "object-hash";
import { timeFormatDefaultLocale } from "d3";
import esMxData from "./data/es-MX.json";

import { ASSOCIATION_MODES, POLYGON_CLIP_PATH } from "./constants";

dayjs.extend(customParseFormat);

const DATE_FMT = config.DATE_FMT ?? "MM/DD/YYYY";
const TIME_FMT = config.TIME_FMT ?? "HH:mm";

export const language = config.store.app.language || "en-US";

export function getPathLeaf(path) {
  const splitPath = path.split("/");
  return splitPath[splitPath.length - 1];
}

export function calcDatetime(date, time) {
  if (!time) time = "00:00";
  const dt = dayjs(`${date} ${time}`, `${DATE_FMT} ${TIME_FMT}`);
  return dt.toDate();
}

export function getCoordinatesForPercent(radius, percent) {
  const x = radius * Math.cos(2 * Math.PI * percent);
  const y = radius * Math.sin(2 * Math.PI * percent);
  return [x, y];
}

/**
 * This function takes the array of percentages: [0.5, 0.5, ...]
 * and maps it by index to the set of colors ['#fff', '#000', ...]
 * If there aren't enough colors in the set, it raises an error for the user
 *
 * Return value:
 * ex. {'#fff': 0.5, '#000': 0.5, ...} */
export function zipColorsToPercentages(colors, percentages) {
  if (colors.length < percentages.length) {
    throw new Error("You must declare an appropriate number of filter colors");
  }

  return percentages.reduce((map, percent, idx) => {
    map[colors[idx]] = percent;
    return map;
  }, {});
}

/**
 * Compare two arrays of scalars
 * @param {array} arr1: array of numbers
 * @param {array} arr2: array of numbers
 */
export function areEqual(arr1, arr2) {
  return (
    arr1.length === arr2.length &&
    arr1.every((element, index) => {
      return element === arr2[index];
    })
  );
}

/**
 * Return whether the variable is neither null nor undefined
 * @param {object} variable
 */
export function isNotNullNorUndefined(variable) {
  return typeof variable !== "undefined" && variable !== null;
}

/*
 * Taken from: https://stackoverflow.com/questions/1026069/how-do-i-make-the-first-letter-of-a-string-uppercase-in-javascript
 */
export function capitalize(string) {
  return string.charAt(0).toUpperCase() + string.slice(1);
}

export function trimAndEllipse(string, stringNum) {
  if (string.length > stringNum) {
    return string.substring(0, 120) + "...";
  }
  return string;
}

/**
 * Takes the complete set of filters, and according to their paths places them at the correct node
 *
 * Returns a nested object where:
 * Key: filter_path_0/filter_path_1/filter_path_2...
 * Value: {...nested children}
 */
export function aggregateFilterPaths(filters) {
  function insertPath(
    children = {},
    [headOfPath, ...remainder],
    accumulatedPath
  ) {
    const childKey = Object.keys(children).find((path) => {
      const pathLeaf = getPathLeaf(path);
      return pathLeaf === headOfPath;
    });
    accumulatedPath.push(headOfPath);
    const accumulatedPlusHead = accumulatedPath.join("/");
    if (!childKey) children[accumulatedPlusHead] = {};
    if (remainder.length > 0)
      insertPath(children[accumulatedPlusHead], remainder, accumulatedPath);
    return children;
  }

  const allPaths = [];
  filters.forEach((filterItem) => allPaths.push(filterItem.filter_paths));
  const aggregatedPaths = allPaths.reduce(
    (children, path) => insertPath(children, path, []),
    {}
  );
  return aggregatedPaths;
}

/**
 * From the set of associations, grab a given filter's set of parents,
 * ie. all the elements in the path array before the idx where the filter is located.
 * If we can't find the filter by the ID, we know its a meta filter, so we look
 * through every association's given path attribute to find its location.
 *
 * Returns the list of parents: ex. ['Chemical', 'Tear Gas', ...]
 */
export function getFilterAncestors(filter) {
  const splitFilter = filter.split("/");
  const ancestors = [];
  splitFilter.forEach((f, index) => {
    const accumulatedPath = splitFilter.slice(0, index + 1).join("/");
    ancestors.push(accumulatedPath);
  });
  // The last element here will be the leaf node aka the filter passed in
  ancestors.pop();
  return ancestors;
}

/**
 * Grabs the second to last element in the paths array for a given existing filter.
 * This is the filter's most immediate ancestor.
 */
export function getImmediateFilterParent(filter) {
  const ancestors = getFilterAncestors(filter);
  return ancestors[ancestors.length - 1];
}

/**
 * Grabs a given filter's siblings: the set of associations that share the same immediate filter parent.
 */
export function getFilterSiblings(allFilters, filterParent, filterKey) {
  function findSiblings(filterPathObj, ancestors) {
    if (ancestors.length === 0 || filterPathObj === {}) return {};
    const nextAncestor = ancestors.shift();
    if (Object.keys(filterPathObj).includes(nextAncestor)) {
      const nextObjToSearch = filterPathObj[nextAncestor];
      if (ancestors.length === 0) {
        return nextObjToSearch;
      } else {
        return findSiblings(nextObjToSearch, ancestors);
      }
    }
  }
  const aggregatedFilters = aggregateFilterPaths(allFilters);
  const ancestors = getFilterAncestors(filterKey);
  const siblings = findSiblings(aggregatedFilters, ancestors);
  return Object.keys(siblings).filter((sib) => sib !== filterKey);
}

/**
 * Looks at the current coloring set (ie. a map between sets of filters and colors) and configures where to add next set
 */
export function addToColoringSet(coloringSet, filters) {
  const flattenedColoringSet = coloringSet.flatMap((f) => f);
  const newColoringSet = filters.filter(
    (k) => flattenedColoringSet.indexOf(k) === -1
  );
  return [...coloringSet, newColoringSet];
}

/**
 * Looks at the current coloring set (ie. a map between sets of filters and colors) and configures new sets based off of existing filters
 */
export function removeFromColoringSet(coloringSet, filters) {
  const newColoringSets = coloringSet.map((set) =>
    set.filter((s) => {
      return !filters.includes(s);
    })
  );
  return newColoringSets.filter((item) => item.length !== 0);
}

export function getEventCategories(event, activeCategories) {
  const eventCats = event.associations.filter(
    (a) => a.mode === ASSOCIATION_MODES.CATEGORY
  );
  return eventCats.reduce((acc, val) => {
    const activeCatTitle = activeCategories.find((cat) => cat === val.title);
    if (activeCatTitle) acc.push(activeCatTitle);
    return acc;
  }, []);
}

/**
 * Takes a filter's path and concatenates it like so: Parent 1/Parent 2/Child
 */
export function createFilterPathString(filter) {
  return filter.filter_paths.join("/");
}

/**
 * Inset the full source represenation from 'allSources' into an event. The
 * function is 'curried' to allow easy use with maps. To use for a single
 * source, call with two sets of parentheses:
 *      const src = insetSourceFrom(sources)(anEvent)
 */
export function insetSourceFrom(allSources) {
  return (event) => {
    let sources;
    if (!event.sources) {
      sources = [];
    } else {
      sources = event.sources.map((id) => {
        return allSources.hasOwnProperty(id) ? allSources[id] : null;
      });
    }
    return {
      ...event,
      sources,
    };
  };
}

/**
 * Debugging function: put in place of a mapStateToProps function to
 * view that source modal by default
 */
export function injectSource(id) {
  return (state) => {
    return {
      ...state,
      app: {
        ...state.app,
        source: state.domain.sources[id],
      },
    };
  };
}

const API_ROOT =
  import.meta.env.MODE === "development" ? "" : config.SERVER_ROOT;

export function urlFromEnv(ext) {
  if (config[ext]) {
    if (!Array.isArray(config[ext])) {
      return [`${API_ROOT}${config[ext]}`];
    } else {
      return config[ext].map((suffix) => `${API_ROOT}${suffix}`);
    }
  } else {
    return null;
  }
}

export function toggleFlagAC(flag) {
  return (appState) => ({
    ...appState,
    flags: {
      ...appState.flags,
      [flag]: !appState.flags[flag],
    },
  });
}

export function selectTypeFromPath(path) {
  let type;
  switch (true) {
    case /\.(png|jpg)$/.test(path):
      type = "Image";
      break;
    case /\.(mp4)$/.test(path):
      type = "Video";
      break;
    case /\.(md)$/.test(path):
      type = "Text";
      break;
    default:
      type = "Unknown";
      break;
  }
  return { type, path };
}

export function typeForPath(path) {
  let type;
  path = path.trim();
  switch (true) {
    case /\.((png)|(jpg)|(jpeg))$/.test(path):
      type = "Image";
      break;
    case /\.(mp4)$/.test(path):
      type = "Video";
      break;
    case /\.(md)$/.test(path):
      type = "Text";
      break;
    case /\.(pdf)$/.test(path):
      type = "Document";
      break;
    case /.+(twitter\.com).+/.test(path):
      type = "Tweet";
      break;
    case /.+(t\.me).+/.test(path):
      type = "Telegram";
      break;

    default:
      type = "Unknown";
      break;
  }
  return type;
}

export function selectTypeFromPathWithPoster(path, poster) {
  return { type: typeForPath(path), path, poster };
}

export function isIdentical(obj1, obj2) {
  return hash(obj1) === hash(obj2);
}

export function calcOpacity(num) {
  /* Events have opacity 0.5 by default, and get added to according to how many
   * other events there are in the same render. The idea here is that the
   * overlaying of events builds up a 'heat map' of the event space, where
   * darker areas represent more events with proportion */
  const base = num >= 1 ? 0.9 : 0;
  return base + Math.min(0.5, 0.08 * (num - 1));
}

export function calcClusterOpacity(pointCount, totalPoints) {
  /* Clusters represent multiple events within a specific radius. The darker the cluster,
  the larger the number of underlying events. We use a multiplication factor (50) here as well
  to ensure that the larger clusters have an appropriately darker shading. */
  return Math.min(0.85, 0.08 + (pointCount / totalPoints) * 50);
}

export function calcClusterSize(pointCount, totalPoints) {
  /* The larger the cluster size, the higher the count of points that the cluster represents.
  Just like with opacity, we use a multiplication factor to ensure that clusters with higher point
  counts appear larger. */
  //TO-DO: Convert maxSize into a config var
  const maxSize = totalPoints > 60 ? 60 : 35;
  return Math.min(maxSize, 10 + (pointCount / totalPoints) * 100);
}

export function calculateTotalClusterPoints(clusters) {
  return clusters.reduce((total, cl) => {
    if (cl && cl.properties && cl.properties.cluster) {
      total += cl.properties.point_count;
    }
    return total;
  }, 0);
}

export function isLatitude(lat) {
  return !!lat && isFinite(lat) && Math.abs(lat) <= 90;
}

export function isLongitude(lng) {
  return !!lng && isFinite(lng) && Math.abs(lng) <= 180;
}

export function mapClustersToLocations(clusters, locations) {
  return clusters.reduce((acc, cl) => {
    const foundLocation = locations.find(
      (location) => location.label === cl.properties.id
    );
    if (foundLocation) acc.push(foundLocation);
    return acc;
  }, []);
}

/**
 * Loops through a set of either locations or events
 * and calculates the proportionate percentage of every given association in relation to the coloring set
 */
export function calculateColorPercentages(set, coloringSet) {
  if (coloringSet.length === 0) return [1];
  const associationMap = {};

  for (const [idx, value] of coloringSet.entries()) {
    for (const filter of value) {
      associationMap[filter] = idx;
    }
  }

  const associationCounts = new Array(coloringSet.length);
  associationCounts.fill(0);

  let totalAssociations = 0;

  set.forEach((item) => {
    let innerSet = "events" in item ? item.events : item;

    if (!Array.isArray(innerSet)) innerSet = [innerSet];

    innerSet.forEach((val) => {
      val.associations.forEach((a) => {
        const idx = associationMap[createFilterPathString(a)];
        if (!idx && idx !== 0) return;
        associationCounts[idx] += 1;
        totalAssociations += 1;
      });
    });
  });

  if (totalAssociations === 0) return [1];

  return associationCounts.map((count) => count / totalAssociations);
}

/**
 * Gets the idx of a given filter in relation to its position in the coloring set
 *
 * Example coloringSet = [['Chemical', 'Tear Gas'], ['Procedural', 'Destruction of property']]
 */
export function getFilterIdxFromColorSet(filter, coloringSet) {
  let filterIdx = -1;
  coloringSet.map((set, idx) => {
    const foundIdx = set.indexOf(filter);
    if (foundIdx !== -1) filterIdx = idx;
    return null;
  });
  return filterIdx;
}

export const dateMin = function () {
  return Array.prototype.slice.call(arguments).reduce(function (a, b) {
    return a < b ? a : b;
  });
};

export const dateMax = function () {
  return Array.prototype.slice.call(arguments).reduce(function (a, b) {
    return a > b ? a : b;
  });
};

/** Taken from
 * https://stackoverflow.com/questions/22697936/binary-search-in-javascript
 * **/
export function binarySearch(ar, el, compareFn) {
  let m = 0;
  let n = ar.length - 1;
  while (m <= n) {
    const k = (n + m) >> 1;
    const cmp = compareFn(el, ar[k]);
    if (cmp > 0) {
      m = k + 1;
    } else if (cmp < 0) {
      n = k - 1;
    } else {
      return k;
    }
  }
  return -m - 1;
}

export function makeNiceDate(datetime) {
  if (datetime === null) return null;
  // see https://stackoverflow.com/questions/3552461/how-to-format-a-javascript-date
  const dateTimeFormat = new Intl.DateTimeFormat(language, {
    year: "numeric",
    month: "long",
    day: "2-digit",
  });
  const [{ value: month }, , { value: day }, , { value: year }] =
    dateTimeFormat.formatToParts(datetime);

  return `${day} ${month}, ${year}`;
}

/**
 * Sets the default locale for d3 to format dates in each available language.
 */
export function setD3Locale() {
  const languages = {
    "es-MX": esMxData,
  };

  if (language !== "es-US" && languages[language]) {
    timeFormatDefaultLocale(languages[language]);
  }
}

/**
 * Gets the set of associated styles for a given shape type from the entire set of shapes
 * @param list shapes - The aggregated set of shapes
 * @param list activeShapes - The set of active shapes in the app
 */
export function mapStyleByShape(shapes, activeShapes) {
  const styledShapes = shapes.map((s) => {
    const { colour, shape, id } = s;
    const style = {
      checkboxStyles: {
        background: activeShapes.includes(id) ? colour : "black",
        border: "none",
        clipPath: POLYGON_CLIP_PATH[shape],
      },
      containerStyles: {
        background: colour,
        clipPath: POLYGON_CLIP_PATH[shape],
      },
    };
    s.styles = style;
    return s;
  });
  return styledShapes;
}

export function mapCategoriesToPaths(categories, panelCategories) {
  const mappedCats = categories.reduce((acc, cat) => {
    const type = cat.filter_paths[0];
    if (!(type in acc)) {
      acc[type] = [];
    }
    acc[type].push(cat);
    return acc;
  }, {});

  const categoryMap =
    panelCategories.length > 1 ? mappedCats : { default: categories };
  return categoryMap;
}

export function getCategoryIdxs(panelCategories, startingIdx) {
  let idxCounter = startingIdx;
  // If there are specified categories from the config, filter out the default value; else, leave the default value
  const catTypes =
    panelCategories.length > 1
      ? panelCategories.filter((val) => val !== "default")
      : panelCategories;
  return catTypes.reduce((set, val) => {
    set[val] = idxCounter;
    idxCounter += 1;
    return set;
  }, {});
}

export function getFilterIdx(
  narrativesExist,
  categoriesExist,
  numCategoryPanels
) {
  if (narrativesExist && !categoriesExist) return 1;
  else if (!narrativesExist && categoriesExist) return numCategoryPanels;
  else if (narrativesExist && categoriesExist) return numCategoryPanels + 1;
  else return 0;
}

export function downloadAsFile(filename, content) {
  let element = document.createElement("a");
  element.setAttribute(
    "href",
    `data:application/octet-stream;charset=utf-8,${encodeURIComponent(content)}`
  );
  element.setAttribute("download", filename);

  element.style.display = "none";
  document.body.appendChild(element);

  element.click();

  document.body.removeChild(element);
}

export const isEmptyString = (s) => s.length === 0;
export const isOdd = (num) => num % 2 !== 0;

export function isEmptyObject(o) {
  return o == null || (typeof o === "object" && !Object.keys(o).length);
}


================================================
FILE: src/components/App.jsx
================================================
import "../scss/main.scss";
import { Component } from "react";
import Layout from "./Layout";

class App extends Component {
  render() {
    return <Layout />;
  }
}

export default App;


================================================
FILE: src/components/InfoPopup.jsx
================================================
import Popup from "./atoms/Popup";
import copy from "../common/data/copy.json";

const Infopopup = ({ isOpen, onClose, language, styles }) => (
  <Popup
    title={copy[language].legend.default.header}
    content={copy[language].legend.default.intro}
    onClose={onClose}
    isOpen={isOpen}
    styles={styles}
  />
);

export default Infopopup;


================================================
FILE: src/components/Layout.jsx
================================================
import { Component } from "react";

import { bindActionCreators } from "redux";
import { connect } from "react-redux";
import * as actions from "../actions";
import * as selectors from "../selectors";

import Toolbar from "./Toolbar";
import InfoPopup from "./InfoPopup";
import Notification from "./Notification";
import TemplateCover from "./TemplateCover";

import Popup from "./atoms/Popup";
import StaticPage from "./atoms/StaticPage";
import MediaOverlay from "./atoms/Media";
import LoadingOverlay from "./atoms/Loading";

import Timeline from "./time/Timeline";
import Space from "./space/Space";
import Search from "./controls/Search";
import CardStack from "./controls/CardStack";
import NarrativeControls from "./controls/NarrativeControls";

import colors from "../common/global";
import { binarySearch, insetSourceFrom } from "../common/utilities";

class Dashboard extends Component {
  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);
  }

  componentDidMount() {
    this.props.actions.fetchDomain().then((domain) => {
      this.props.actions.updateDomain({
        domain,
        features: this.props.features,
      });
      this.props.actions.rehydrateState();
    });

    // NOTE: hack to get the timeline to always show. Not entirely sure why
    // this is necessary.
    window.dispatchEvent(new Event("resize"));
  }

  handleHighlight(highlighted) {
    this.props.actions.updateHighlighted(highlighted || null);
  }

  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;
    });
  }

  handleSelect(selected, axis) {
    if (selected.length <= 0) {
      this.props.actions.updateSelected([]);
      return;
    }

    const matchedEvents = [];
    const TIMELINE_AXIS = 0;
    if (axis === TIMELINE_AXIS) {
      matchedEvents.push(selected);
      // find in events
      const { events } = this.props.domain;
      const idx = this.findEventIdx(selected);
      // binary search can return event with different id
      if (events[idx].id !== selected.id) {
        matchedEvents.push(events[idx]);
      }

      // check events before
      let ptr = idx - 1;
      while (
        ptr >= 0 &&
        events[idx].datetime.getTime() === events[ptr].datetime.getTime()
      ) {
        if (events[ptr].id !== selected.id) {
          matchedEvents.push(events[ptr]);
        }
        ptr -= 1;
      }

      // check events after
      ptr = idx + 1;
      while (
        ptr < events.length &&
        events[idx].datetime.getTime() === events[ptr].datetime.getTime()
      ) {
        if (events[ptr].id !== selected.id) {
          matchedEvents.push(events[ptr]);
        }
        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;
    }
  }

  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.props.actions.updateNarrative(narrative);
  }

  setNarrativeFromFilters(withSteps) {
    const { app, domain } = this.props;
    let activeFilters = app.associations.filters;

    if (activeFilters.length === 0) {
      alert("No filters selected, cant narrativise");
      return;
    }

    activeFilters = activeFilters.map((f) => ({ name: f }));

    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;
        }
      }
      if (hasOne) return true;
      return false;
    });

    if (evs.length === 0) {
      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");
    this.setNarrative({
      id: name,
      label: name,
      description: desc,
      withLines: withSteps,
      steps: evs.map(insetSourceFrom(domain.sources)),
    });
  }

  selectNarrativeStep(idx) {
    // Try to find idx if event passed rather than number
    if (typeof idx !== "number") {
      const e = idx[0] || idx;

      if (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);
        const narrativeIdx = steps.indexOf(narrativeIdxObj);

        if (narrativeIdx > -1) {
          idx = narrativeIdx;
        }
      }
    }

    const { narrative } = this.props.app.associations;
    if (narrative === null) return;

    if (idx < narrative.steps.length && idx >= 0) {
      const step = narrative.steps[idx];

      this.handleSelect([step]);
      this.props.actions.updateNarrativeStepIdx(idx);
    }
  }

  onKeyDown(e) {
    const { narrative, selected } = this.props.app;
    const { events } = this.props.domain;

    const prev = (idx) => {
      if (narrative === null) {
        this.handleSelect(events[idx - 1], 0);
      } else {
        this.selectNarrativeStep(this.props.narrativeIdx - 1);
      }
    };
    const next = (idx) => {
      if (narrative === null) {
        this.handleSelect(events[idx + 1], 0);
      } else {
        this.selectNarrativeStep(this.props.narrativeIdx + 1);
      }
    };
    if (selected.length > 0) {
      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;
        case 39: // right arrow
        case 40: // down arrow
          if (idx < 0 || idx >= this.props.domain.length - 1) return;
          next(idx);
          break;
        default:
      }
    }
  }

  renderIntroPopup(styles) {
    const { app, actions } = this.props;
    const localStorageKey = "rememberDismissedIntro2"; // can be incremented when new data appears on the cover

    let searchParams = new URLSearchParams(window.location.href.split("?")[1]);
    let rememberDismissedIntro =
      localStorage.getItem(localStorageKey) === "true";
    let forceShowIntro = searchParams.get("cover") === "true";
    if (
      (forceShowIntro || !rememberDismissedIntro) &&
      !searchParams.has("id")
    ) {
      return (
        <Popup
          title="Introduction to the platform"
          theme="dark"
          isOpen={
            app.flags.isIntropopup && searchParams.get("cover") !== "false"
          }
          onClose={() => {
            actions.toggleIntroPopup();
            localStorage.setItem(localStorageKey, "true");
          }}
          content={app.intro}
          styles={styles}
        ></Popup>
      );
    } else {
      return null;
    }
  }

  render() {
    const { actions, app, domain, timeline, features } = this.props;

    const popupStyles = {};

    return (
      <div>
        {
          <Toolbar
            isNarrative={!!app.associations.narrative}
            domain={domain}
            methods={{
              onTitle: actions.toggleCover,
              onSelectFilter: (filters) =>
                actions.toggleAssociations("filters", filters),
              onCategoryFilter: (categories) =>
                actions.toggleAssociations("categories", categories),
              onShapeFilter: actions.toggleShapes,
              onSelectNarrative: this.setNarrative,
            }}
          />
        }
        <Space
          kind={"map" in app ? "map" : "space3d"}
          onKeyDown={this.onKeyDown}
          methods={{
            onSelectNarrative: this.setNarrative,
            getCategoryColor: this.getCategoryColor,
            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),
              onUpdateTimerange: actions.updateTimeRange,
              getCategoryColor: this.getCategoryColor,
            }}
          />
        }
        <CardStack
          timelineDims={timeline.dimensions}
          onViewSource={this.handleViewSource}
          onSelect={
            app.associations.narrative ? this.selectNarrativeStep : () => null
          }
          onHighlight={this.handleHighlight}
          onToggleCardstack={() => actions.updateSelected([])}
          getCategoryColor={this.getCategoryColor}
        />
        <NarrativeControls
          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,
          }}
        />
        <InfoPopup
          language={app.language}
          styles={popupStyles}
          isOpen={app.flags.isInfopopup}
          onClose={actions.toggleInfoPopup}
        />
        {this.renderIntroPopup(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.source ? (
          <MediaOverlay
            source={app.source}
            onCancel={() => {
              actions.updateSource(null);
            }}
          />
        ) : null}
        <LoadingOverlay
          isLoading={app.loading || app.flags.isFetchingDomain}
          ui={app.flags.isFetchingDomain}
          language={app.language}
        />
        {features.USE_COVER && (
          <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}
            />
          </StaticPage>
        )}
      </div>
    );
  }
}

function mapDispatchToProps(dispatch) {
  return {
    actions: bindActionCreators(actions, dispatch),
  };
}

export default connect(
  (state) => ({
    ...state,
    timeline: {
      dimensions: selectors.selectDimensions(state),
    },
    narrativeIdx: selectors.selectNarrativeIdx(state),
    narratives: selectors.selectNarratives(state),
    selected: selectors.selectSelected(state),
  }),
  mapDispatchToProps
)(Dashboard);


================================================
FILE: src/components/Notification.jsx
================================================
import { Component } from "react";

export default class Notification extends Component {
  constructor(props) {
    super();
    this.state = {
      isExtended: false,
    };
  }

  toggleDetails() {
    this.setState({ isExtended: !this.state.isExtended });
  }

  renderItems(items) {
    if (!items) return "";
    return (
      <div>
        {items.map((item, idx) => {
          if (item.error) {
            return <p key={idx}>{item.error.message}</p>;
          }
          return null;
        })}
      </div>
    );
  }

  renderNotificationContent(notification) {
    const { type, message, items } = notification;

    return (
      <div>
        <div className={`message ${type}`}>{message}</div>
        <div className={`details ${this.state.isExtended}`}>
          {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)
    );
    if (notificationsToRender.length > 0) {
      return (
        <div className="notification-wrapper">
          {this.props.notifications.map((notification, idx) => {
            return (
              <div
                className="notification"
                onClick={() => this.toggleDetails()}
                key={idx}
              >
                <button
                  onClick={this.props.onToggle}
                  className="side-menu-burg over-white is-active"
                >
                  <span />
                </button>
                {this.renderNotificationContent(notification)}
              </div>
            );
          })}
        </div>
      );
    }
    return <div />;
  }
}


================================================
FILE: src/components/Portal.jsx
================================================
import { Component } from "react";
import ReactDOM from "react-dom";

class Portal extends Component {
  render() {
    const { children, node } = this.props;
    if (!node) return null;
    return ReactDOM.createPortal(children, node);
  }
}

export default Portal;


================================================
FILE: src/components/TemplateCover.jsx
================================================
import { Component } from "react";
import { connect } from "react-redux";
import { Player } from "video-react";
import { marked } from "marked";
import MediaOverlay from "./atoms/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.
 * These are documented in docs/custom-cover.md.
 * The component is a bit of a mess, keeping a lot of internal state and using
 * a couple of weird offset calculations... but it works for the time being.
 */
class TemplateCover extends Component {
  constructor(props) {
    super(props);
    this.state = {
      video: MEDIA_HIDDEN,
      featureLang: 0,
    };
  }

  getVideo(index, headerEndIndex) {
    if (index < headerEndIndex) {
      return this.props.cover.headerVideos[index];
    } else if (index >= 0) {
      return this.props.cover.videos[index - headerEndIndex];
    } else {
      return null;
    }
  }

  onVideoClickHandler(index) {
    const buffer = this.props.cover.headerVideos
      ? this.props.cover.headerVideos.length
      : 0;
    return () => {
      this.setState({
        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,
          };

    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
                    key={trans.code}
                    onClick={() => this.setState({ featureLang: langIdx })}
                    className="trans-button"
                  >
                    {trans.code}
                  </div>
                );
              } else {
                return (
                  <div
                    key="ENG"
                    onClick={() => this.setState({ featureLang: 0 })}
                    className="trans-button"
                  >
                    ENG
                  </div>
                );
              }
            })}
        </div>

        <Player
          className="source-video"
          poster={source.poster}
          playsInline
          src={source.file}
        />
      </div>
    );
  }

  renderHeaderVideos() {
    const { headerVideos } = this.props.cover;
    return (
      <div className="row">
        {headerVideos.slice(0, 2).map((media, index) => (
          <div
            key={index}
            className="cell plain"
            onClick={() => this.setState({ video: index })}
          >
            {media.buttonTitle}
          </div>
        ))}
      </div>
    );
  }

  renderButton(button, yellow) {
    return (
      <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
    );
    return (
      <MediaOverlay
        opaque
        source={{
          title: video.title,
          desc: video.desc,
          paths: [video.file],
          poster: video.poster,
        }}
        translations={video.translations}
        onCancel={() => this.setState({ video: MEDIA_HIDDEN })}
      />
    );
  }

  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>
      );
    }

    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://bellingcat.com">
            <img className="cover-logo" src={bcatlogo} alt="Bellingcat logo" />
          </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 dangerouslySetInnerHTML={{ __html: 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">
            {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.exploreButton}
              </div>
            </div>
          </div>

          {Array.isArray(this.props.cover.description) ? (
            this.props.cover.description.map((e, index) => (
              <div
                key={index}
                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">
                {/* NOTE: only take first four videos, drop any others for style reasons */}
                {videos &&
                  videos.slice(0, 2).map((media, index) => (
                    <div
                      key={index}
                      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
                      key={index}
                      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>
          ) : null}
        </div>

        {this.state.video !== MEDIA_HIDDEN ? this.renderMediaOverlay() : null}
      </div>
    );
  }
}

function mapStateToProps(state) {
  return {
    cover: state.app.cover,
  };
}

export default connect(mapStateToProps)(TemplateCover);


================================================
FILE: src/components/Toolbar.jsx
================================================
import { Component } from "react";
import { connect } from "react-redux";
import { bindActionCreators } from "redux";
import * as actions from "../actions";
import * as selectors from "../selectors";
import config from "../../config";

import { Tabs, TabList, TabPanel } from "react-tabs";
import FilterListPanel from "./controls/FilterListPanel";
import CategoriesListPanel from "./controls/CategoriesListPanel";
import ShapesListPanel from "./controls/ShapesListPanel";
import BottomActions from "./controls/BottomActions";
import copy from "../common/data/copy.json";
import {
  trimAndEllipse,
  getImmediateFilterParent,
  getFilterSiblings,
  getFilterAncestors,
  addToColoringSet,
  removeFromColoringSet,
  mapCategoriesToPaths,
  getCategoryIdxs,
  getFilterIdx,
} from "../common/utilities";
import { ToolbarButton } from "./controls/atoms/ToolbarButton";
import { FullscreenToggle } from "./controls/FullScreenToggle";
import DownloadPanel from "./controls/DownloadPanel";

class Toolbar extends Component {
  constructor(props) {
    super(props);
    this.onSelectFilter = this.onSelectFilter.bind(this);
    this.state = { _selected: 0, _active: false };
  }

  selectTab(selected) {
    let active = true;
    if (this.state._selected === selected && this.state._active === true) {
      active = false;
    }
    this.setState({ _selected: selected, _active: active });
  }

  onSelectFilter(key, matchingKeys) {
    const { filters, activeFilters, coloringSet, maxNumOfColors } = this.props;

    const parent = getImmediateFilterParent(key);
    const isTurningOff = activeFilters.includes(key);

    if (!isTurningOff) {
      const updatedColoringSet = addToColoringSet(coloringSet, matchingKeys);
      if (updatedColoringSet.length <= maxNumOfColors) {
        this.props.actions.updateColoringSet(updatedColoringSet);
      }
    } else {
      if (parent && activeFilters.includes(parent)) {
        const siblings = getFilterSiblings(filters, parent, key);
        let siblingsOff = true;
        for (const sibling of siblings) {
          if (activeFilters.includes(sibling)) {
            siblingsOff = false;
            break;
          }
        }

        if (siblingsOff) {
          const grandparentsOn = getFilterAncestors(key).filter((filt) =>
            activeFilters.includes(filt)
          );
          matchingKeys = matchingKeys.concat(grandparentsOn);
        }
      }

      const updatedColoringSet = removeFromColoringSet(
        coloringSet,
        matchingKeys
      );
      this.props.actions.updateColoringSet(updatedColoringSet);
    }
    this.props.methods.onSelectFilter(matchingKeys);
    this.props.actions.updateSelected([]);
  }

  renderClosePanel() {
    return (
      <div
        className="panel-header"
        onClick={() => this.selectTab(this.state._selected)}
      >
        <div className="caret" />
      </div>
    );
  }

  goToNarrative(narrative) {
    // this.selectTab(-1); // set all unselected within this component
    this.props.methods.onSelectNarrative(narrative);
  }

  renderToolbarNarrativePanel() {
    const { panels } = this.props.toolbarCopy;
    return (
      <TabPanel>
        <h2>{panels.narratives.label}</h2>
        <p>{panels.narratives.description}</p>
        {this.props.narratives.map((narr) => {
          return (
            <div className="panel-action action">
              <button
                onClick={() => {
                  this.goToNarrative(narr);
                }}
              >
                <p>{narr.id}</p>
                <p>
                  <small>{trimAndEllipse(narr.desc, 120)}</small>
                </p>
              </button>
            </div>
          );
        })}
      </TabPanel>
    );
  }

  renderToolbarCategoriesPanel() {
    const { categories: panelCategories } = this.props.toolbarCopy.panels;
    const catMap = mapCategoriesToPaths(
      this.props.categories,
      Object.keys(panelCategories)
    );

    return (
      <div>
        {Object.keys(catMap).map((type) => {
          const children = catMap[type];
          return (
            <TabPanel key={type}>
              <CategoriesListPanel
                categories={children}
                activeCategories={this.props.activeCategories}
                onCategoryFilter={this.props.methods.onCategoryFilter}
                language={this.props.language}
                title={panelCategories[type].label}
                description={panelCategories[type].description}
              />
            </TabPanel>
          );
        })}
      </div>
    );
  }

  renderToolbarFilterPanel() {
    const { panels } = this.props.toolbarCopy;
    return (
      <TabPanel>
        <FilterListPanel
          filters={this.props.filters}
          activeFilters={this.props.activeFilters}
          onSelectFilter={this.onSelectFilter}
          language={this.props.language}
          coloringSet={this.props.coloringSet}
          filterColors={this.props.filterColors}
          title={panels.filters.label}
          description={panels.filters.description}
        />
      </TabPanel>
    );
  }

  renderToolbarShapePanel() {
    const { panels } = this.props.toolbarCopy;

    if (this.props.features.USE_SHAPES) {
      return (
        <TabPanel>
          <ShapesListPanel
            shapes={this.props.shapes}
            activeShapes={this.props.activeShapes}
            onShapeFilter={this.props.methods.onShapeFilter}
            language={this.props.language}
            title={panels.shapes.label}
            description={panels.shapes.description}
          />
        </TabPanel>
      );
    }
  }

  renderToolbarDownloadPanel() {
    const { panels } = this.props.toolbarCopy;

    return (
      <TabPanel>
        <DownloadPanel
          language={this.props.language}
          title={panels.download.label}
          description={panels.download.description}
          domain={this.props.domain}
        />
      </TabPanel>
    );
  }

  renderToolbarTab(_selected, label, iconKey, key) {
    return (
      <ToolbarButton
        key={key}
        label={label}
        iconKey={iconKey}
        isActive={
          this.state._selected === _selected && this.state._active === true
        }
        onClick={() => {
          this.selectTab(_selected);
        }}
      />
    );
  }

  renderToolbarCategoryTabs(idxs) {
    const { categories: panelCategories } = this.props.toolbarCopy.panels;
    return (
      <div>
        {Object.keys(idxs).map((key) => {
          return this.renderToolbarTab(
            idxs[key],
            panelCategories[key].label,
            panelCategories[key].icon,
            key
          );
        })}
      </div>
    );
  }

  renderToolbarPanels() {
    const { features, narratives } = this.props;
    const classes =
      this.state._active === true ? "toolbar-panels" : "toolbar-panels folded";
    return (
      <div className={classes}>
        {this.renderClosePanel()}
        {narratives && narratives.length !== 0
          ? this.renderToolbarNarrativePanel()
          : null}
        {features.USE_CATEGORIES ? this.renderToolbarCategoriesPanel() : null}
        {features.USE_ASSOCIATIONS ? this.renderToolbarFilterPanel() : null}
        {features.USE_SHAPES ? this.renderToolbarShapePanel() : null}
        {features.USE_DOWNLOAD ? this.renderToolbarDownloadPanel() : null}
      </div>
    );
  }

  renderToolbarNavs() {
    if (this.props.narratives) {
      return this.props.narratives.map((nar, idx) => {
        const isActive =
          idx === this.state._selected && this.state._active === true;

        const classes = isActive ? "toolbar-tab active" : "toolbar-tab";

        return (
          <div
            className={classes}
            onClick={() => {
              this.selectTab(idx);
            }}
          >
            <div className="tab-caption">{nar.label}</div>
          </div>
        );
      });
    }
    return null;
  }

  renderToolbarTabs() {
    const { features, narratives, toolbarCopy } = this.props;
    const narrativesExist = narratives && narratives.length !== 0;
    let title = copy[this.props.language].toolbar.title;
    if (config.display_title) title = config.display_title;
    const { panels } = toolbarCopy;

    const narrativesIdx = 0;
    const categoryIdxs = getCategoryIdxs(
      Object.keys(panels.categories),
      narrativesExist ? 1 : 0
    );
    const numCategoryPanels = Object.keys(categoryIdxs).length;
    const filtersIdx = getFilterIdx(
      narrativesExist,
      features.USE_CATEGORIES,
      numCategoryPanels || 0
    );
    const shapesIdx = filtersIdx + features.USE_SHAPES;
    const downloadIdx = shapesIdx + features.USE_DOWNLOAD;

    return (
      <div className="toolbar">
        <div className="toolbar-header" onClick={this.props.methods.onTitle}>
          <p>{title}</p>
        </div>
        <div className="toolbar-tabs">
          <TabList>
            {narrativesExist
              ? this.renderToolbarTab(
                  narrativesIdx,
                  panels.narratives.label,
                  panels.narratives.icon
                )
              : null}
            {features.USE_CATEGORIES
              ? this.renderToolbarCategoryTabs(categoryIdxs)
              : null}
            {features.USE_ASSOCIATIONS
              ? this.renderToolbarTab(
                  filtersIdx,
                  panels.filters.label,
                  panels.filters.icon
                )
              : null}
            {features.USE_SHAPES
              ? this.renderToolbarTab(
                  shapesIdx,
                  panels.shapes.label,
                  panels.shapes.icon
                )
              : null}
            {features.USE_DOWNLOAD
              ? this.renderToolbarTab(
                  downloadIdx,
                  panels.download.label,
                  panels.download.icon
                )
              : null}
            {features.USE_FULLSCREEN && (
              <FullscreenToggle language={this.props.language} />
            )}
          </TabList>
        </div>
        <BottomActions
          info={{
            enabled: this.props.infoShowing,
            toggle: this.props.actions.toggleInfoPopup,
          }}
          sites={{
            enabled: this.props.sitesShowing,
            toggle: this.props.actions.toggleSites,
          }}
          cover={{
            toggle: this.props.actions.toggleCover,
          }}
          features={this.props.features}
        />

        <div id="made-with">
          Made with{" "}
          <a href="https://github.com/forensic-architecture/timemap">TimeMap</a>
          <br />
          Free software from{" "}
          <a href="https://forensic-architecture.org">Forensic Architecture</a>
        </div>
      </div>
    );
  }

  render() {
    const { isNarrative } = this.props;

    return (
      <div
        id="toolbar-wrapper"
        className={`toolbar-wrapper ${isNarrative ? "narrative-mode" : ""}`}
      >
        <Tabs onSelect={() => null} selectedIndex={this.state._selected}>
          {this.renderToolbarTabs()}
          {this.renderToolbarPanels()}
        </Tabs>
      </div>
    );
  }
}

function mapStateToProps(state) {
  return {
    filters: selectors.getFilters(state),
    categories: selectors.getCategories(state),
    narratives: selectors.selectNarratives(state),
    shapes: selectors.getShapes(state),
    language: state.app.language,
    toolbarCopy: state.app.toolbar,
    activeFilters: selectors.getActiveFilters(state),
    activeCategories: selectors.getActiveCategories(state),
    activeShapes: selectors.getActiveShapes(state),
    viewFilters: state.app.associations.views,
    narrative: state.app.associations.narrative,
    sitesShowing: state.app.flags.isShowingSites,
    infoShowing: state.app.flags.isInfopopup,
    coloringSet: state.app.associations.coloringSet,
    maxNumOfColors: state.ui.coloring.maxNumOfColors,
    filterColors: state.ui.coloring.colors,
    eventRadius: state.ui.eventRadius,
    features: selectors.getFeatures(state),
  };
}

function mapDispatchToProps(dispatch) {
  return {
    actions: bindActionCreators(actions, dispatch),
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(Toolbar);


================================================
FILE: src/components/atoms/Checkbox.jsx
================================================
import { DEFAULT_CHECKBOX_COLOR } from "../../common/constants";

const Checkbox = ({ label, isActive, onClickCheckbox, color, styleProps }) => {
  const checkboxColor = color ? color : DEFAULT_CHECKBOX_COLOR;
  const baseStyles = {
    checkboxStyles: {
      background: isActive ? checkboxColor : "none",
      border: `1px solid ${checkboxColor}`,
    },
  };
  const containerStyles = styleProps ? styleProps.containerStyles : {};
  const checkboxStyles = styleProps
    ? styleProps.checkboxStyles
    : baseStyles.checkboxStyles;

  const generatedId = label.toLowerCase().replaceAll(" ", "-");
  const onClickCheckboxWrapper = (e) => {
    // stop propagation in order to call method only one time
    e.stopPropagation();
    onClickCheckbox(e);
  };
  return (
    <div
      className={isActive ? "item active" : "item"}
      onClick={onClickCheckboxWrapper}
    >
      <button id={generatedId} onClick={onClickCheckboxWrapper}>
        <div className="border" style={containerStyles}>
          <div className="checkbox" style={checkboxStyles} />
        </div>
      </button>
      <label htmlFor={generatedId} style={{ color: color }}>
        {label}
      </label>
    </div>
  );
};

export default Checkbox;


================================================
FILE: src/components/atoms/CoeventIcon.jsx
================================================
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
          strokeLinejoin="round"
          strokeMiterlimit="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
          strokeLinejoin="round"
          strokeMiterlimit="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;


================================================
FILE: src/components/atoms/ColoredMarkers.jsx
================================================
import { getCoordinatesForPercent } from "../../common/utilities";

function ColoredMarkers({ radius, colorPercentMap, styles, className }) {
  let cumulativeAngleSweep = 0;
  const colors = Object.keys(colorPercentMap);

  return (
    <>
      {colors.map((color, idx) => {
        const colorPercent = colorPercentMap[color];

        const [startX, startY] = getCoordinatesForPercent(
          radius,
          cumulativeAngleSweep
        );

        cumulativeAngleSweep += colorPercent;

        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;

        // 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(" ");

        const extraStyles = {
          ...styles,
          fill: color,
        };

        return (
          <path
            key={`arc_${idx}`}
            className={className}
            id={`arc_${idx}`}
            d={arc}
            style={extraStyles}
          />
        );
      })}
    </>
  );
}

export default ColoredMarkers;


================================================
FILE: src/components/atoms/Content.jsx
================================================
import { Player } from "video-react";
import { Img } from "react-image";
import Md from "./Md";
import Spinner from "../atoms/Spinner";
import NoSource from "../atoms/NoSource";

const Content = ({ media, viewIdx, translations, switchLanguage, langIdx }) => {
  const el = document.querySelector(".source-media-gallery");
  const shiftW = el ? el.getBoundingClientRect().width : 0;

  function renderMedia(media) {
    const { path, type, poster } = media;
    switch (type) {
      case "Image":
        return (
          <div className="source-image-container">
            <Img
              className="source-image"
              src={path}
              loader={
                <div className="source-image-loader">
                  <Spinner />
                </div>
              }
              unloader={<NoSource failedUrls={[path]} />}
              onClick={() => window.open(path, "_blank")}
            />
          </div>
        );
      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>
            <Player
              poster={poster}
              className="source-video"
              playsInline
              src={path}
            />
          </div>
        );
      case "Text":
        return (
          <div className="source-text-container">
            <Md
              path={path}
              loader={<Spinner />}
              unloader={() => this.renderError()}
            />
          </div>
        );
      case "Document":
        return <iframe title={path} className="source-document" src={path} />;
      default:
        return (
          <NoSource
            failedUrls={[
              `Application does not support extension: ${path.split(".")[1]}`,
            ]}
          />
        );
    }
  }

  return (
    <div
      className="source-media-gallery"
      style={{ transform: `translate(${viewIdx * -shiftW}px)` }}
    >
      {media.map((m) => renderMedia(m))}
    </div>
  );
};

export default Content;


================================================
FILE: src/components/atoms/Controls.jsx
================================================
const OverlayControls = ({ viewIdx, paths, onShiftHandler }) => {
  const backArrow =
    viewIdx !== 0 ? (
      <div className="back" onClick={() => onShiftHandler(-1)}>
        <div className="centerer">
          <i className="material-icons">arrow_left</i>
        </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>
        </div>
      </div>
    ) : null;

  if (paths.length > 1) {
    return (
      <div className="media-gallery-controls">
        {backArrow}
        {forwardArrow}
      </div>
    );
  }
  return <div className="media-gallery-controls" />;
};

export default OverlayControls;


================================================
FILE: src/components/atoms/CoverIcon.jsx
================================================
const CoverIcon = ({ isActive, isDisabled, onClickHandler }) => {
  let classes = isActive ? "action-button enabled" : "action-button";
  if (isDisabled) {
    classes = "action-button disabled";
  }

  return (
    <button className={classes} onClick={onClickHandler}>
      <i className="material-icons">home</i>
    </button>
  );
};

export default CoverIcon;


================================================
FILE: src/components/atoms/InfoIcon.jsx
================================================
const CoverIcon = ({ isActive, isDisabled, onClickHandler }) => {
  let classes = isActive ? "action-button enabled" : "action-button";
  if (isDisabled) {
    classes = "action-button disabled";
  }

  return (
    <button className={classes} onClick={onClickHandler}>
      <i className="material-icons">info</i>
    </button>
  );
};

export default CoverIcon;


================================================
FILE: src/components/atoms/Loading.jsx
================================================
import copy from "../../common/data/copy.json";

const LoadingOverlay = ({ isLoading, language }) => {
  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>
      </div>
    </div>
  );
};

export default LoadingOverlay;


================================================
FILE: src/components/atoms/Md.jsx
================================================
import { Component } from "react";
import PropTypes from "prop-types";
import { marked } from "marked";

class Md extends Component {
  constructor(props) {
    super(props);
    this.state = { md: null, error: null };
  }

  componentDidMount() {
    fetch(this.props.path)
      .then((resp) => resp.text())
      .then((text) => {
        if (text.length <= 0) {
          throw new Error();
        }

        this.setState({ md: marked(text) });
      })
      .catch(() => {
        this.setState({ error: true });
      });
  }

  render() {
    if (this.state.md && !this.state.error) {
      return (
        <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>;
    } else {
      return this.props.loader;
    }
  }
}

Md.propTypes = {
  loader: PropTypes.func,
  unloader: PropTypes.func.isRequired,
  path: PropTypes.string.isRequired,
};

export default Md;


================================================
FILE: src/components/atoms/Media.jsx
================================================
import { Component } 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
 * can be changed by the user. These are both managed in this component's React
 * state.
 */
class SourceOverlay extends Component {
  constructor() {
    super();
    this.state = { mediaIdx: 0, langIdx: 0 };
    this.onShiftGallery = this.onShiftGallery.bind(this);
  }

  getTypeCounts(media) {
    return media.reduce(
      (acc, vl) => {
        acc[vl.type] += 1;
        return acc;
      },
      { Image: 0, Video: 0, Text: 0 }
    );
  }

  onShiftGallery(shift) {
    // no more left
    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 });
  }

  switchLanguage(idx) {
    this.setState({ langIdx: idx });
  }

  renderContent(source) {
    const { url, title, paths, date, type, poster, description } = source;
    const shortenedTitle = title.substring(0, 100);
    return (
      <>
        <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>
        </div>
        <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))}
              viewIdx={this.state.mediaIdx}
            />
          </div>
        </div>

        <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}

            {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}
                </div>
                <div>
                  {date ? <h4>Date Published</h4> : 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" rel="noreferrer">
                        Link to original URL
                      </a>
                    </span>
                  ) : null}
                </div>
              </div>
            ) : null}
          </div>
        </div>
      </>
    );
  }

  renderIntlContent() {
    const { langIdx } = this.state;
    const { translations, source } = this.props;
    let translated = null;
    if (translations && translations.length && langIdx > 0) {
      translated = translations[langIdx - 1];
    }
    if (translated) {
      translated = {
        ...translated,
        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,
      };
    }

    return this.renderContent(langIdx === 0 ? source : translated);
  }

  render() {
    if (typeof this.props.source !== "object") {
      return this.renderError();
    }

    return (
      <div className={`mo-overlay ${this.props.opaque ? "opaque" : ""}`}>
        {this.renderIntlContent()}
      </div>
    );
  }
}

export default SourceOverlay;


================================================
FILE: src/components/atoms/NoSource.jsx
================================================
const NoSource = ({ failedUrls }) => {
  return (
    <div className="no-source-container">
      <div className="no-source-row">
        <p>
          <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>
      </div>
    </div>
  );
};

export default NoSource;


================================================
FILE: src/components/atoms/Popup.jsx
================================================
import { marked } from "marked";

const fontSize = window.innerWidth > 1000 ? 14 : 18;

const Popup = ({
  content = [],
  styles = {},
  isOpen = true,
  onClose,
  title,
  theme = "light",
  children,
}) => (
  <div>
    <div
      className={`infopopup__bg ${isOpen ? "" : "hidden"}`}
      onClick={onClose}
    ></div>
    <div
      className={`infopopup ${isOpen ? "" : "hidden"} ${
        theme === "dark" ? "dark" : "light"
      }`}
      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, idx) => (
        <div key={idx} dangerouslySetInnerHTML={{ __html: marked(t) }} />
      ))}
      {children}
    </div>
  </div>
);

export default Popup;


================================================
FILE: src/components/atoms/RefreshIcon.jsx
================================================
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
        strokeWidth="2"
        strokeMiterlimit="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>
  );
};


================================================
FILE: src/components/atoms/RouteIcon.jsx
================================================
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 " />
      </svg>
    </button>
  );
};

export default RouteIcon;


================================================
FILE: src/components/atoms/SitesIcon.jsx
================================================
const SitesIcon = ({ isActive, isDisabled, onClickHandler }) => {
  let classes = isActive ? "action-button enabled" : "action-button";
  if (isDisabled) {
    classes = "action-button disabled";
  }

  return (
    <button className={classes} onClick={onClickHandler}>
      <i className="material-icons">location_on</i>
    </button>
  );
};

export default SitesIcon;


================================================
FILE: src/components/atoms/Spinner.jsx
================================================
const Spinner = ({ small }) => {
  return (
    <div className={`spinner ${small ? "small" : ""}`}>
      <div className="double-bounce-overlay" />
      <div className="double-bounce" />
    </div>
  );
};

export default Spinner;


================================================
FILE: src/components/atoms/StaticPage.jsx
================================================
const StaticPage = ({ showing, children }) => (
  <div className={`cover-container ${showing ? "showing" : ""}`}>
    {children}
  </div>
);

export default StaticPage;


================================================
FILE: src/components/controls/BottomActions.jsx
================================================
import SitesIcon from "../atoms/SitesIcon";
import CoverIcon from "../atoms/CoverIcon";
// import InfoIcon from "../atoms/InfoIcon";

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>
        {/* ,
        <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 style={{ fontSize: 9, paddingTop: 10 }}>
          Made with{" "}
          <a href="https://github.com/forensic-architecture/timemap">TimeMap</a>
          <br />
          Free software from <br />{" "}
          <a href="https://forensic-architecture.org">Forensic Architecture</a>
        </div>
      </>
    );
  }

  return <div className="bottom-actions">{renderToggles()}</div>;
}

export default BottomActions;


================================================
FILE: src/components/controls/Card.jsx
================================================
import { useState } from "react";
import CardText from "./atoms/Text";
import CardTime from "./atoms/Time";
import CardButton from "./atoms/Button";
import CardCaret from "./atoms/Caret";
import CardCustom from "./atoms/CustomField";
import CardMedia from "./atoms/Media";

import { makeNiceDate, isEmptyString } from "../../common/utilities";
import hash from "object-hash";

export const generateCardLayout = {
  basic: ({ event }) => {
    return [
      [
        {
          kind: "date",
          title: "Reported Incident Date",
          value: event.datetime || event.date || ``,
        },
        {
          kind: "text",
          title: "Location",
          value: event.location || `—`,
        },
        {
          kind: "text",
          title: "id",
          value: event.civId || `—`,
        },
      ],
      [{ kind: "line-break", times: 0.4 }],
      [
        {
          kind: "text",
          title: "Summary",
          value: event.description || ``,
          scaleFont: 1.1,
        },
      ],
    ];
  },
  sourced: ({ event }) => {
    return [
      [
        {
          kind: "date",
          title: "Reported Incident Date",
          value: event.datetime || event.date || ``,
        },
        {
          kind: "text",
          title: "Location",
          value: event.location || `—`,
        },
        {
          kind: "text",
          title: "id",
          value: event.civId || `—`,
        },
      ],
      [
        {
          kind: "text",
          title: "Summary",
          value: event.description || ``,
          scaleFont: 1.1,
        },
      ],
      [
        {
          kind: "sources",
          values: event.sources.flatMap((source) => [
            source.paths.map((p) => ({
              kind: "media",
              title: "Media",
              value: [
                { src: p, title: null, graphic: event.graphic === "TRUE" },
              ],
            })),
          ]),
        },
      ],
    ];
  },
};

export const Card = ({
  content = [],
  isLoading = true,
  cardIdx = -1,
  onSelect = () => {},
  sources = [],
  isSelected = false,
  language = "en-US",
}) => {
  const [isOpen, setIsOpen] = useState(false);
  const toggle = () => setIsOpen(!isOpen);

  // NB: should be internationalized.
  const renderTime = (field) => (
    <CardTime
      language={language}
      timelabel={makeNiceDate(field.value)}
      {...field}
    />
  );

  const renderCaret = () =>
    sources.length === 0 && (
      <CardCaret toggle={() => toggle()} isOpen={isOpen} />
    );

  const renderMedia = ({ media, idx, cardIdx }) => {
    return (
      <CardMedia
        key={idx}
        cardIdx={cardIdx}
        src={media.src}
        title={media.title}
        graphic={media.graphic}
      />
    );
  };

  function renderField(field, cardIdx) {
    switch (field.kind) {
      case "media":
        return (
          <div className="card-cell">
            {field.value.map((media, idx) => {
              return renderMedia({ media, idx, cardIdx });
            })}
          </div>
        );
      case "line":
        return (
          <div style={{ height: `1rem`, width: `100%` }}>
            <hr />
          </div>
        );
      case "line-break":
        return (
          <div style={{ height: `${field.times || 1}rem`, width: `100%` }} />
        );
      case "item":
        // this is like a span
        return null;
      case "markdown":
        return <CardCustom {...field} />;
      case "tag":
        return (
          <div
            className="card-cell m0"
            style={{
              textTransform: `uppercase`,
              fontSize: `.8em`,
              lineHeight: `.8em`,
            }}
          >
            <div
              style={{
                display: "flex",
                justifyContent: `flex-${field.align || `start`}`,
              }}
            >
              {field.value}
            </div>
          </div>
        );
      case "button":
        return (
          <div className="card-cell">
            {field.title && <h4>{field.title}</h4>}
            {/* <div className="card-row"> */}
            {field.value.map((t, idx) => (
              <CardButton key={`card-button-${idx}`} {...t} />
            ))}
            {/* </div> */}
          </div>
        );
      case "text":
        return !isEmptyString(field.value) && <CardText {...field} />;
      case "date":
        return renderTime(field);
      case "links":
        return (
          <div className="card-cell">
            {field.title && <h4>{field.title}</h4>}
            <div className="card-row m0">
              {field.value.map(({ text, href }, idx) => (
                <a href={href} key={`card-links-url-${idx}`}>
                  {text}
                </a>
              ))}
            </div>
          </div>
        );
      case "list":
        // Only render if some of the list's strings are non-empty
        const shouldFieldRender =
          !!field.value.length &&
          !!field.value.filter((s) => !isEmptyString(s)).length;
        return shouldFieldRender ? (
          // <div className="card-cell">
          <div>
            {field.title && <h4>{field.title}</h4>}
            <div className="card-row m0">
              {field.value.map((t, idx) => (
                <CardText key={`card-list-text-${idx}`} value={t} {...t} />
              ))}
            </div>
          </div>
        ) : null;
      default:
        return null;
    }
  }

  function renderRow(row, cardIdx, salt) {
    return (
      <div className="card-row" key={hash({ ...row, salt })}>
        {row.map((field) => (
          // src by src meaning wrapGrahpic must be called around a map of renderField for sources
          <span key={hash({ ...field, row: row })}>
            {renderField(field, cardIdx)}
          </span>
        ))}
      </div>
    );
  }

  // TODO: render afterCaret appropriately from props
  sources = [];

  return (
    <li
      key={hash(content)}
      className={`event-card ${isSelected ? "selected" : ""}`}
      onClick={onSelect}
    >
      {content.map((row, idx) => {
        if (row[0].kind === "sources" && row[0].values.length > 0) {
          return (
            <div key={idx}>
              <details open={true}>
                <summary>
                  <span className="summary-line"></span>
                  <span className="summary-text">
                    <span className="summary-show">Show</span>{" "}
                    <span className="summary-hide">Hide</span> sources (
                    {row[0].values.length})
                  </span>
                  <span className="summary-line"></span>
                </summary>
                {row[0].values.map((r) => renderRow(r, cardIdx, row[0]))}
              </details>
            </div>
          );
        } else return renderRow(row, cardIdx);
      })}

      {/* {isOpen && (
        <div className="card-bottomhalf">
          {sources.map(() => (
            <div className="card-row"></div>
          ))}
        </div>
      )} */}
      {sources.length > 0 ? renderCaret() : null}
    </li>
  );
};


================================================
FILE: src/components/controls/CardStack.jsx
================================================
import { createRef, Component } from "react";
import { connect } from "react-redux";
import { generateCardLayout, Card } from "./Card";

import * as selectors from "../../selectors";
import { getFilterIdxFromColorSet } from "../../common/utilities";
import copy from "../../common/data/copy.json";

class CardStack extends Component {
  constructor() {
    super();
    this.refs = {};
    this.refCardStack = createRef();
    this.refCardStackContent = createRef();
  }

  componentDidUpdate() {
    const isNarrative = !!this.props.narrative;

    if (isNarrative) {
      this.scrollToCard();
    }
  }

  scrollToCard() {
    const duration = 500;
    const element = this.refCardStack.current;
    const cardScroll =
      this.refs[this.props.narrative.current].current.offsetTop;

    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;
    };

    const animateScroll = function () {
      currentTime += increment;
      const val = Math.easeInOutQuad(currentTime, start, change, duration);
      element.scrollTop = val;
      if (currentTime < duration) setTimeout(animateScroll, increment);
    };
    animateScroll();
  }

  renderCards(events, selections) {
    // if no selections provided, select all
    if (!selections) {
      selections = events.map((e) => true);
    }
    this.refs = [];

    const generateTemplate =
      generateCardLayout[this.props.cardUI.layout.template];

    return events.map((event, idx) => {
      const thisRef = createRef();
      this.refs[idx] = thisRef;

      const content = generateTemplate({
        event,
        colors: this.props.colors,
        coloringSet: this.props.coloringSet,
        getFilterIdxFromColorSet,
      });

      return (
        <Card
          key={idx}
          cardIdx={idx}
          content={content}
          language={this.props.language}
          isLoading={this.props.isLoading}
          isSelected={selections[idx]}
        />
      );
    });
  }

  renderSelectedCards() {
    const { selected } = this.props;

    if (selected.length > 0) {
      return this.renderCards(selected);
    }
    return null;
  }

  renderNarrativeCards() {
    const { narrative } = this.props;
    const showing = narrative.steps;

    const selections = showing.map((_, idx) => idx === narrative.current);

    return this.renderCards(showing, selections);
  }

  renderCardStackHeader() {
    const headerLang = copy[this.props.language].cardstack.header;

    return (
      <div
        id="card-stack-header"
        className="card-stack-header"
        onClick={() => this.props.onToggleCardstack()}
      >
        <button className="side-menu-burg is-active">
          <span />
        </button>
        <p className="header-copy top">
          {`${this.props.selected.length} ${headerLang}`}
        </p>
      </div>
    );
  }

  renderCardStackContent() {
    return (
      <div
        id="card-stack-content"
        className="card-stack-content scrollbar-black"
      >
        <ul>{this.renderSelectedCards()}</ul>
      </div>
    );
  }

  renderNarrativeContent() {
    return (
      <div
        id="card-stack-content"
        className="card-stack-content"
        ref={this.refCardStackContent}
      >
        <ul>{this.renderNarrativeCards()}</ul>
      </div>
    );
  }

  render() {
    const { isCardstack, selected, narrative } = this.props;
    if (selected.length > 0) {
      if (!narrative) {
        return (
          <div
            id="card-stack"
            className={`card-stack ${isCardstack ? "" : " folded"}`}
          >
            {this.renderCardStackHeader()}
            {this.renderCardStackContent()}
          </div>
        );
      } else {
        return (
          <div
            id="card-stack"
            ref={this.refCardStack}
            className={`card-stack narrative-mode
            ${isCardstack ? "" : " folded"}`}
          >
            {this.renderNarrativeContent()}
          </div>
        );
      }
    }

    return <div />;
  }
}

function mapStateToProps(state) {
  return {
    narrative: selectors.selectActiveNarrative(state),
    selected: selectors.selectSelected(state),
    sourceError: state.app.errors.source,
    language: state.app.language,
    isCardstack: state.app.flags.isCardstack,
    isLoading: state.app.flags.isFetchingSources,
    cardUI: state.ui.card,
    colors: state.ui.coloring.colors,
    coloringSet: state.app.associations.coloringSet,
    features: state.features,
  };
}

export default connect(mapStateToProps)(CardStack);


================================================
FILE: src/components/controls/CategoriesListPanel.jsx
================================================
import { marked } from "marked";
import PanelTree from "./atoms/PanelTree";
import { ASSOCIATION_MODES } from "../../common/constants";

const CategoriesListPanel = ({
  categories,
  activeCategories,
  onCategoryFilter,
  language,
  title,
  description,
}) => {
  return (
    <div className="react-innertabpanel">
      <h2>{title}</h2>
      <p
        dangerouslySetInnerHTML={{
          __html: marked(description),
        }}
      />
      <PanelTree
        data={categories}
        activeValues={activeCategories}
        onSelect={onCategoryFilter}
        type={ASSOCIATION_MODES.CATEGORY}
      />
    </div>
  );
};

export default CategoriesListPanel;


================================================
FILE: src/components/controls/DownloadButton.jsx
================================================
import { Component } from "react";
import dayjs from "dayjs";
import { Parser } from "@json2csv/plainjs";
import copy from "../../common/data/copy.json";
import { downloadAsFile } from "../../common/utilities";
import config from "../../../config";

export class DownloadButton extends Component {
  onDownload(format, domain) {
    let filename = `ukr-civharm-${dayjs().format("YYYY-MM-DD")}`;
    if (format === "api") {
      console.log(config["API_DATA"])
      window.open(config["API_DATA"], '_blank');
    }else if (format === "csv") {
      let outputData = this.getCsvData(domain);
      downloadAsFile(`${filename}.csv`, outputData);
    } else if (format === "json") {
      let outputData = this.getJsonData(domain);
      downloadAsFile(`${filename}.json`, outputData);
    }
  }
  getCsvData(domain) {
    const { events, sources } = domain;
    const exportEvents = events.map((e) => {
      return {
        id: e.civId,
        date: e.date,
        latitude: e.latitude,
        longitude: e.longitude,
        location: e.location,
        description: e.description,
        sources: e.sources.map((s) => sources[s].paths[0]).join(","),
        associations: e.associations
          .map((a) => a.filter_paths.join("="))
          .join(","),
      };
    });
    const parser = new Parser();
    return parser.parse(exportEvents, { flatten: true });
  }
  getJsonData(domain) {
    const { events, sources } = domain;
    const exportEvents = events.map((e) => {
      return {
        id: e.civId,
        date: e.date,
        latitude: e.latitude,
        longitude: e.longitude,
        location: e.location,
        description: e.description,
        sources: e.sources.map((id) => {
          const s = sources[id];
          return {
            id,
            path: s.paths[0],
            description: s.description,
          };
        }),
        filters: e.associations.map((a) => {
          return {
            key: a.filter_paths[0],
            value: a.filter_paths[1],
          };
        }),
      };
    });
    return JSON.stringify(exportEvents);
  }
  render() {
    const { language, domain, format } = this.props;
    const textByFormat = copy[language].toolbar.download.panel.formats[format];

    let description = <span className="download-description">{textByFormat.description}</span>;
    
    if(format=='api'){
      const endpoint = config["API_DATA"];
      description = <span className="download-description">{textByFormat.description} <a href={endpoint}>Copy API endpoint link from here.</a></span>
    }

    return (
      <div className="download-row">
        <span
          className="download-button"
          key={`download-${format}`}
          onClick={() => this.onDownload(format, domain)}
        >
          <i className="material-icons">{"download"}</i>
          <span className="tab-caption">{textByFormat.label}</span>
        </span>
       {description}
      </div>
    );
  }
}


================================================
FILE: src/components/controls/DownloadPanel.jsx
================================================
import { DownloadButton } from "./DownloadButton";

const DownloadPanel = ({ language, title, description, domain }) => {
  return (
    <div className="react-innertabpanel">
      <div className="sticky-header">
        <h2>{title}</h2>
      </div>
      <div
        className="panel-description"
        dangerouslySetInnerHTML={{
          __html: description,
        }}
      />
      <hr />
      <DownloadButton language={language} domain={domain} format="api" />
      <DownloadButton language={language} domain={domain} format="csv" />
      <DownloadButton language={language} domain={domain} format="json" />
    </div>
  );
};

export default DownloadPanel;


================================================
FILE: src/components/controls/FilterListPanel.jsx
================================================
import Checkbox from "../atoms/Checkbox";
import { marked } from "marked";
import {
  aggregateFilterPaths,
  getFilterIdxFromColorSet,
  getPathLeaf,
} from "../../common/utilities";

/** recursively get an array of node keys to toggle */
function getFiltersToToggle(filter, activeFilters) {
  const [key, children] = filter;

  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;
}

function FilterListPanel({
  filters,
  activeFilters,
  onSelectFilter,
  language,
  coloringSet,
  filterColors,
  title,
  description,
}) {
  function createNodeComponent(filter, depth) {
    const [key, children] = filter;
    const pathLeaf = getPathLeaf(key);
    const matchingKeys = getFiltersToToggle(filter, activeFilters);
    const idxFromColorSet = getFilterIdxFromColorSet(key, coloringSet);
    const assignedColor =
      idxFromColorSet !== -1 && activeFilters.includes(key)
        ? filterColors[idxFromColorSet]
        : "";

    const styles = {
      color: assignedColor,
      marginLeft: `${depth * 20}px`,
    };

    return (
      <li
        key={pathLeaf.replace(/ /g, "_")}
        className="filter-filter"
        style={{ ...styles }}
      >
        <Checkbox
          label={pathLeaf}
          isActive={activeFilters.includes(key)}
          onClickCheckbox={(e) => {
            e.preventDefault();
            onSelectFilter(key, matchingKeys);
          }}
          color={assignedColor}
        />
        {Object.keys(children).length > 0 ? (
          <ul>
            {Object.entries(children).map((filter) =>
              createNodeComponent(filter, depth + 1)
            )}
          </ul>
        ) : null}
      </li>
    );
  }

  function renderTree(filters) {
    const aggregatedFilterPaths = aggregateFilterPaths(filters);

    return (
      <div className="scrolled-area">
        {Object.entries(aggregatedFilterPaths).map((filter) =>
          createNodeComponent(filter, 0)
        )}
      </div>
    );
  }

  return (
    <div>
      <div className="sticky-header">
        <h2>{title}</h2>
      </div>
      <div
        className="panel-description"
        dangerouslySetInnerHTML={{
          __html: marked(description),
        }}
      />
      {renderTree(filters)}
    </div>
  );
}

export default FilterListPanel;


================================================
FILE: src/components/controls/FullScreenToggle.jsx
================================================
import { Component } from "react";
import screenfull from "screenfull";
import { ToolbarButton } from "./atoms/ToolbarButton";
import copy from "../../common/data/copy.json";

export class FullscreenToggle extends Component {
  constructor(props) {
    super(props);

    this.onFullscreenStateChange = this.onFullscreenStateChange.bind(this);

    this.state = {
      isFullscreen: screenfull.isFullscreen,
    };
  }

  componentDidMount() {
    if (screenfull.on) screenfull.on("change", this.onFullscreenStateChange);
  }

  componentWillUnmount() {
    if (screenfull.off) screenfull.off("change", this.onFullscreenStateChange);
  }

  onFullscreenStateChange(evt) {
    this.setState({ isFullscreen: screenfull.isFullscreen });
  }

  onToggleFullscreen() {
    screenfull.toggle().catch(console.warn);
  }

  render() {
    if (!screenfull.isEnabled) return null;

    const { language } = this.props;
    const { isFullscreen } = this.state;

    return (
      <ToolbarButton
        isActive={isFullscreen}
        label={
          isFullscreen
            ? copy[language].toolbar.fullscreen_exit
            : copy[language].toolbar.fullscreen_enter
        }
        iconKey={isFullscreen ? "fullscreen_exit" : "fullscreen"}
        onClick={this.onToggleFullscreen}
      />
    );
  }
}


================================================
FILE: src/components/controls/NarrativeControls.jsx
================================================
import Card from "./atoms/NarrativeCard";
import Adjust from "./atoms/NarrativeAdjust";
import Close from "./atoms/NarrativeClose";

const NarrativeControls = ({ narrative, methods }) => {
  if (!narrative) return null;

  const { current, steps } = narrative;
  const prevExists = current !== 0;
  const nextExists = current < steps.length - 1;

  return (
    <>
      <Card narrative={narrative} />
      <Adjust
        isDisabled={!prevExists}
        direction="left"
        onClickHandler={methods.onPrev}
      />
      <Adjust
        isDisabled={!nextExists}
        direction="right"
        onClickHandler={methods.onNext}
      />
      <Close
        onClickHandler={() => methods.onSelectNarrative(null)}
        closeMsg="-- exit from narrative --"
      />
    </>
  );
};

export default NarrativeControls;


================================================
FILE: src/components/controls/Search.jsx
================================================
import { Component } from "react";

import { bindActionCreators } from "redux";
import { connect } from "react-redux";
import * as actions from "../../actions";

import SearchRow from "./atoms/SearchRow.jsx";

class Search extends Component {
  constructor(props) {
    super(props);

    this.state = {
      isFolded: true,
    };
    this.onButtonClick = this.onButtonClick.bind(this);
    this.updateSearchQuery = this.updateSearchQuery.bind(this);
  }

  onButtonClick() {
    this.setState((prevState) => {
      return { isFolded: !prevState.isFolded };
    });
  }

  updateSearchQuery(e) {
    const queryString = e.target.value;
    this.props.actions.updateSearchQuery(queryString);
  }

  render() {
    let searchResults;

    const searchAttributes = ["description", "location", "category", "date"];

    if (!this.props.queryString) {
      searchResults = [];
    } else {
      searchResults = this.props.events.filter((event) =>
        searchAttributes.some((attribute) =>
          event[attribute]
            .toLowerCase()
            .includes(this.props.queryString.toLowerCase())
        )
      );
    }

    return (
      <div
        className={
          "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
          className={
            "search-bar-overlay" + (this.state.isFolded ? " folded" : "")
          }
        >
          <div className="search-input-container">
            <input
              className="search-bar-input"
              onChange={this.updateSearchQuery}
              type="text"
            />
            <i
              id="close-search-overlay"
              className="material-icons"
              onClick={this.onButtonClick}
            >
              close
            </i>
          </div>
          <div className="search-results">
            {searchResults.map((result) => {
              return (
                <SearchRow
                  onSearchRowClick={this.props.onSearchRowClick}
                  eventObj={result}
                  query={this.props.queryString}
                />
              );
            })}
          </div>
        </div>
      </div>
    );
  }
}

function mapDispatchToProps(dispatch) {
  return {
    actions: bindActionCreators(actions, dispatch),
  };
}

export default connect((state) => state, mapDispatchToProps)(Search);


================================================
FILE: src/components/controls/ShapesListPanel.jsx
================================================
import { marked } from "marked";
import PanelTree from "./atoms/PanelTree";
import { mapStyleByShape } from "../../common/utilities";
import { SHAPE } from "../../common/constants";

const ShapesListPanel = ({
  shapes,
  activeShapes,
  onShapeFilter,
  language,
  title,
  description,
}) => {
  const styledShapes = mapStyleByShape(shapes, activeShapes);
  return (
    <div className="react-innertabpanel">
      <h2>{title}</h2>
      <p
        dangerouslySetInnerHTML={{
          __html: marked(description),
        }}
      />
      <PanelTree
        data={styledShapes}
        activeValues={activeShapes}
        onSelect={onShapeFilter}
        type={SHAPE}
      />
    </div>
  );
};

export default ShapesListPanel;


================================================
FILE: src/components/controls/atoms/Button.jsx
================================================
import PropTypes from "prop-types";

/**
 * Primary UI component for user interaction
 */
export const Button = ({
  primary,
  backgroundColor,
  borderRadius,
  size,
  label,
  normalCursor,
  ...props
}) => {
  const mode = primary ? "button--primary" : "button--secondary";
  return (
    <button
      type="button"
      className={[
        "button",
        `button--${size}`,
        mode,
        normalCursor ? "no-hover" : "",
      ].join(" ")}
      style={{ backgroundColor: backgroundColor, borderRadius: borderRadius }}
      {...props}
    >
      {label}
    </button>
  );
};

Button.propTypes = {
  /**
   * Is this the principal call to action on the page?
   */
  primary: PropTypes.bool,
  /**
   * What background color to use
   */
  backgroundColor: PropTypes.string,
  /**
   * How much rounded are they?
   */
  borderRadius: PropTypes.string,
  /**
   * How large should the button be?
   */
  size: PropTypes.oneOf(["small", "medium", "large"]),
  /**
   * Button contents
   */
  label: PropTypes.string.isRequired,
  /**
   * Optional click handler
   */
  onClick: PropTypes.func,
};

Button.defaultProps = {
  backgroundColor: "red",
  borderRadius: "0%",
  primary: false,
  size: "medium",
  onClick: undefined,
};

const CardButton = ({
  text,
  color = "#000",
  onClick = () => {},
  normalCursor,
}) => (
  <Button
    size={"small"}
    backgroundColor={color}
    borderRadius={"12px"}
    primary={false}
    label={text}
    onClick={onClick}
    normalCursor={normalCursor}
  />
);

export default CardButton;


================================================
FILE: src/components/controls/atoms/Caret.jsx
================================================
const CardCaret = ({ isOpen, toggle }) => {
  let classes = isOpen ? "arrow-down" : "arrow-down folded";

  return (
    <div className="card-toggle" onClick={toggle}>
      <p>
        <i className={classes} />
      </p>
    </div>
  );
};

export default CardCaret;


================================================
FILE: src/components/controls/atoms/CustomField.jsx
================================================
import { marked } from "marked";

// TODO could this be a security vulnerability?
const CardCustomField = ({ title, value }) => (
  <div className="card-cell">
    {title ? <h4>{title}</h4> : null}
    <div dangerouslySetInnerHTML={{ __html: marked(`${value}`) }} />
  </div>
);

export default CardCustomField;


================================================
FILE: src/components/controls/atoms/Media.jsx
================================================
import { useRef } from "react";
import { useCallback } from "react";
import { typeForPath } from "../../../common/utilities";
import TwitterTweet from'./TwitterTweet'
import TelegramPostEmbed from "./TelegramEmbed";

const TITLE_LENGTH = 50;
// TODO should videos
//    - play inline
//    - appear zoomed out/in
//    - only show cover image and then lightbox when clicked
//    - show video control plane?
// TODO landscape image doesn't fit in box properly
const Media = ({ cardIdx, src, title, graphic }) => {
  const wrapGraphic = (content) => {
    if (!graphic) return content;

    const contentId = `graphic${cardIdx}`;
    const overlayId = `overlay-${contentId}`;
    return (
      <div>
        <div className={`card-cell media source-graphic ${overlayId}`}>
          <h4
            onClick={() => {
              Array.from(document.querySelectorAll("." + contentId)).map(
                (o) => (o.style.display = "block")
              );
              // Array.from(document.querySelectorAll("." + overlayId)).map(o => o.remove())
              Array.from(document.querySelectorAll("." + overlayId)).map(
                (o) => (o.style.display = "none")
              );
              // document.getElementById(contentId).style.display = "block"
            }}
          >
            Graphic content
            <br />
            Click here to show
          </h4>
        </div>
        <span className={contentId} style={{ display: "none" }}>
          {content}
        </span>
      </div>
    );
  };
  const videoRef = useRef();
  const onVideoStart = useCallback(() => {
    return videoRef.current?.play();
  }, []);
  const onVideoStop = useCallback(() => {
    return videoRef.current?.pause();
  }, []);

  const type = typeForPath(src);
  const formattedTitle =
    title && title.length > TITLE_LENGTH
      ? `${title.slice(0, TITLE_LENGTH + 1)}...`
      : title;

  switch (type) {
    case "Video":
      return wrapGraphic(
        <div className="card-cell media">
          {title && <h4 title={title}>{formattedTitle}</h4>}
          <video
            onMouseEnter={onVideoStart}
            onMouseLeave={onVideoStop}
            ref={videoRef}
            // controls
            // controlsList="nodownload noremoteplayback"
            disablePictureInPicture
          >
            <source src={src} />
          </video>
        </div>
      );
    case "Image":
      return wrapGraphic(
        <div className="card-cell media">
          {title && <h4 title={title}>{formattedTitle}</h4>}
          <div className="img-wrapper">
            <img
              src={src}
              alt="an inline photograph for the event card component"
            />
          </div>
        </div>
      );

    case "Telegram":
      if (src.includes("https://t.me/c/")) {
        return <div>Private <a href={src}>telegram post</a></div>
      }
      try {
        return wrapGraphic(
          <div className="card-cell media embedded">
            <TelegramPostEmbed src={src} />
          </div>
        );
      } catch (error) {
        return <div>Unable to display <a href={src}>telegram post</a></div>
      }

    case "Tweet":
      const tweetIdRegex =
        /https?:\/\/(mobile\.){0,1}twitter.com\/[0-9a-zA-Z_]{1,20}\/status\/([0-9]*)/;
      const match = tweetIdRegex.exec(src);
      if (!match || match.length < 2) {
        return null;
      }
      const tweetId = match[match.length - 1];
      try {
        return wrapGraphic(
          <div className="card-cell media embedded">
            <TwitterTweet
              tweetId={tweetId}
              options={{ conversation: "none" }}
            />
          </div>
        );
      } catch (error) {
        return <div>Unable to display <a href={src}>tweet</a></div>
      }
    default:
      if (src === "HIDDEN") {
        return (
          <div className="card-cell media source-hidden">
            <h4>
              Source hidden
              <br />
              Privacy concerns
            </h4>
          </div>
        );
      } else {
        return <div><a href={src}>other source</a></div>
      }
  }
};

export default Media;


================================================
FILE: src/components/controls/atoms/NarrativeAdjust.jsx
================================================
const Adjust = ({ isDisabled, direction, onClickHandler }) => {
  return (
    <div
      className={`narrative-adjust ${direction}`}
      onClick={!isDisabled ? onClickHandler : null}
    >
      <i className={`material-icons ${isDisabled ? "disabled" : ""}`}>
        {`chevron_${direction}`}
      </i>
    </div>
  );
};

export default Adjust;


================================================
FILE: src/components/controls/atoms/NarrativeCard.jsx
================================================
import { connect } from "react-redux";
import { selectActiveNarrative } from "../../../selectors";

function NarrativeCard({ narrative }) {
  // no display if no 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">
              {current + 1}/{steps.length}
            </div>
          </div>
          <div>
            <h3>{narrative.label}</h3>
          </div>
        </div>

        {/* <i className='material-icons left'>location_on</i> */}
        {/* {_renderActions(current, steps)} */}
        <div className="narrative-info-desc">
          <p>{narrative.description}</p>
        </div>
      </div>
    );
  } else {
    return null;
  }
}

function mapStateToProps(state) {
  return {
    narrative: selectActiveNarrative(state),
  };
}
export default connect(mapStateToProps)(NarrativeCard);


================================================
FILE: src/components/controls/atoms/NarrativeClose.jsx
================================================
const Close = ({ onClickHandler, closeMsg }) => {
  return (
    <div className="narrative-close" onClick={onClickHandler}>
      <button className="side-menu-burg is-active">
        <span />
      </button>
      <div className="close-text">{closeMsg}</div>
    </div>
  );
};

export default Close;


================================================
FILE: src/components/controls/atoms/PanelTree.jsx
================================================
import Checkbox from "../../atoms/Checkbox";
import { ASSOCIATION_MODES } from "../../../common/constants";

const PanelTree = ({ data, activeValues, onSelect, type }) => {
  // If the parent panel is of type 'CATEGORY': filter on title. If panel is 'SHAPE': filter on id
  const onSelectionType = type === ASSOCIATION_MODES.CATEGORY ? "title" : "id";
  return (
    <div>
      {data.map((val) => {
        return (
          <li
            key={val.title.replace(/ /g, "_")}
            className="filter-filter active"
          >
            <Checkbox
              label={val.title}
              isActive={activeValues.includes(val[onSelectionType])}
              onClickCheckbox={() => onSelect(val[onSelectionType])}
              styleProps={val.styles}
            />
          </li>
        );
      })}
    </div>
  );
};

export default PanelTree;


================================================
FILE: src/components/controls/atoms/SearchRow.jsx
================================================
const SearchRow = ({ query, eventObj, onSearchRowClick }) => {
  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>
    );
  }

  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;
  }

  return (
    <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>
          <p>{getHighlightedText(location, query)}</p>
        </div>
      </div>
      <p>
        {getShortDescription(description, query).map((match) => {
          return (
            <span>
              {getHighlightedText(match, query)}...
              <br />
            </span>
          );
        })}
      </p>
    </div>
  );
};

export default SearchRow;


================================================
FILE: src/components/controls/atoms/TelegramEmbed.jsx
================================================
/*
 * Adapted from https://github.com/cudr/react-telegram-embed
 */
import { Component } from "react";

const styles = {
  width: "100%",
  frameBorder: "0",
  scrolling: "no",
  border: "none",
  overflow: "hidden",
};

const containerStyles = {};

/**
 * Simple Component for Telegram embedding
 * @extends Component
 */

class TelegramEmbed extends Component {
  constructor(props) {
    super(props);

    this.state = {
      src: this.props.src,
      id: "",
      height: "80px",
    };
    this.messageHandler = this.messageHandler.bind(this);
    this.urlObj = document.createElement("a");
  }

  componentDidMount() {
    window.addEventListener("message", this.messageHandler);

    this.iFrame.addEventListener("load", () => {
      this.checkFrame(this.state.id);
    });
  }

  componentWillUnmount() {
    window.removeEventListener("message", this.messageHandler);
  }

  messageHandler({ data, source }) {
    if (
      !data ||
      typeof data !== "string" ||
      source !== this.iFrame.contentWindow
    ) {
      return;
    }

    const action = JSON.parse(data);

    if (action.event === "resize" && action.height) {
      this.setState({
        height: action.height + "px",
      });
    }
  }

  checkFrame(id) {
    this.iFrame.contentWindow.postMessage(
      JSON.stringify({ event: "visible", frame: id }),
      "*"
    );
  }

  UNSAFE_componentWillReceiveProps({ src }) {
    if (this.state.src !== src) {
      this.urlObj.href = src;
      const id = `telegram-post${this.urlObj.pathname.replace(
        /[^a-z0-9_]/gi,
        "-"
      )}`;

      this.setState({ src, id }, () => this.checkFrame(id));
    }
  }

  render() {
    const { src, height } = this.state;
    const { container } = this.props;
    const embedSrc = new URL(src);
    embedSrc.searchParams.append("embed", "1");

    return (
      <div data-sharing-id={container} style={containerStyles}>
        <iframe
          title={src}
          ref={(node) => (this.iFrame = node)}
          src={embedSrc.toString()}
          height={height}
          id={
            "telegram-post" + this.urlObj.pathname.replace(/[^a-z0-9_]/gi, "-")
          }
          style={styles}
        />
      </div>
    );
  }
}

export default TelegramEmbed;


================================================
FILE: src/components/controls/atoms/Text.jsx
================================================
import { useState } from "react";

const CardText = ({ title, value, hoverValue = null }) => {
  const [showHover, setShowHover] = useState(false);

  return (
    <div className="card-cell">
      {title ? <h4>{title}</h4> : null}
      <div
        className="card-cell__text"
        style={{
          width: `fit-content`,
        }}
      >
        <div
          onMouseOver={() => hoverValue && setShowHover(true)}
          onMouseOut={() => hoverValue && setShowHover(false)}
        >
          {showHover ? (
            <span
              style={{
                pointerEvents: `none`,
                opacity: 0.8,
              }}
            >
              <em>{hoverValue}</em>
            </span>
          ) : (
            <div
              style={{
                pointerEvents: `none`,
                display: `inline-block`,
                height: `1.1rem`,
                borderBottom: hoverValue && `1px rgb(235, 68, 62) dashed`,
              }}
            >
              {value}
            </div>
          )}
        </div>
        {/* {!showHover && value} */}
      </div>
    </div>
  );
};

export default CardText;


================================================
FILE: src/components/controls/atoms/Time.jsx
================================================
import copy from "../../../common/data/copy.json";
import { isNotNullNorUndefined } from "../../../common/utilities";

const CardTime = ({ title = "Timestamp", timelabel, language, precision }) => {
  const unknownLang = copy[language].cardstack.unknown_time;

  if (isNotNullNorUndefined(timelabel)) {
    return (
      <div className="card-cell">
        {/* <i className="material-icons left">today</i> */}
        <h4>{title}</h4>
        {timelabel}
        {precision && precision !== "" ? ` - ${precision}` : null}
      </div>
    );
  } else {
    return (
      <div className="card-cell">
        {/* <i className="material-icons left">today</i> */}
        <h4>{title}</h4>
        {unknownLang}
      </div>
    );
  }
};

export default CardTime;


================================================
FILE: src/components/controls/atoms/ToolbarButton.jsx
================================================
export function ToolbarButton({ isActive, iconKey, onClick, label }) {
  return (
    <div
      className={isActive ? "toolbar-tab active" : "toolbar-tab"}
      key={iconKey}
      onClick={onClick}
    >
      <i className="material-icons">{iconKey}</i>
      <div className="tab-caption">{label}</div>
    </div>
  );
}

// https://github.com/reactjs/react-tabs#set-tabsrole
ToolbarButton.tabsRole = "Tab";


================================================
FILE: src/components/controls/atoms/TwitterTweet.jsx
================================================
import React from 'react';
import script from 'scriptjs';
/**
 * vite changes led to this error: https://github.com/saurabhnemade/react-twitter-embed/issues/105
 * applied same solution, and dropped https://github.com/saurabhnemade/react-twitter-embed
 */

var methodName$5 = 'createTweet';
var twitterWidgetJs = 'https://platform.twitter.com/widgets.js';

const TwitterTweet = (props) => {
	var ref = React.useRef(null);

	var _React$useState = React.useState(true),
		loading = _React$useState[0],
		setLoading = _React$useState[1];

	React.useEffect(function () {
		var isComponentMounted = true;

		script(twitterWidgetJs, 'twitter-embed', function () {
			if (!window.twttr) {
				console.error('Failure to load window.twttr, aborting load');
				return;
			}

			if (isComponentMounted) {
				if (!window.twttr.widgets[methodName$5]) {
					console.error("Method " + methodName$5 + " is not present anymore in twttr.widget api");
					return;
				}

				window.twttr.widgets[methodName$5](props.tweetId, ref === null || ref === void 0 ? void 0 : ref.current, props.options).then(function (element) {
					setLoading(false);

					if (props.onLoad) {
						props.onLoad(element);
					}
				});
			}
		});
		return function () {
			isComponentMounted = false;
		};
	}, []);
	return React.createElement(React.Fragment, null, loading && React.createElement(React.Fragment, null, props.placeholder), React.createElement("div", {
		ref: ref
	}));
};
export default TwitterTweet;


================================================
FILE: src/components/space/Space.jsx
================================================
import MapCarto from "./carto/Map";
// import Map3d from "./3d/Map";

const Space = (props) => {
  switch (props.kind) {
    // case "3d":
    //   return <Map3d {...props} />;
    default:
      return <MapCarto {...props} />;
  }
};

export default Space;


================================================
FILE: src/components/space/carto/Map.jsx
================================================
/* global L */
import { bindActionCreators } from "redux";
import "leaflet";
import { createRef, Component } from "react";
import { flushSync } from "react-dom";
import Supercluster from "supercluster";
import { isMobileOnly } from "react-device-detect";

import { connect } from "react-redux";
import config from "../../../../config";
import * as actions from "../../../actions";
import * as selectors from "../../../selectors";

import Sites from "./atoms/Sites";
import Regions from "./atoms/Regions";
import Events from "./atoms/Events";
import Clusters from "./atoms/Clusters";
import SelectedEvents from "./atoms/SelectedEvents";
import Portal from "../../Portal";
import Narratives from "./atoms/Narratives";
import DefsMarkers from "./atoms/DefsMarkers";
import SatelliteOverlayToggle from "./atoms/SatelliteOverlayToggle";
import LoadingOverlay from "../../atoms/Loading";

import {
  mapClustersToLocations,
  isIdentical,
  isLatitude,
  isLongitude,
  calculateTotalClusterPoints,
  calcClusterSize,
} from "../../../common/utilities";

class Map extends 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 = createRef();
    this.map = null;
    this.superclusterIndex = null;
    this.tileLayer = null;
    this.state = {
      mapTransformX: 0,
      mapTransformY: 0,
      indexLoaded: false,
      clusters: [],
    };
    this.styleLocation = this.styleLocation.bind(this);
    this.syncMapViewToUrl = this.syncMapViewToUrl.bind(this);
  }

  componentDidMount() {
    if (this.map === null) {
      this.initializeMap();
      this.initializeTileLayer();
    }
    window.dispatchEvent(new Event("resize"));
  }

  componentDidUpdate(prevProps) {
    if (prevProps.ui.tile !== this.props.ui.tile && this.map) {
      this.initializeTileLayer();
    }
  }

  UNSAFE_componentWillReceiveProps(nextProps) {
    if (!isIdentical(nextProps.domain.locations, this.props.domain.locations)) {
      this.loadClusterData(nextProps.domain.locations);
    }

    // Update map view if anchor or zoom changed (e.g., from URL state rehydration)
    const { anchor: nextAnchor, startZoom: nextZoom } = nextProps.app.map;
    const { anchor: currAnchor, startZoom: currZoom } = this.props.app.map;
    if (
      this.map &&
      (!isIdentical(nextAnchor, currAnchor) || nextZoom !== currZoom)
    ) {
      const currentCenter = this.map.getCenter();
      const currentZoom = this.map.getZoom();
      // Only update if the values actually differ from the current map state
      if (
        Math.abs(currentCenter.lat - nextAnchor[0]) > 0.00001 ||
        Math.abs(currentCenter.lng - nextAnchor[1]) > 0.00001 ||
        currentZoom !== nextZoom
      ) {
        this.map.setView(nextAnchor, nextZoom, { animate: false });
      }
    }

    // Set appropriate zoom for narrative
    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;

        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,
              },
            }
          );
        }
      }
    }
  }

  /**
   * Initialize the base tile layer based on the ui state
   */
  initializeTileLayer() {
    if (!this.map) {
      return;
    }

    const url = this.props.ui.tile;
    /**
     * If a tile layer already exists, we update its url. Otherwise, we create it and add it to the map.
     */
    if (this.tileLayer) {
      this.tileLayer.setUrl(url);
    } else {
      this.tileLayer = L.tileLayer(url);
      this.tileLayer.addTo(this.map);
    }
  }

  initializeMap() {
    /**
     * Creates a Leaflet map
     */
    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);
    // This assumes your map is the constant 'map'
    map.attributionControl.addAttribution(
      `Tiles © Esri — Esri, DeLorme, NAVTEQ, TomTom, Intermap, iPC, USGS, FAO, NPS, NRCAN, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, GeoBase, Kadaster NL, Ordnance Survey, Esri Japan, METI, Esri China (Hong Kong), and the GIS User Community`
    );

    // Initialize supercluster index
    this.superclusterIndex = new Supercluster(clusterConfig);

    map.keyboard.disable();
    map.zoomControl.remove();

    map.on("moveend", () => {
      this.alignLayers();
      this.updateClusters();
      this.syncMapViewToUrl();
    });

    map.on("zoomend viewreset", () => {
      this.map.dragging.enable();
      this.map.doubleClickZoom.enable();
      this.map.scrollWheelZoom.enable();
      flushSync(() => {
        this.alignLayers();
        this.updateClusters();
      });
    });
    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;
  }

  getMapDetails() {
    const bounds = this.map.getBounds();
    const bbox = [
      bounds.getWest(),
      bounds.getSouth(),
      bounds.getEast(),
      bounds.getNorth(),
    ];
    const zoom = this.map.getZoom();
    return [bbox, zoom];
  }

  syncMapViewToUrl() {
    if (!this.map) return;
    const center = this.map.getCenter();
    const zoom = this.map.getZoom();
    // Round to 5 decimal places for cleaner URLs
    const lat = Math.round(center.lat * 100000) / 100000;
    const lng = Math.round(center.lng * 100000) / 100000;
    this.props.actions.updateMapView(lat, lng, zoom);
  }

  updateClusters() {
    const [bbox, zoom] = this.getMapDetails();
    if (this.superclusterIndex && this.state.indexLoaded) {
      this.setState({
        clusters: this.superclusterIndex.getClusters(bbox, zoom),
      });
    }
  }

  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);
        if (validCoordinates) {
          const feature = {
            type: "Feature",
            properties: {
              cluster: false,
              id: loc.label,
            },
            geometry: {
              type: "Point",
              coordinates: [longitude, latitude],
            },
          };
          acc.push(feature);
        }
        return acc;
      }, []);
      this.superclusterIndex.load(convertedLocations);
      this.setState({ indexLoaded: true }, () => {
        this.updateClusters();
      });
    } else {
      this.setState({ clusters: [] });
    }
  }

  getClusterChildren(clusterId) {
    if (this.superclusterIndex) {
      try {
        const children = this.superclusterIndex.getLeaves(
          clusterId,
          Infinity,
          0
        );
        return mapClustersToLocations(children, this.props.domain.locations);
      } catch (err) {
        return [];
      }
    }
    return [];
  }

  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);
          if (children && children.length > 0) {
            children.forEach((child) => {
              const clusterPresent =
                acc.findIndex((item) => item.id === cl.id) >= 0;
              if (selectedIds.includes(child.id) && !clusterPresent) {
                acc.push(cl);
              }
            });
          }
        }
        return acc;
      }, []);
    }
    return [];
  }

  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");

    // Offset with leaflet map transform boundaries
    this.setState({
      mapTransformX: +transform.split(",")[4],
      mapTransformY: +transform.split(",")[5].split(")")[0],
    });
  }

  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,
    };
  }

  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.dragging.disable();
    this.map.doubleClickZoom.disable();
    this.map.scrollWheelZoom.disable();
    this.map.flyTo(new L.LatLng(latitude, longitude), zoomToFly);
  }

  getClientDims() {
    const boundingClient = document
      .querySelector(`#${this.props.ui.dom.map}`)
      .getBoundingClientRect();

    return {
      width: boundingClient.width,
      height: boundingClient.height,
    };
  }

  renderTiles() {
    const pane = this.map.getPanes().overlayPane;
    const { width, height } = this.getClientDims();

    return this.map ? (
      <Portal node={pane}>
        <svg
          ref={this.svgRef}
          width={width}
          height={height}
          style={{
            transform: `translate3d(${-this.state.mapTransformX}px, ${-this
              .state.mapTransformY}px, 0)`,
          }}
          className="leaflet-svg"
        />
      </Portal>
    ) : null;
  }

  renderSites() {
    return (
      <Sites
        sites={this.props.domain.sites}
        projectPoint={this.projectPoint}
        isEnabled={this.props.app.views.sites}
      />
    );
  }

  renderRegions() {
    return (
      <Regions
Download .txt
gitextract_fsuxlpf2/

├── .dockerignore
├── .eslintrc.js
├── .github/
│   ├── ISSUE_TEMPLATE.md
│   └── workflows/
│       ├── cd.yml
│       └── ci.yml
├── .gitignore
├── .prettierrc
├── .travis.yml
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE.md
├── README.md
├── config.js
├── docs/
│   ├── configuration.md
│   └── custom-covers.md
├── example.config.js
├── index.html
├── package.json
├── public/
│   ├── CNAME
│   └── index.html
├── src/
│   ├── actions/
│   │   └── index.js
│   ├── common/
│   │   ├── constants.js
│   │   ├── data/
│   │   │   ├── copy.json
│   │   │   └── es-MX.json
│   │   ├── global.js
│   │   └── utilities.js
│   ├── components/
│   │   ├── App.jsx
│   │   ├── InfoPopup.jsx
│   │   ├── Layout.jsx
│   │   ├── Notification.jsx
│   │   ├── Portal.jsx
│   │   ├── TemplateCover.jsx
│   │   ├── Toolbar.jsx
│   │   ├── atoms/
│   │   │   ├── Checkbox.jsx
│   │   │   ├── CoeventIcon.jsx
│   │   │   ├── ColoredMarkers.jsx
│   │   │   ├── Content.jsx
│   │   │   ├── Controls.jsx
│   │   │   ├── CoverIcon.jsx
│   │   │   ├── InfoIcon.jsx
│   │   │   ├── Loading.jsx
│   │   │   ├── Md.jsx
│   │   │   ├── Media.jsx
│   │   │   ├── NoSource.jsx
│   │   │   ├── Popup.jsx
│   │   │   ├── RefreshIcon.jsx
│   │   │   ├── RouteIcon.jsx
│   │   │   ├── SitesIcon.jsx
│   │   │   ├── Spinner.jsx
│   │   │   └── StaticPage.jsx
│   │   ├── controls/
│   │   │   ├── BottomActions.jsx
│   │   │   ├── Card.jsx
│   │   │   ├── CardStack.jsx
│   │   │   ├── CategoriesListPanel.jsx
│   │   │   ├── DownloadButton.jsx
│   │   │   ├── DownloadPanel.jsx
│   │   │   ├── FilterListPanel.jsx
│   │   │   ├── FullScreenToggle.jsx
│   │   │   ├── NarrativeControls.jsx
│   │   │   ├── Search.jsx
│   │   │   ├── ShapesListPanel.jsx
│   │   │   └── atoms/
│   │   │       ├── Button.jsx
│   │   │       ├── Caret.jsx
│   │   │       ├── CustomField.jsx
│   │   │       ├── Media.jsx
│   │   │       ├── NarrativeAdjust.jsx
│   │   │       ├── NarrativeCard.jsx
│   │   │       ├── NarrativeClose.jsx
│   │   │       ├── PanelTree.jsx
│   │   │       ├── SearchRow.jsx
│   │   │       ├── TelegramEmbed.jsx
│   │   │       ├── Text.jsx
│   │   │       ├── Time.jsx
│   │   │       ├── ToolbarButton.jsx
│   │   │       └── TwitterTweet.jsx
│   │   ├── space/
│   │   │   ├── Space.jsx
│   │   │   └── carto/
│   │   │       ├── Map.jsx
│   │   │       └── atoms/
│   │   │           ├── Clusters.jsx
│   │   │           ├── DefsMarkers.jsx
│   │   │           ├── Events.jsx
│   │   │           ├── Narratives.jsx
│   │   │           ├── Regions.jsx
│   │   │           ├── SatelliteOverlayToggle.jsx
│   │   │           ├── SelectedEvents.jsx
│   │   │           ├── Sites.jsx
│   │   │           └── __tests__/
│   │   │               └── SatelliteOverlayToggle.spec.jsx
│   │   └── time/
│   │       ├── Axis.jsx
│   │       ├── Categories.jsx
│   │       ├── Timeline.jsx
│   │       └── atoms/
│   │           ├── Clip.jsx
│   │           ├── DatetimeBar.jsx
│   │           ├── DatetimeDot.jsx
│   │           ├── DatetimePentagon.jsx
│   │           ├── DatetimeSquare.jsx
│   │           ├── DatetimeStar.jsx
│   │           ├── DatetimeTriangle.jsx
│   │           ├── Events.jsx
│   │           ├── Handles.jsx
│   │           ├── Header.jsx
│   │           ├── Labels.jsx
│   │           ├── Markers.jsx
│   │           ├── Project.jsx
│   │           └── ZoomControls.jsx
│   ├── index.jsx
│   ├── reducers/
│   │   ├── __tests__/
│   │   │   ├── index.spec.js
│   │   │   └── ui.spec.js
│   │   ├── app.js
│   │   ├── domain.js
│   │   ├── features.js
│   │   ├── index.js
│   │   ├── root.js
│   │   ├── ui.js
│   │   └── validate/
│   │       ├── associationsSchema.js
│   │       ├── eventSchema.js
│   │       ├── regionSchema.js
│   │       ├── shapeSchema.js
│   │       ├── siteSchema.js
│   │       ├── sourceSchema.js
│   │       └── validators.js
│   ├── scss/
│   │   ├── _burger.scss
│   │   ├── _icons.scss
│   │   ├── _variables.scss
│   │   ├── button.scss
│   │   ├── card.scss
│   │   ├── cardstack.scss
│   │   ├── common.scss
│   │   ├── cover.scss
│   │   ├── header.scss
│   │   ├── infopopup.scss
│   │   ├── loading.scss
│   │   ├── main.scss
│   │   ├── map.scss
│   │   ├── mediaplayer.scss
│   │   ├── narrativecard.scss
│   │   ├── notification.scss
│   │   ├── overlay.scss
│   │   ├── popup.scss
│   │   ├── satelliteoverlaytoggle.scss
│   │   ├── search.scss
│   │   ├── tabs.scss
│   │   ├── timeline.scss
│   │   ├── toolbar.scss
│   │   └── video.scss
│   ├── selectors/
│   │   ├── __tests__/
│   │   │   └── timeline.spec.js
│   │   ├── helpers.js
│   │   └── index.js
│   ├── store/
│   │   ├── index.js
│   │   ├── initial.js
│   │   └── plugins/
│   │       └── urlState/
│   │           ├── applyUrlState.js
│   │           ├── index.js
│   │           ├── middleware.js
│   │           ├── schema.js
│   │           └── urlState.js
│   └── test/
│       └── App.test.jsx
├── test/
│   ├── __mocks__/
│   │   ├── fileMock.js
│   │   └── styleMock.js
│   └── setup.js
└── vite.config.js
Download .txt
SYMBOL INDEX (406 symbols across 53 files)

FILE: src/actions/index.js
  constant EVENT_DATA_URL (line 5) | const EVENT_DATA_URL = urlFromEnv("EVENTS_EXT");
  constant ASSOCIATIONS_URL (line 6) | const ASSOCIATIONS_URL = urlFromEnv("ASSOCIATIONS_EXT");
  constant SOURCES_URL (line 7) | const SOURCES_URL = urlFromEnv("SOURCES_EXT");
  constant SITES_URL (line 8) | const SITES_URL = urlFromEnv("SITES_EXT");
  constant REGIONS_URL (line 9) | const REGIONS_URL = urlFromEnv("REGIONS_EXT");
  constant SHAPES_URL (line 10) | const SHAPES_URL = urlFromEnv("SHAPES_EXT");
  function fetchDomain (line 15) | function fetchDomain() {
  constant FETCH_ERROR (line 136) | const FETCH_ERROR = "FETCH_ERROR";
  function fetchError (line 137) | function fetchError(message) {
  constant UPDATE_DOMAIN (line 144) | const UPDATE_DOMAIN = "UPDATE_DOMAIN";
  function updateDomain (line 145) | function updateDomain(payload) {
  function fetchSource (line 152) | function fetchSource(source) {
  constant UPDATE_HIGHLIGHTED (line 177) | const UPDATE_HIGHLIGHTED = "UPDATE_HIGHLIGHTED";
  function updateHighlighted (line 178) | function updateHighlighted(highlighted) {
  constant UPDATE_SELECTED (line 185) | const UPDATE_SELECTED = "UPDATE_SELECTED";
  function updateSelected (line 186) | function updateSelected(selected) {
  constant UPDATE_DISTRICT (line 193) | const UPDATE_DISTRICT = "UPDATE_DISTRICT";
  function updateDistrict (line 194) | function updateDistrict(district) {
  constant CLEAR_FILTER (line 201) | const CLEAR_FILTER = "CLEAR_FILTER";
  function clearFilter (line 202) | function clearFilter(filter) {
  constant TOGGLE_ASSOCIATIONS (line 209) | const TOGGLE_ASSOCIATIONS = "TOGGLE_ASSOCIATIONS";
  function toggleAssociations (line 210) | function toggleAssociations(association, value, shouldColor) {
  constant TOGGLE_SHAPES (line 219) | const TOGGLE_SHAPES = "TOGGLE_SHAPES";
  function toggleShapes (line 220) | function toggleShapes(shape) {
  constant SET_LOADING (line 227) | const SET_LOADING = "SET_LOADING";
  function setLoading (line 228) | function setLoading() {
  constant SET_NOT_LOADING (line 234) | const SET_NOT_LOADING = "SET_NOT_LOADING";
  function setNotLoading (line 235) | function setNotLoading() {
  constant SET_INITIAL_CATEGORIES (line 241) | const SET_INITIAL_CATEGORIES = "SET_INITIAL_CATEGORIES";
  function setInitialCategories (line 242) | function setInitialCategories(values) {
  constant SET_INITIAL_SHAPES (line 249) | const SET_INITIAL_SHAPES = "SET_INITIAL_SHAPES";
  function setInitialShapes (line 250) | function setInitialShapes(values) {
  constant UPDATE_TIMERANGE (line 257) | const UPDATE_TIMERANGE = "UPDATE_TIMERANGE";
  function updateTimeRange (line 258) | function updateTimeRange(timerange) {
  constant UPDATE_DIMENSIONS (line 265) | const UPDATE_DIMENSIONS = "UPDATE_DIMENSIONS";
  function updateDimensions (line 266) | function updateDimensions(dims) {
  constant UPDATE_NARRATIVE (line 273) | const UPDATE_NARRATIVE = "UPDATE_NARRATIVE";
  function updateNarrative (line 274) | function updateNarrative(narrative) {
  constant UPDATE_NARRATIVE_STEP_IDX (line 281) | const UPDATE_NARRATIVE_STEP_IDX = "UPDATE_NARRATIVE_STEP_IDX";
  function updateNarrativeStepIdx (line 282) | function updateNarrativeStepIdx(idx) {
  constant UPDATE_SOURCE (line 289) | const UPDATE_SOURCE = "UPDATE_SOURCE";
  function updateSource (line 290) | function updateSource(source) {
  constant UPDATE_COLORING_SET (line 297) | const UPDATE_COLORING_SET = "UPDATE_COLORING_SET";
  function updateColoringSet (line 298) | function updateColoringSet(coloringSet) {
  constant UPDATE_TICKS (line 305) | const UPDATE_TICKS = "UPDATE_TICKS";
  function updateTicks (line 306) | function updateTicks(ticks) {
  constant TOGGLE_SITES (line 315) | const TOGGLE_SITES = "TOGGLE_SITES";
  function toggleSites (line 316) | function toggleSites() {
  constant TOGGLE_FETCHING_DOMAIN (line 322) | const TOGGLE_FETCHING_DOMAIN = "TOGGLE_FETCHING_DOMAIN";
  function toggleFetchingDomain (line 323) | function toggleFetchingDomain() {
  constant TOGGLE_FETCHING_SOURCES (line 329) | const TOGGLE_FETCHING_SOURCES = "TOGGLE_FETCHING_SOURCES";
  function toggleFetchingSources (line 330) | function toggleFetchingSources() {
  constant TOGGLE_LANGUAGE (line 336) | const TOGGLE_LANGUAGE = "TOGGLE_LANGUAGE";
  function toggleLanguage (line 337) | function toggleLanguage(language) {
  constant CLOSE_TOOLBAR (line 344) | const CLOSE_TOOLBAR = "CLOSE_TOOLBAR";
  function closeToolbar (line 345) | function closeToolbar() {
  constant TOGGLE_INFOPOPUP (line 351) | const TOGGLE_INFOPOPUP = "TOGGLE_INFOPOPUP";
  function toggleInfoPopup (line 352) | function toggleInfoPopup() {
  constant TOGGLE_INTROPOPUP (line 358) | const TOGGLE_INTROPOPUP = "TOGGLE_INTROPOPUP";
  function toggleIntroPopup (line 359) | function toggleIntroPopup() {
  constant TOGGLE_NOTIFICATIONS (line 365) | const TOGGLE_NOTIFICATIONS = "TOGGLE_NOTIFICATIONS";
  function toggleNotifications (line 366) | function toggleNotifications() {
  constant MARK_NOTIFICATIONS_READ (line 372) | const MARK_NOTIFICATIONS_READ = "MARK_NOTIFICATIONS_READ";
  function markNotificationsRead (line 373) | function markNotificationsRead() {
  constant TOGGLE_COVER (line 379) | const TOGGLE_COVER = "TOGGLE_COVER";
  function toggleCover (line 380) | function toggleCover() {
  constant TOGGLE_TILE_OVERLAY (line 386) | const TOGGLE_TILE_OVERLAY = "TOGGLE_TILE_OVERLAY";
  function toggleTileOverlay (line 387) | function toggleTileOverlay() {
  constant UPDATE_SEARCH_QUERY (line 393) | const UPDATE_SEARCH_QUERY = "UPDATE_SEARCH_QUERY";
  function updateSearchQuery (line 394) | function updateSearchQuery(searchQuery) {
  constant FETCH_SOURCE_ERROR (line 403) | const FETCH_SOURCE_ERROR = "FETCH_SOURCE_ERROR";
  function fetchSourceError (line 404) | function fetchSourceError(msg) {
  constant TOGGLE_SATELLITE_VIEW (line 411) | const TOGGLE_SATELLITE_VIEW = "TOGGLE_SATELLITE_VIEW";
  function toggleSatelliteView (line 412) | function toggleSatelliteView() {
  constant REHYDRATE_STATE (line 418) | const REHYDRATE_STATE = "REHYDRATE_STATE";
  function rehydrateState (line 419) | function rehydrateState() {
  constant UPDATE_MAP_VIEW (line 425) | const UPDATE_MAP_VIEW = "UPDATE_MAP_VIEW";
  function updateMapView (line 426) | function updateMapView(lat, lng, zoom) {

FILE: src/common/constants.js
  constant ASSOCIATION_MODES (line 1) | const ASSOCIATION_MODES = {
  constant SHAPE (line 7) | const SHAPE = "SHAPE";
  constant DEFAULT_TAB_ICONS (line 9) | const DEFAULT_TAB_ICONS = {
  constant AVAILABLE_SHAPES (line 17) | const AVAILABLE_SHAPES = {
  constant POLYGON_CLIP_PATH (line 27) | const POLYGON_CLIP_PATH = {
  constant DEFAULT_CHECKBOX_COLOR (line 34) | const DEFAULT_CHECKBOX_COLOR = "#ffffff";

FILE: src/common/utilities.js
  constant DATE_FMT (line 12) | const DATE_FMT = config.DATE_FMT ?? "MM/DD/YYYY";
  constant TIME_FMT (line 13) | const TIME_FMT = config.TIME_FMT ?? "HH:mm";
  function getPathLeaf (line 17) | function getPathLeaf(path) {
  function calcDatetime (line 22) | function calcDatetime(date, time) {
  function getCoordinatesForPercent (line 28) | function getCoordinatesForPercent(radius, percent) {
  function zipColorsToPercentages (line 41) | function zipColorsToPercentages(colors, percentages) {
  function areEqual (line 57) | function areEqual(arr1, arr2) {
  function isNotNullNorUndefined (line 70) | function isNotNullNorUndefined(variable) {
  function capitalize (line 77) | function capitalize(string) {
  function trimAndEllipse (line 81) | function trimAndEllipse(string, stringNum) {
  function aggregateFilterPaths (line 95) | function aggregateFilterPaths(filters) {
  function getFilterAncestors (line 130) | function getFilterAncestors(filter) {
  function getImmediateFilterParent (line 146) | function getImmediateFilterParent(filter) {
  function getFilterSiblings (line 154) | function getFilterSiblings(allFilters, filterParent, filterKey) {
  function addToColoringSet (line 176) | function addToColoringSet(coloringSet, filters) {
  function removeFromColoringSet (line 187) | function removeFromColoringSet(coloringSet, filters) {
  function getEventCategories (line 196) | function getEventCategories(event, activeCategories) {
  function createFilterPathString (line 210) | function createFilterPathString(filter) {
  function insetSourceFrom (line 220) | function insetSourceFrom(allSources) {
  function injectSource (line 241) | function injectSource(id) {
  constant API_ROOT (line 253) | const API_ROOT =
  function urlFromEnv (line 256) | function urlFromEnv(ext) {
  function toggleFlagAC (line 268) | function toggleFlagAC(flag) {
  function selectTypeFromPath (line 278) | function selectTypeFromPath(path) {
  function typeForPath (line 297) | function typeForPath(path) {
  function selectTypeFromPathWithPoster (line 327) | function selectTypeFromPathWithPoster(path, poster) {
  function isIdentical (line 331) | function isIdentical(obj1, obj2) {
  function calcOpacity (line 335) | function calcOpacity(num) {
  function calcClusterOpacity (line 344) | function calcClusterOpacity(pointCount, totalPoints) {
  function calcClusterSize (line 351) | function calcClusterSize(pointCount, totalPoints) {
  function calculateTotalClusterPoints (line 360) | function calculateTotalClusterPoints(clusters) {
  function isLatitude (line 369) | function isLatitude(lat) {
  function isLongitude (line 373) | function isLongitude(lng) {
  function mapClustersToLocations (line 377) | function mapClustersToLocations(clusters, locations) {
  function calculateColorPercentages (line 391) | function calculateColorPercentages(set, coloringSet) {
  function getFilterIdxFromColorSet (line 431) | function getFilterIdxFromColorSet(filter, coloringSet) {
  function binarySearch (line 456) | function binarySearch(ar, el, compareFn) {
  function makeNiceDate (line 473) | function makeNiceDate(datetime) {
  function setD3Locale (line 490) | function setD3Locale() {
  function mapStyleByShape (line 505) | function mapStyleByShape(shapes, activeShapes) {
  function mapCategoriesToPaths (line 525) | function mapCategoriesToPaths(categories, panelCategories) {
  function getCategoryIdxs (line 540) | function getCategoryIdxs(panelCategories, startingIdx) {
  function getFilterIdx (line 554) | function getFilterIdx(
  function downloadAsFile (line 565) | function downloadAsFile(filename, content) {
  function isEmptyObject (line 584) | function isEmptyObject(o) {

FILE: src/components/App.jsx
  class App (line 5) | class App extends Component {
    method render (line 6) | render() {

FILE: src/components/Layout.jsx
  class Dashboard (line 27) | class Dashboard extends Component {
    method constructor (line 28) | constructor(props) {
    method componentDidMount (line 42) | componentDidMount() {
    method handleHighlight (line 56) | handleHighlight(highlighted) {
    method handleViewSource (line 60) | handleViewSource(source) {
    method findEventIdx (line 64) | findEventIdx(theEvent) {
    method handleSelect (line 71) | handleSelect(selected, axis) {
    method getCategoryColor (line 122) | getCategoryColor(category) {
    method setNarrative (line 135) | setNarrative(narrative) {
    method setNarrativeFromFilters (line 143) | setNarrativeFromFilters(withSteps) {
    method selectNarrativeStep (line 183) | selectNarrativeStep(idx) {
    method onKeyDown (line 212) | onKeyDown(e) {
    method renderIntroPopup (line 249) | renderIntroPopup(styles) {
    method render (line 281) | render() {
  function mapDispatchToProps (line 401) | function mapDispatchToProps(dispatch) {

FILE: src/components/Notification.jsx
  class Notification (line 3) | class Notification extends Component {
    method constructor (line 4) | constructor(props) {
    method toggleDetails (line 11) | toggleDetails() {
    method renderItems (line 15) | renderItems(items) {
    method renderNotificationContent (line 29) | renderNotificationContent(notification) {
    method render (line 42) | render() {

FILE: src/components/Portal.jsx
  class Portal (line 4) | class Portal extends Component {
    method render (line 5) | render() {

FILE: src/components/TemplateCover.jsx
  constant MEDIA_HIDDEN (line 8) | const MEDIA_HIDDEN = -2;
  class TemplateCover (line 16) | class TemplateCover extends Component {
    method constructor (line 17) | constructor(props) {
    method getVideo (line 25) | getVideo(index, headerEndIndex) {
    method onVideoClickHandler (line 35) | onVideoClickHandler(index) {
    method renderFeature (line 46) | renderFeature() {
    method renderHeaderVideos (line 98) | renderHeaderVideos() {
    method renderButton (line 115) | renderButton(button, yellow) {
    method renderMediaOverlay (line 125) | renderMediaOverlay() {
    method render (line 145) | render() {
  function mapStateToProps (line 264) | function mapStateToProps(state) {

FILE: src/components/Toolbar.jsx
  class Toolbar (line 29) | class Toolbar extends Component {
    method constructor (line 30) | constructor(props) {
    method selectTab (line 36) | selectTab(selected) {
    method onSelectFilter (line 44) | onSelectFilter(key, matchingKeys) {
    method renderClosePanel (line 84) | renderClosePanel() {
    method goToNarrative (line 95) | goToNarrative(narrative) {
    method renderToolbarNarrativePanel (line 100) | renderToolbarNarrativePanel() {
    method renderToolbarCategoriesPanel (line 126) | renderToolbarCategoriesPanel() {
    method renderToolbarFilterPanel (line 154) | renderToolbarFilterPanel() {
    method renderToolbarShapePanel (line 172) | renderToolbarShapePanel() {
    method renderToolbarDownloadPanel (line 191) | renderToolbarDownloadPanel() {
    method renderToolbarTab (line 206) | renderToolbarTab(_selected, label, iconKey, key) {
    method renderToolbarCategoryTabs (line 222) | renderToolbarCategoryTabs(idxs) {
    method renderToolbarPanels (line 238) | renderToolbarPanels() {
    method renderToolbarNavs (line 256) | renderToolbarNavs() {
    method renderToolbarTabs (line 279) | renderToolbarTabs() {
    method render (line 369) | render() {
  function mapStateToProps (line 386) | function mapStateToProps(state) {
  function mapDispatchToProps (line 409) | function mapDispatchToProps(dispatch) {

FILE: src/components/atoms/ColoredMarkers.jsx
  function ColoredMarkers (line 3) | function ColoredMarkers({ radius, colorPercentMap, styles, className }) {

FILE: src/components/atoms/Content.jsx
  function renderMedia (line 11) | function renderMedia(media) {

FILE: src/components/atoms/Md.jsx
  class Md (line 5) | class Md extends Component {
    method constructor (line 6) | constructor(props) {
    method componentDidMount (line 11) | componentDidMount() {
    method render (line 26) | render() {

FILE: src/components/atoms/Media.jsx
  class SourceOverlay (line 12) | class SourceOverlay extends Component {
    method constructor (line 13) | constructor() {
    method getTypeCounts (line 19) | getTypeCounts(media) {
    method onShiftGallery (line 29) | onShiftGallery(shift) {
    method switchLanguage (line 41) | switchLanguage(idx) {
    method renderContent (line 45) | renderContent(source) {
    method renderIntlContent (line 125) | renderIntlContent() {
    method render (line 145) | render() {

FILE: src/components/controls/BottomActions.jsx
  function BottomActions (line 5) | function BottomActions(props) {

FILE: src/components/controls/Card.jsx
  function renderField (line 126) | function renderField(field, cardIdx) {
  function renderRow (line 220) | function renderRow(row, cardIdx, salt) {

FILE: src/components/controls/CardStack.jsx
  class CardStack (line 9) | class CardStack extends Component {
    method constructor (line 10) | constructor() {
    method componentDidUpdate (line 17) | componentDidUpdate() {
    method scrollToCard (line 25) | scrollToCard() {
    method renderCards (line 56) | renderCards(events, selections) {
    method renderSelectedCards (line 90) | renderSelectedCards() {
    method renderNarrativeCards (line 99) | renderNarrativeCards() {
    method renderCardStackHeader (line 108) | renderCardStackHeader() {
    method renderCardStackContent (line 127) | renderCardStackContent() {
    method renderNarrativeContent (line 138) | renderNarrativeContent() {
    method render (line 150) | render() {
  function mapStateToProps (line 181) | function mapStateToProps(state) {

FILE: src/components/controls/DownloadButton.jsx
  class DownloadButton (line 8) | class DownloadButton extends Component {
    method onDownload (line 9) | onDownload(format, domain) {
    method getCsvData (line 22) | getCsvData(domain) {
    method getJsonData (line 41) | getJsonData(domain) {
    method render (line 69) | render() {

FILE: src/components/controls/FilterListPanel.jsx
  function getFiltersToToggle (line 10) | function getFiltersToToggle(filter, activeFilters) {
  function FilterListPanel (line 22) | function FilterListPanel({

FILE: src/components/controls/FullScreenToggle.jsx
  class FullscreenToggle (line 6) | class FullscreenToggle extends Component {
    method constructor (line 7) | constructor(props) {
    method componentDidMount (line 17) | componentDidMount() {
    method componentWillUnmount (line 21) | componentWillUnmount() {
    method onFullscreenStateChange (line 25) | onFullscreenStateChange(evt) {
    method onToggleFullscreen (line 29) | onToggleFullscreen() {
    method render (line 33) | render() {

FILE: src/components/controls/Search.jsx
  class Search (line 9) | class Search extends Component {
    method constructor (line 10) | constructor(props) {
    method onButtonClick (line 20) | onButtonClick() {
    method updateSearchQuery (line 26) | updateSearchQuery(e) {
    method render (line 31) | render() {
  function mapDispatchToProps (line 94) | function mapDispatchToProps(dispatch) {

FILE: src/components/controls/atoms/Media.jsx
  constant TITLE_LENGTH (line 7) | const TITLE_LENGTH = 50;

FILE: src/components/controls/atoms/NarrativeCard.jsx
  function NarrativeCard (line 4) | function NarrativeCard({ narrative }) {
  function mapStateToProps (line 34) | function mapStateToProps(state) {

FILE: src/components/controls/atoms/SearchRow.jsx
  function getHighlightedText (line 3) | function getHighlightedText(text, highlight) {
  function getShortDescription (line 21) | function getShortDescription(text, searchQuery) {

FILE: src/components/controls/atoms/TelegramEmbed.jsx
  class TelegramEmbed (line 21) | class TelegramEmbed extends Component {
    method constructor (line 22) | constructor(props) {
    method componentDidMount (line 34) | componentDidMount() {
    method componentWillUnmount (line 42) | componentWillUnmount() {
    method messageHandler (line 46) | messageHandler({ data, source }) {
    method checkFrame (line 64) | checkFrame(id) {
    method UNSAFE_componentWillReceiveProps (line 71) | UNSAFE_componentWillReceiveProps({ src }) {
    method render (line 83) | render() {

FILE: src/components/controls/atoms/ToolbarButton.jsx
  function ToolbarButton (line 1) | function ToolbarButton({ isActive, iconKey, onClick, label }) {

FILE: src/components/space/carto/Map.jsx
  class Map (line 34) | class Map extends Component {
    method constructor (line 35) | constructor() {
    method componentDidMount (line 55) | componentDidMount() {
    method componentDidUpdate (line 63) | componentDidUpdate(prevProps) {
    method UNSAFE_componentWillReceiveProps (line 69) | UNSAFE_componentWillReceiveProps(nextProps) {
    method initializeTileLayer (line 127) | initializeTileLayer() {
    method initializeMap (line 144) | initializeMap() {
    method getMapDetails (line 196) | getMapDetails() {
    method syncMapViewToUrl (line 208) | syncMapViewToUrl() {
    method updateClusters (line 218) | updateClusters() {
    method loadClusterData (line 227) | loadClusterData(locations) {
    method getClusterChildren (line 257) | getClusterChildren(clusterId) {
    method getSelectedClusters (line 273) | getSelectedClusters() {
    method alignLayers (line 297) | alignLayers() {
    method projectPoint (line 314) | projectPoint(location) {
    method onClusterSelect (line 322) | onClusterSelect({ id, latitude, longitude }) {
    method getClientDims (line 339) | getClientDims() {
    method renderTiles (line 350) | renderTiles() {
    method renderSites (line 370) | renderSites() {
    method renderRegions (line 380) | renderRegions() {
    method renderNarratives (line 391) | renderNarratives() {
    method styleLocation (line 419) | styleLocation(location) {
    method styleCluster (line 423) | styleCluster(cluster) {
    method renderEvents (line 427) | renderEvents() {
    method renderClusters (line 463) | renderClusters() {
    method renderSelected (line 483) | renderSelected() {
    method renderMarkers (line 522) | renderMarkers() {
    method render (line 530) | render() {
  function mapStateToProps (line 571) | function mapStateToProps(state) {
  function mapDispatchToProps (line 610) | function mapDispatchToProps(dispatch) {

FILE: src/components/space/carto/atoms/Clusters.jsx
  constant HIGHLIGHT_COLOR (line 15) | const HIGHLIGHT_COLOR = "#E31A1B";
  function Cluster (line 26) | function Cluster({
  function ClusterEvents (line 128) | function ClusterEvents({

FILE: src/components/space/carto/atoms/Events.jsx
  constant HIGHLIGHT_COLOR (line 11) | const HIGHLIGHT_COLOR = "#E31A1B";
  function MapEvents (line 13) | function MapEvents({

FILE: src/components/space/carto/atoms/Narratives.jsx
  function MapNarratives (line 12) | function MapNarratives({

FILE: src/components/space/carto/atoms/Regions.jsx
  function MapRegions (line 3) | function MapRegions({ svg, regions, projectPoint, styles }) {

FILE: src/components/space/carto/atoms/SelectedEvents.jsx
  class MapSelectedEvents (line 6) | class MapSelectedEvents extends Component {
    method renderMarker (line 7) | renderMarker(marker) {
    method render (line 35) | render() {

FILE: src/components/space/carto/atoms/Sites.jsx
  function MapSites (line 1) | function MapSites({ sites, projectPoint }) {

FILE: src/components/time/Axis.jsx
  constant TEXT_HEIGHT (line 5) | const TEXT_HEIGHT = 15;
  class TimelineAxis (line 7) | class TimelineAxis extends Component {
    method constructor (line 8) | constructor() {
    method componentDidUpdate (line 17) | componentDidUpdate() {
    method render (line 64) | render() {

FILE: src/components/time/Categories.jsx
  class TimelineCategories (line 4) | class TimelineCategories extends Component {
    method constructor (line 5) | constructor(props) {
    method componentDidUpdate (line 13) | componentDidUpdate() {
    method renderCategory (line 26) | renderCategory(cat, idx) {
    method render (line 56) | render() {

FILE: src/components/time/Timeline.jsx
  class Timeline (line 20) | class Timeline extends Component {
    method constructor (line 21) | constructor(props) {
    method componentDidMount (line 46) | componentDidMount() {
    method UNSAFE_componentWillReceiveProps (line 50) | UNSAFE_componentWillReceiveProps(nextProps) {
    method addEventListeners (line 86) | addEventListeners() {
    method makeScaleX (line 98) | makeScaleX() {
    method makeScaleY (line 107) | makeScaleY(categories, trackHeight, marginTop) {
    method componentDidUpdate (line 130) | componentDidUpdate(prevProps, prevState) {
    method getTimeScaleExtent (line 139) | getTimeScaleExtent() {
    method onClickArrow (line 145) | onClickArrow() {
    method computeDims (line 151) | computeDims() {
    method onMoveTime (line 176) | onMoveTime(direction) {
    method onCenterTime (line 197) | onCenterTime(newCentralTime) {
    method onSoftTimeRangeUpdate (line 213) | onSoftTimeRangeUpdate(timerange) {
    method onApplyZoom (line 221) | onApplyZoom(zoom) {
    method toggleTransition (line 254) | toggleTransition(isTransition) {
    method onDragStart (line 261) | onDragStart(event) {
    method onDrag (line 276) | onDrag(event) {
    method onDragEnd (line 302) | onDragEnd() {
    method getDatetimeX (line 307) | getDatetimeX(datetime) {
    method getY (line 311) | getY(event) {
    method styleDatetime (line 346) | styleDatetime(timestamp, category) {
    method onSelect (line 350) | onSelect(event) {
    method render (line 363) | render() {
  function mapStateToProps (line 496) | function mapStateToProps(state) {
  function mapDispatchToProps (line 531) | function mapDispatchToProps(dispatch) {

FILE: src/components/time/atoms/Events.jsx
  constant HIGHLIGHT_COLOR (line 18) | const HIGHLIGHT_COLOR = "#E31A1B";
  function renderDot (line 20) | function renderDot(event, styles, props) {
  function renderBar (line 47) | function renderBar(event, styles, props) {
  function renderDiamond (line 69) | function renderDiamond(event, styles, props) {
  function renderSquare (line 82) | function renderSquare(event, styles, props) {
  function renderTriangle (line 94) | function renderTriangle(event, styles, props) {
  function renderPentagon (line 106) | function renderPentagon(event, styles, props) {
  function renderStar (line 118) | function renderStar(event, styles, props) {
  function renderEvent (line 153) | function renderEvent(acc, event) {

FILE: src/components/time/atoms/Markers.jsx
  function renderMarker (line 21) | function renderMarker(acc, event) {

FILE: src/components/time/atoms/ZoomControls.jsx
  constant DEFAULT_ZOOM_LEVELS (line 1) | const DEFAULT_ZOOM_LEVELS = [
  function zoomIsActive (line 10) | function zoomIsActive(duration, extent, max) {
  function renderZoom (line 18) | function renderZoom(zoom, idx) {

FILE: src/reducers/app.js
  function updateHighlighted (line 37) | function updateHighlighted(appState, action) {
  function updateTicks (line 43) | function updateTicks(appState, action) {
  function updateSelected (line 56) | function updateSelected(appState, action) {
  function updateColoringSet (line 62) | function updateColoringSet(appState, action) {
  function updateNarrative (line 72) | function updateNarrative(appState, action) {
  function updateNarrativeStepIdx (line 142) | function updateNarrativeStepIdx(appState, action) {
  function toggleAssociations (line 151) | function toggleAssociations(appState, action) {
  function toggleShapes (line 175) | function toggleShapes(appState, action) {
  function clearFilter (line 190) | function clearFilter(appState, action) {
  function updateTimeRange (line 200) | function updateTimeRange(appState, action) {
  function updateDimensions (line 217) | function updateDimensions(appState, action) {
  function toggleLanguage (line 230) | function toggleLanguage(appState, action) {
  function updateSource (line 237) | function updateSource(appState, action) {
  function fetchError (line 244) | function fetchError(state, action) {
  function fetchSourceError (line 260) | function fetchSourceError(appState, action) {
  function setLoading (line 270) | function setLoading(appState) {
  function setNotLoading (line 277) | function setNotLoading(appState) {
  function setInitialCategories (line 284) | function setInitialCategories(appState, action) {
  function setInitialShapes (line 299) | function setInitialShapes(appState, action) {
  function updateSearchQuery (line 307) | function updateSearchQuery(appState, action) {
  function updateMapView (line 314) | function updateMapView(appState, action) {
  function app (line 325) | function app(appState = initial.app, action) {

FILE: src/reducers/domain.js
  function updateDomain (line 6) | function updateDomain(domainState, action) {
  function markNotificationsRead (line 13) | function markNotificationsRead(domainState, action) {
  function domain (line 23) | function domain(domainState = initial.domain, action) {

FILE: src/reducers/features.js
  function features (line 3) | function features(featureState = initial.features, action) {

FILE: src/reducers/index.js
  function decorateRootReducer (line 8) | function decorateRootReducer(rootReducer, reducer) {

FILE: src/reducers/root.js
  function rootReducer (line 4) | function rootReducer(state = {}, action) {

FILE: src/reducers/ui.js
  function ui (line 5) | function ui(uiState = initial.ui, action) {

FILE: src/reducers/validate/eventSchema.js
  function joiFromCustom (line 3) | function joiFromCustom(custom) {
  function createEventSchema (line 16) | function createEventSchema(custom) {

FILE: src/reducers/validate/validators.js
  function makeError (line 14) | function makeError(type, id, message) {
  function isValidDate (line 22) | function isValidDate(d) {
  function findDuplicateAssociations (line 26) | function findDuplicateAssociations(associations) {
  function validateDomain (line 49) | function validateDomain(domain, features) {

FILE: src/selectors/helpers.js
  function isTimeRangedIn (line 9) | function isTimeRangedIn(event, timeRange) {
  function shuffle (line 19) | function shuffle(a) {

FILE: src/selectors/index.js
  function mapFiltersToIds (line 427) | function mapFiltersToIds(arr, filterMapping) {

FILE: src/store/plugins/urlState/applyUrlState.js
  function applyUrlState (line 5) | function applyUrlState(state) {

FILE: src/store/plugins/urlState/middleware.js
  function urlStateMiddleware (line 4) | function urlStateMiddleware(store) {

FILE: src/store/plugins/urlState/schema.js
  constant SCHEMA_TYPES (line 20) | const SCHEMA_TYPES = {
  function isSchemaArray (line 29) | function isSchemaArray(schema) {
  constant SCHEMA (line 46) | const SCHEMA = Object.freeze({
  method dehydrate (line 51) | dehydrate(state) {
  method rehydrate (line 55) | rehydrate(nextState, { id }) {
  method dehydrate (line 77) | dehydrate() {
  method rehydrate (line 80) | rehydrate(nextState, { hid }) {
  method dehydrate (line 90) | dehydrate(state) {
  method rehydrate (line 93) | rehydrate(nextState, { range }) {
  method dehydrate (line 112) | dehydrate(state) {
  method rehydrate (line 116) | rehydrate(nextState, { filter }) {
  method dehydrate (line 132) | dehydrate(state) {
  method rehydrate (line 136) | rehydrate(state, { color }) {
  method dehydrate (line 152) | dehydrate(state) {
  method rehydrate (line 155) | rehydrate(state, { lat }) {
  method dehydrate (line 168) | dehydrate(state) {
  method rehydrate (line 171) | rehydrate(state, { lng }) {
  method dehydrate (line 184) | dehydrate(state) {
  method rehydrate (line 187) | rehydrate(state, { zoom }) {
  function mapFilterIdsToPaths (line 198) | function mapFilterIdsToPaths(filters) {

FILE: src/store/plugins/urlState/urlState.js
  class URLState (line 4) | class URLState {
    method constructor (line 5) | constructor() {
    method delete (line 10) | delete(key) {
    method set (line 19) | set(key, value) {
    method serialize (line 42) | serialize() {
    method deserialize (line 50) | deserialize() {
    method _decode (line 70) | _decode(schema, value) {
    method _encode (line 89) | _encode(schema, value) {

FILE: test/__mocks__/styleMock.js
  method get (line 5) | get(_, key) {
Condensed preview — 158 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (410K chars).
[
  {
    "path": ".dockerignore",
    "chars": 39,
    "preview": "node_modules/\nbuild/\nexample.config.js\n"
  },
  {
    "path": ".eslintrc.js",
    "chars": 481,
    "preview": "module.exports = {\n  root: true,\n  plugins: [],\n\n  parserOptions: {\n    sourceType: \"module\",\n    ecmaFeatures: {\n      "
  },
  {
    "path": ".github/ISSUE_TEMPLATE.md",
    "chars": 1221,
    "preview": "<!--- Hi, and thanks for contributing! -->\n\n<!--- Before opening a new issue, please search our existing issues to see i"
  },
  {
    "path": ".github/workflows/cd.yml",
    "chars": 464,
    "preview": "name: CD\non:\n  push:\n    branches: [ develop ]\n#  pull_request:\n#    branches: [ develop ]\n\njobs:\n  build:\n    runs-on: "
  },
  {
    "path": ".github/workflows/ci.yml",
    "chars": 494,
    "preview": "name: CI\non:\n  push:\n    branches: [ develop ]\n  pull_request:\n    branches: [ develop ]\n\njobs:\n  test:\n    runs-on: ubu"
  },
  {
    "path": ".gitignore",
    "chars": 188,
    "preview": ".idea/\nbuild/\nnode_modules/\n\ndev.config.js\n!config/webpack*.config.js\n!config/getHttpsConfig.js\n\n\ntags\ntags.lock\ntags.te"
  },
  {
    "path": ".prettierrc",
    "chars": 0,
    "preview": ""
  },
  {
    "path": ".travis.yml",
    "chars": 216,
    "preview": "language: node_js\nnode_js:\n  - \"stable\"\ncache:\n  directories:\n    - node_modules\nbefore_script:\n  - cp example.config.js"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 4643,
    "preview": "# Contributing to timemap \n\nHello! Thanks for being part of the Bellingcat Tech Community 💪 We really appreciate your id"
  },
  {
    "path": "Dockerfile",
    "chars": 275,
    "preview": "FROM mhart/alpine-node:10.11\n\nLABEL authors=\"Lachlan Kermode <lk@forensic-architecture.org>\"\n\n# Install app dependencies"
  },
  {
    "path": "LICENSE.md",
    "chars": 4392,
    "preview": "Do No Harm License\n\n**Preamble**\n\nMost software today is developed with little to no thought of how it will be used, or "
  },
  {
    "path": "README.md",
    "chars": 3041,
    "preview": "<h1 align=\"center\">Civilian Harm in Ukraine TimeMap</h1>\n\n<h2 align=\"center\">\n\tExplore it in <a href=\"https://ukraine.be"
  },
  {
    "path": "config.js",
    "chars": 13326,
    "preview": "const one_day = 1440;\n\nconst config = {\n  title: \"ukraine\",\n  display_title: \"Civilian Harm\\nin Ukraine\",\n  SERVER_ROOT:"
  },
  {
    "path": "docs/configuration.md",
    "chars": 5019,
    "preview": "# Configuration\n\n**NOTE: WIP. These settings are currently slightly out of date.**\n\nIn order to make timemap interesting"
  },
  {
    "path": "docs/custom-covers.md",
    "chars": 3494,
    "preview": "## Using Timemap with a Custom Cover\n\nBy default, instances of Timemap use no cover. By setting the `USE_COVER` flag\nto "
  },
  {
    "path": "example.config.js",
    "chars": 689,
    "preview": "const config = {\n  title: 'example',\n  display_title: 'example',\n  SERVER_ROOT: 'http://localhost:4040',\n  EVENTS_EXT: '"
  },
  {
    "path": "index.html",
    "chars": 1272,
    "preview": "<!doctype html>\n<html lang=\"en\">\n\n<head>\n  <meta charset=\"utf-8\">\n  <title>Civilian Harm in Ukraine Timemap - Bellingcat"
  },
  {
    "path": "package.json",
    "chars": 2102,
    "preview": "{\n  \"name\": \"timemap\",\n  \"version\": \"0.1.0\",\n  \"description\": \"\",\n  \"homepage\": \"https://bellingcat.github.io/ukraine-ti"
  },
  {
    "path": "public/CNAME",
    "chars": 22,
    "preview": "ukraine.bellingcat.com"
  },
  {
    "path": "public/index.html",
    "chars": 1820,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n    <meta charset=\"utf-8\" />\n    <title>Civilian Harm in Ukraine</title>\n    <m"
  },
  {
    "path": "src/actions/index.js",
    "chars": 10178,
    "preview": "import { urlFromEnv } from \"../common/utilities\";\n\n// TODO: relegate these URLs entirely to environment variables\n// con"
  },
  {
    "path": "src/common/constants.js",
    "chars": 848,
    "preview": "export const ASSOCIATION_MODES = {\n  CATEGORY: \"CATEGORY\",\n  NARRATIVE: \"NARRATIVE\",\n  FILTER: \"FILTER\",\n};\n\nexport cons"
  },
  {
    "path": "src/common/data/copy.json",
    "chars": 11075,
    "preview": "{\n  \"es-MX\": {\n    \"tiles\": {\n      \"default\": \"Mapa\",\n      \"satellite\": \"Sat\"\n    },\n    \"loading\": \"Cargando...\",\n   "
  },
  {
    "path": "src/common/data/es-MX.json",
    "chars": 632,
    "preview": "{\n  \"dateTime\": \"%x, %X\",\n  \"date\": \"%d/%m/%Y\",\n  \"time\": \"%-I:%M:%S %p\",\n  \"periods\": [\"AM\", \"PM\"],\n  \"days\": [\n    \"do"
  },
  {
    "path": "src/common/global.js",
    "chars": 290,
    "preview": "export const colors = {\n  fa_red: \"#eb443e\",\n  yellow: \"#ffd800\",\n  black: \"#000\",\n  white: \"#fff\",\n};\n\nconst exports = "
  },
  {
    "path": "src/common/utilities.js",
    "chars": 16694,
    "preview": "import config from \"../../config\";\nimport customParseFormat from \"dayjs/plugin/customParseFormat\";\nimport dayjs from \"da"
  },
  {
    "path": "src/components/App.jsx",
    "chars": 188,
    "preview": "import \"../scss/main.scss\";\nimport { Component } from \"react\";\nimport Layout from \"./Layout\";\n\nclass App extends Compone"
  },
  {
    "path": "src/components/InfoPopup.jsx",
    "chars": 349,
    "preview": "import Popup from \"./atoms/Popup\";\nimport copy from \"../common/data/copy.json\";\n\nconst Infopopup = ({ isOpen, onClose, l"
  },
  {
    "path": "src/components/Layout.jsx",
    "chars": 12424,
    "preview": "import { Component } from \"react\";\n\nimport { bindActionCreators } from \"redux\";\nimport { connect } from \"react-redux\";\ni"
  },
  {
    "path": "src/components/Notification.jsx",
    "chars": 1776,
    "preview": "import { Component } from \"react\";\n\nexport default class Notification extends Component {\n  constructor(props) {\n    sup"
  },
  {
    "path": "src/components/Portal.jsx",
    "chars": 267,
    "preview": "import { Component } from \"react\";\nimport ReactDOM from \"react-dom\";\n\nclass Portal extends Component {\n  render() {\n    "
  },
  {
    "path": "src/components/TemplateCover.jsx",
    "chars": 8034,
    "preview": "import { Component } from \"react\";\nimport { connect } from \"react-redux\";\nimport { Player } from \"video-react\";\nimport {"
  },
  {
    "path": "src/components/Toolbar.jsx",
    "chars": 12338,
    "preview": "import { Component } from \"react\";\nimport { connect } from \"react-redux\";\nimport { bindActionCreators } from \"redux\";\nim"
  },
  {
    "path": "src/components/atoms/Checkbox.jsx",
    "chars": 1229,
    "preview": "import { DEFAULT_CHECKBOX_COLOR } from \"../../common/constants\";\n\nconst Checkbox = ({ label, isActive, onClickCheckbox, "
  },
  {
    "path": "src/components/atoms/CoeventIcon.jsx",
    "chars": 1032,
    "preview": "const CoeventIcon = ({ isEnabled, toggleMapViews }) => {\n  return (\n    <button onClick={() => toggleMapViews(\"coevents\""
  },
  {
    "path": "src/components/atoms/ColoredMarkers.jsx",
    "chars": 1403,
    "preview": "import { getCoordinatesForPercent } from \"../../common/utilities\";\n\nfunction ColoredMarkers({ radius, colorPercentMap, s"
  },
  {
    "path": "src/components/atoms/Content.jsx",
    "chars": 2727,
    "preview": "import { Player } from \"video-react\";\nimport { Img } from \"react-image\";\nimport Md from \"./Md\";\nimport Spinner from \"../"
  },
  {
    "path": "src/components/atoms/Controls.jsx",
    "chars": 801,
    "preview": "const OverlayControls = ({ viewIdx, paths, onShiftHandler }) => {\n  const backArrow =\n    viewIdx !== 0 ? (\n      <div c"
  },
  {
    "path": "src/components/atoms/CoverIcon.jsx",
    "chars": 364,
    "preview": "const CoverIcon = ({ isActive, isDisabled, onClickHandler }) => {\n  let classes = isActive ? \"action-button enabled\" : \""
  },
  {
    "path": "src/components/atoms/InfoIcon.jsx",
    "chars": 364,
    "preview": "const CoverIcon = ({ isActive, isDisabled, onClickHandler }) => {\n  let classes = isActive ? \"action-button enabled\" : \""
  },
  {
    "path": "src/components/atoms/Loading.jsx",
    "chars": 587,
    "preview": "import copy from \"../../common/data/copy.json\";\n\nconst LoadingOverlay = ({ isLoading, language }) => {\n  let classes = \""
  },
  {
    "path": "src/components/atoms/Md.jsx",
    "chars": 1051,
    "preview": "import { Component } from \"react\";\nimport PropTypes from \"prop-types\";\nimport { marked } from \"marked\";\n\nclass Md extend"
  },
  {
    "path": "src/components/atoms/Media.jsx",
    "chars": 4636,
    "preview": "import { Component } from \"react\";\nimport { marked } from \"marked\";\nimport Content from \"./Content\";\nimport Controls fro"
  },
  {
    "path": "src/components/atoms/NoSource.jsx",
    "chars": 404,
    "preview": "const NoSource = ({ failedUrls }) => {\n  return (\n    <div className=\"no-source-container\">\n      <div className=\"no-sou"
  },
  {
    "path": "src/components/atoms/Popup.jsx",
    "chars": 894,
    "preview": "import { marked } from \"marked\";\n\nconst fontSize = window.innerWidth > 1000 ? 14 : 18;\n\nconst Popup = ({\n  content = [],"
  },
  {
    "path": "src/components/atoms/RefreshIcon.jsx",
    "chars": 604,
    "preview": "export default ({ isActive, isDisabled, onClickHandler }) => {\n  return (\n    <svg\n      className=\"reset\"\n      x=\"0px\""
  },
  {
    "path": "src/components/atoms/RouteIcon.jsx",
    "chars": 514,
    "preview": "const RouteIcon = ({ isEnabled, toggleMapViews }) => {\n  return (\n    <button onClick={() => toggleMapViews(\"routes\")}>\n"
  },
  {
    "path": "src/components/atoms/SitesIcon.jsx",
    "chars": 371,
    "preview": "const SitesIcon = ({ isActive, isDisabled, onClickHandler }) => {\n  let classes = isActive ? \"action-button enabled\" : \""
  },
  {
    "path": "src/components/atoms/Spinner.jsx",
    "chars": 232,
    "preview": "const Spinner = ({ small }) => {\n  return (\n    <div className={`spinner ${small ? \"small\" : \"\"}`}>\n      <div className"
  },
  {
    "path": "src/components/atoms/StaticPage.jsx",
    "chars": 169,
    "preview": "const StaticPage = ({ showing, children }) => (\n  <div className={`cover-container ${showing ? \"showing\" : \"\"}`}>\n    {c"
  },
  {
    "path": "src/components/controls/BottomActions.jsx",
    "chars": 1301,
    "preview": "import SitesIcon from \"../atoms/SitesIcon\";\nimport CoverIcon from \"../atoms/CoverIcon\";\n// import InfoIcon from \"../atom"
  },
  {
    "path": "src/components/controls/Card.jsx",
    "chars": 7192,
    "preview": "import { useState } from \"react\";\nimport CardText from \"./atoms/Text\";\nimport CardTime from \"./atoms/Time\";\nimport CardB"
  },
  {
    "path": "src/components/controls/CardStack.jsx",
    "chars": 4871,
    "preview": "import { createRef, Component } from \"react\";\nimport { connect } from \"react-redux\";\nimport { generateCardLayout, Card }"
  },
  {
    "path": "src/components/controls/CategoriesListPanel.jsx",
    "chars": 671,
    "preview": "import { marked } from \"marked\";\nimport PanelTree from \"./atoms/PanelTree\";\nimport { ASSOCIATION_MODES } from \"../../com"
  },
  {
    "path": "src/components/controls/DownloadButton.jsx",
    "chars": 2967,
    "preview": "import { Component } from \"react\";\nimport dayjs from \"dayjs\";\nimport { Parser } from \"@json2csv/plainjs\";\nimport copy fr"
  },
  {
    "path": "src/components/controls/DownloadPanel.jsx",
    "chars": 672,
    "preview": "import { DownloadButton } from \"./DownloadButton\";\n\nconst DownloadPanel = ({ language, title, description, domain }) => "
  },
  {
    "path": "src/components/controls/FilterListPanel.jsx",
    "chars": 2503,
    "preview": "import Checkbox from \"../atoms/Checkbox\";\nimport { marked } from \"marked\";\nimport {\n  aggregateFilterPaths,\n  getFilterI"
  },
  {
    "path": "src/components/controls/FullScreenToggle.jsx",
    "chars": 1304,
    "preview": "import { Component } from \"react\";\nimport screenfull from \"screenfull\";\nimport { ToolbarButton } from \"./atoms/ToolbarBu"
  },
  {
    "path": "src/components/controls/NarrativeControls.jsx",
    "chars": 826,
    "preview": "import Card from \"./atoms/NarrativeCard\";\nimport Adjust from \"./atoms/NarrativeAdjust\";\nimport Close from \"./atoms/Narra"
  },
  {
    "path": "src/components/controls/Search.jsx",
    "chars": 2550,
    "preview": "import { Component } from \"react\";\n\nimport { bindActionCreators } from \"redux\";\nimport { connect } from \"react-redux\";\ni"
  },
  {
    "path": "src/components/controls/ShapesListPanel.jsx",
    "chars": 734,
    "preview": "import { marked } from \"marked\";\nimport PanelTree from \"./atoms/PanelTree\";\nimport { mapStyleByShape } from \"../../commo"
  },
  {
    "path": "src/components/controls/atoms/Button.jsx",
    "chars": 1558,
    "preview": "import PropTypes from \"prop-types\";\n\n/**\n * Primary UI component for user interaction\n */\nexport const Button = ({\n  pri"
  },
  {
    "path": "src/components/controls/atoms/Caret.jsx",
    "chars": 269,
    "preview": "const CardCaret = ({ isOpen, toggle }) => {\n  let classes = isOpen ? \"arrow-down\" : \"arrow-down folded\";\n\n  return (\n   "
  },
  {
    "path": "src/components/controls/atoms/CustomField.jsx",
    "chars": 312,
    "preview": "import { marked } from \"marked\";\n\n// TODO could this be a security vulnerability?\nconst CardCustomField = ({ title, valu"
  },
  {
    "path": "src/components/controls/atoms/Media.jsx",
    "chars": 4172,
    "preview": "import { useRef } from \"react\";\nimport { useCallback } from \"react\";\nimport { typeForPath } from \"../../../common/utilit"
  },
  {
    "path": "src/components/controls/atoms/NarrativeAdjust.jsx",
    "chars": 350,
    "preview": "const Adjust = ({ isDisabled, direction, onClickHandler }) => {\n  return (\n    <div\n      className={`narrative-adjust $"
  },
  {
    "path": "src/components/controls/atoms/NarrativeCard.jsx",
    "chars": 1010,
    "preview": "import { connect } from \"react-redux\";\nimport { selectActiveNarrative } from \"../../../selectors\";\n\nfunction NarrativeCa"
  },
  {
    "path": "src/components/controls/atoms/NarrativeClose.jsx",
    "chars": 302,
    "preview": "const Close = ({ onClickHandler, closeMsg }) => {\n  return (\n    <div className=\"narrative-close\" onClick={onClickHandle"
  },
  {
    "path": "src/components/controls/atoms/PanelTree.jsx",
    "chars": 863,
    "preview": "import Checkbox from \"../../atoms/Checkbox\";\nimport { ASSOCIATION_MODES } from \"../../../common/constants\";\n\nconst Panel"
  },
  {
    "path": "src/components/controls/atoms/SearchRow.jsx",
    "chars": 1827,
    "preview": "const SearchRow = ({ query, eventObj, onSearchRowClick }) => {\n  const { description, location, date } = eventObj;\n  fun"
  },
  {
    "path": "src/components/controls/atoms/TelegramEmbed.jsx",
    "chars": 2258,
    "preview": "/*\n * Adapted from https://github.com/cudr/react-telegram-embed\n */\nimport { Component } from \"react\";\n\nconst styles = {"
  },
  {
    "path": "src/components/controls/atoms/Text.jsx",
    "chars": 1159,
    "preview": "import { useState } from \"react\";\n\nconst CardText = ({ title, value, hoverValue = null }) => {\n  const [showHover, setSh"
  },
  {
    "path": "src/components/controls/atoms/Time.jsx",
    "chars": 762,
    "preview": "import copy from \"../../../common/data/copy.json\";\nimport { isNotNullNorUndefined } from \"../../../common/utilities\";\n\nc"
  },
  {
    "path": "src/components/controls/atoms/ToolbarButton.jsx",
    "chars": 411,
    "preview": "export function ToolbarButton({ isActive, iconKey, onClick, label }) {\n  return (\n    <div\n      className={isActive ? \""
  },
  {
    "path": "src/components/controls/atoms/TwitterTweet.jsx",
    "chars": 1480,
    "preview": "import React from 'react';\nimport script from 'scriptjs';\n/**\n * vite changes led to this error: https://github.com/saur"
  },
  {
    "path": "src/components/space/Space.jsx",
    "chars": 258,
    "preview": "import MapCarto from \"./carto/Map\";\n// import Map3d from \"./3d/Map\";\n\nconst Space = (props) => {\n  switch (props.kind) {"
  },
  {
    "path": "src/components/space/carto/Map.jsx",
    "chars": 18213,
    "preview": "/* global L */\nimport { bindActionCreators } from \"redux\";\nimport \"leaflet\";\nimport { createRef, Component } from \"react"
  },
  {
    "path": "src/components/space/carto/atoms/Clusters.jsx",
    "chars": 5516,
    "preview": "import { useState } from \"react\";\nimport colors from \"../../../../common/global\";\nimport ColoredMarkers from \"../../../a"
  },
  {
    "path": "src/components/space/carto/atoms/DefsMarkers.jsx",
    "chars": 660,
    "preview": "const MapDefsMarkers = () => (\n  <svg>\n    <defs>\n      <marker\n        id=\"arrow\"\n        viewBox=\"0 0 6 6\"\n        ref"
  },
  {
    "path": "src/components/space/carto/atoms/Events.jsx",
    "chars": 4621,
    "preview": "import colors from \"../../../../common/global\";\nimport ColoredMarkers from \"../../../atoms/ColoredMarkers\";\nimport Porta"
  },
  {
    "path": "src/components/space/carto/atoms/Narratives.jsx",
    "chars": 5190,
    "preview": "import Portal from \"../../../Portal\";\n// import { concatStatic } from 'rxjs/operator/concat'\n// import { single } from '"
  },
  {
    "path": "src/components/space/carto/atoms/Regions.jsx",
    "chars": 1015,
    "preview": "import Portal from \"../../../Portal\";\n\nfunction MapRegions({ svg, regions, projectPoint, styles }) {\n  function renderRe"
  },
  {
    "path": "src/components/space/carto/atoms/SatelliteOverlayToggle.jsx",
    "chars": 1041,
    "preview": "import copy from \"../../../../common/data/copy.json\";\nimport { language } from \"../../../../common/utilities\";\nimport ma"
  },
  {
    "path": "src/components/space/carto/atoms/SelectedEvents.jsx",
    "chars": 1225,
    "preview": "import { Component } from \"react\";\nimport colors from \"../../../../common/global\";\nimport hash from \"object-hash\";\nimpor"
  },
  {
    "path": "src/components/space/carto/atoms/Sites.jsx",
    "chars": 576,
    "preview": "function MapSites({ sites, projectPoint }) {\n  function renderSite(site) {\n    const { x, y } = projectPoint([site.latit"
  },
  {
    "path": "src/components/space/carto/atoms/__tests__/SatelliteOverlayToggle.spec.jsx",
    "chars": 1457,
    "preview": "import { vi } from \"vitest\";\nimport { fireEvent, render, screen } from \"@testing-library/react\";\nimport SatelliteOverlay"
  },
  {
    "path": "src/components/time/Axis.jsx",
    "chars": 2001,
    "preview": "import { createRef, Component } from \"react\";\nimport { axisBottom, timeFormat, select } from \"d3\";\nimport { setD3Locale "
  },
  {
    "path": "src/components/time/Categories.jsx",
    "chars": 2061,
    "preview": "import { createRef, Component } from \"react\";\nimport { drag as d3Drag, select } from \"d3\";\n\nclass TimelineCategories ext"
  },
  {
    "path": "src/components/time/Timeline.jsx",
    "chars": 16611,
    "preview": "import { createRef, Component } from \"react\";\nimport { bindActionCreators } from \"redux\";\nimport { connect } from \"react"
  },
  {
    "path": "src/components/time/atoms/Clip.jsx",
    "chars": 256,
    "preview": "const TimelineClip = ({ dims }) => (\n  <clipPath id=\"clip\">\n    <rect\n      x={dims.marginLeft}\n      y=\"0\"\n      width="
  },
  {
    "path": "src/components/time/atoms/DatetimeBar.jsx",
    "chars": 807,
    "preview": "const DatetimeBar = ({\n  highlights,\n  events,\n  x,\n  y,\n  width,\n  height,\n  onSelect,\n  styleProps,\n  extraRender,\n}) "
  },
  {
    "path": "src/components/time/atoms/DatetimeDot.jsx",
    "chars": 277,
    "preview": "export default ({\n  category,\n  events,\n  x,\n  y,\n  r,\n  onSelect,\n  styleProps,\n  extraRender,\n}) => {\n  if (!y) return"
  },
  {
    "path": "src/components/time/atoms/DatetimePentagon.jsx",
    "chars": 428,
    "preview": "const DatetimePentagon = ({ x, y, r, transform, onSelect, styleProps }) => {\n  const s = (r * 2) / 3;\n  return (\n    <po"
  },
  {
    "path": "src/components/time/atoms/DatetimeSquare.jsx",
    "chars": 330,
    "preview": "const DatetimeSquare = ({\n  x,\n  y,\n  r,\n  transform,\n  onSelect,\n  styleProps,\n  extraRender,\n}) => {\n  return (\n    <r"
  },
  {
    "path": "src/components/time/atoms/DatetimeStar.jsx",
    "chars": 432,
    "preview": "const DatetimeStar = ({\n  x,\n  y,\n  r,\n  transform,\n  onSelect,\n  styleProps,\n  extraRender,\n}) => {\n  const s = (r * 2)"
  },
  {
    "path": "src/components/time/atoms/DatetimeTriangle.jsx",
    "chars": 384,
    "preview": "const DatetimeTriangle = ({ x, y, r, transform, onSelect, styleProps }) => {\n  const s = (r * 2) / 3;\n  return (\n    <po"
  },
  {
    "path": "src/components/time/atoms/Events.jsx",
    "chars": 6702,
    "preview": "import DatetimeBar from \"./DatetimeBar\";\nimport DatetimeSquare from \"./DatetimeSquare\";\nimport DatetimeStar from \"./Date"
  },
  {
    "path": "src/components/time/atoms/Handles.jsx",
    "chars": 487,
    "preview": "const TimelineHandles = ({ dims, onMoveTime, backward }) => {\n  if (backward === true) {\n    return (\n      <div classNa"
  },
  {
    "path": "src/components/time/atoms/Header.jsx",
    "chars": 1049,
    "preview": "import { makeNiceDate } from \"../../../common/utilities\";\n\nconst TimelineHeader = ({ title, from, to, onClick, hideInfo,"
  },
  {
    "path": "src/components/time/atoms/Labels.jsx",
    "chars": 744,
    "preview": "const TimelineLabels = ({ dims, timelabels }) => {\n  return (\n    <g>\n      <line\n        className=\"axisBoundaries\"\n   "
  },
  {
    "path": "src/components/time/atoms/Markers.jsx",
    "chars": 2709,
    "preview": "import colors from \"../../../common/global\";\nimport hash from \"object-hash\";\nimport {\n  getEventCategories,\n  isLatitude"
  },
  {
    "path": "src/components/time/atoms/Project.jsx",
    "chars": 467,
    "preview": "const Project = ({\n  offset,\n  id,\n  start,\n  end,\n  getX,\n  y,\n  dims,\n  colour,\n  eventRadius,\n  onClick,\n}) => {\n  co"
  },
  {
    "path": "src/components/time/atoms/ZoomControls.jsx",
    "chars": 1188,
    "preview": "const DEFAULT_ZOOM_LEVELS = [\n  { label: \"20 years\", duration: 10512000 },\n  { label: \"2 years\", duration: 1051200 },\n  "
  },
  {
    "path": "src/index.jsx",
    "chars": 1370,
    "preview": "import ReactDOM from \"react-dom/client\";\nimport { Provider } from \"react-redux\";\nimport store from \"./store\";\nimport App"
  },
  {
    "path": "src/reducers/__tests__/index.spec.js",
    "chars": 476,
    "preview": "import { updateTimeRange } from \"../../actions\";\nimport initial from \"../../store/initial\";\nimport reduce from \"../app\";"
  },
  {
    "path": "src/reducers/__tests__/ui.spec.js",
    "chars": 761,
    "preview": "import { toggleTileOverlay } from \"../../actions\";\nimport initial from \"../../store/initial\";\nimport ui from \"../ui\";\nim"
  },
  {
    "path": "src/reducers/app.js",
    "chars": 10004,
    "preview": "import initial from \"../store/initial\";\nimport { ASSOCIATION_MODES } from \"../common/constants\";\nimport { toggleFlagAC }"
  },
  {
    "path": "src/reducers/domain.js",
    "chars": 828,
    "preview": "import initial from \"../store/initial\";\n\nimport { UPDATE_DOMAIN, MARK_NOTIFICATIONS_READ } from \"../actions\";\nimport { v"
  },
  {
    "path": "src/reducers/features.js",
    "chars": 153,
    "preview": "import initial from \"../store/initial\";\n\nfunction features(featureState = initial.features, action) {\n  return featureSt"
  },
  {
    "path": "src/reducers/index.js",
    "chars": 485,
    "preview": "import { combineReducers } from \"redux\";\nimport rootReducer from \"./root\";\nimport domain from \"./domain\";\nimport app fro"
  },
  {
    "path": "src/reducers/root.js",
    "chars": 289,
    "preview": "import { REHYDRATE_STATE } from \"../actions\";\nimport { applyUrlState } from \"../store/plugins/urlState\";\n\nexport default"
  },
  {
    "path": "src/reducers/ui.js",
    "chars": 512,
    "preview": "import initial from \"../store/initial\";\n\nimport { TOGGLE_TILE_OVERLAY } from \"../actions\";\n\nfunction ui(uiState = initia"
  },
  {
    "path": "src/reducers/validate/associationsSchema.js",
    "chars": 300,
    "preview": "import Joi from \"joi\";\n\nconst associationsSchema = Joi.object().keys({\n  id: Joi.string().allow(\"\").required(),\n  title:"
  },
  {
    "path": "src/reducers/validate/eventSchema.js",
    "chars": 1496,
    "preview": "import Joi from \"joi\";\n\nfunction joiFromCustom(custom) {\n  const output = {};\n  custom.forEach((field) => {\n    if (fiel"
  },
  {
    "path": "src/reducers/validate/regionSchema.js",
    "chars": 165,
    "preview": "import Joi from \"joi\";\n\nconst regionSchema = Joi.object().keys({\n  name: Joi.string().required(),\n  items: Joi.array().r"
  },
  {
    "path": "src/reducers/validate/shapeSchema.js",
    "chars": 227,
    "preview": "import Joi from \"joi\";\n\nconst shapeSchema = Joi.object().keys({\n  id: Joi.string().allow(\"\"),\n  title: Joi.string().allo"
  },
  {
    "path": "src/reducers/validate/siteSchema.js",
    "chars": 319,
    "preview": "import Joi from \"joi\";\n\nconst siteSchema = Joi.object().keys({\n  id: Joi.string().required(),\n  description: Joi.string("
  },
  {
    "path": "src/reducers/validate/sourceSchema.js",
    "chars": 502,
    "preview": "import Joi from \"joi\";\n\nconst sourceSchema = Joi.object().keys({\n  id: Joi.string().required(),\n  title: Joi.string().al"
  },
  {
    "path": "src/reducers/validate/validators.js",
    "chars": 6204,
    "preview": "import createEventSchema from \"./eventSchema\";\nimport siteSchema from \"./siteSchema\";\nimport associationsSchema from \"./"
  },
  {
    "path": "src/scss/_burger.scss",
    "chars": 742,
    "preview": "// Burger transition\n.side-menu-burg {\n  overflow: hidden;\n  margin: 0;\n  appearance: none;\n  box-shadow: none;\n  border"
  },
  {
    "path": "src/scss/_icons.scss",
    "chars": 252,
    "preview": ".icon {\n  display: inline-block;\n  width: 32px;\n  height: 1em;\n  stroke-width: 0;\n  stroke: $offwhite;\n  fill: $offwhite"
  },
  {
    "path": "src/scss/_variables.scss",
    "chars": 1609,
    "preview": "@font-face {\n  font-family: \"GT-Zirkon\";\n  src: url(../assets/fonts/timemapfont.woff); // a Lato woff by default\n}\n\n$eve"
  },
  {
    "path": "src/scss/button.scss",
    "chars": 681,
    "preview": ".button {\n  font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n  font-weight: normal;\n  border: 0;\n  border-ra"
  },
  {
    "path": "src/scss/card.scss",
    "chars": 6353,
    "preview": ".event-card {\n  box-sizing: border-box;\n  margin: 0;\n  padding: 15px;\n  transition: 0.2 ease;\n  border: 0;\n  opacity: 1;"
  },
  {
    "path": "src/scss/cardstack.scss",
    "chars": 2107,
    "preview": "// @import 'burger';\n@import \"card\";\n\n.card-stack {\n  display: flex;\n  flex-direction: column;\n  position: absolute;\n  t"
  },
  {
    "path": "src/scss/common.scss",
    "chars": 2208,
    "preview": "@import \"variables\";\n\nhtml {\n  font-family: $mainfont;\n  font-size: 14px;\n  -webkit-font-smoothing: antialiased;\n  @medi"
  },
  {
    "path": "src/scss/cover.scss",
    "chars": 5783,
    "preview": ".cover-container {\n  position: absolute;\n  top: -100%;\n  left: 0;\n  height: 100vh;\n  background-color: black;\n  width: 1"
  },
  {
    "path": "src/scss/header.scss",
    "chars": 1064,
    "preview": ".header {\n  background: #000000;\n  position: fixed;\n  padding: 10px;\n  z-index: 10;\n  top: 10px;\n  right: 10px;\n  height"
  },
  {
    "path": "src/scss/infopopup.scss",
    "chars": 2973,
    "preview": "@import \"burger\";\n\n.infopopup {\n  display: block;\n  position: absolute;\n  width: 600px;\n  max-width: calc(min(60vw, 100%"
  },
  {
    "path": "src/scss/loading.scss",
    "chars": 1627,
    "preview": ".loading-overlay {\n  font-weight: 300;\n  width: 100%;\n  height: 100%;\n  position: absolute;\n  background: rgba(0, 0, 0, "
  },
  {
    "path": "src/scss/main.scss",
    "chars": 332,
    "preview": "@import \"variables\";\n@import \"common\";\n@import \"loading\";\n@import \"header\";\n@import \"cardstack\";\n@import \"narrativecard\""
  },
  {
    "path": "src/scss/map.scss",
    "chars": 3057,
    "preview": "@import \"popup\";\n\n@-webkit-keyframes pulsate {\n  0% {\n    opacity: 0.1;\n  }\n\n  50% {\n    opacity: 0.25;\n  }\n\n  100% {\n  "
  },
  {
    "path": "src/scss/mediaplayer.scss",
    "chars": 44,
    "preview": "@import \"video-react/dist/video-react.css\";\n"
  },
  {
    "path": "src/scss/narrativecard.scss",
    "chars": 3043,
    "preview": "/*\nNARRATIVE INFO\n*/\n.narrative-info {\n  position: fixed;\n  top: 30px;\n  left: auto;\n  right: $card-right; // looks a bi"
  },
  {
    "path": "src/scss/notification.scss",
    "chars": 1495,
    "preview": "@import \"burger\";\n\n.notification-wrapper {\n  top: 60px;\n  right: 60px;\n  width: 400px;\n  height: auto;\n  position: absol"
  },
  {
    "path": "src/scss/overlay.scss",
    "chars": 6787,
    "preview": "a {\n  color: $yellow !important;\n}\n\n.mo-overlay {\n  display: flex;\n  flex-direction: column;\n  // justify-content: cente"
  },
  {
    "path": "src/scss/popup.scss",
    "chars": 1320,
    "preview": ".popup {\n  box-sizing: border-box;\n  margin: 0;\n  padding: 15px;\n  border: 0;\n  opacity: 0;\n  border-radius: 2px;\n  tran"
  },
  {
    "path": "src/scss/satelliteoverlaytoggle.scss",
    "chars": 765,
    "preview": "@import \"variables\";\n\n.satellite-overlay-toggle {\n  position: fixed;\n  top: 0.5em;\n  right: 0.5em;\n  z-index: $map-overl"
  },
  {
    "path": "src/scss/search.scss",
    "chars": 1737,
    "preview": "#search-bar-icon-container {\n  position: absolute;\n  background-color: black;\n  color: #a0a0a0;\n  border: #a0a0a0 solid "
  },
  {
    "path": "src/scss/tabs.scss",
    "chars": 975,
    "preview": ".react-tabs {\n  padding-top: 0;\n  box-sizing: border-box;\n\n  [role=\"tablist\"] {\n    padding: 0;\n  }\n\n  [role=\"tab\"] {\n  "
  },
  {
    "path": "src/scss/timeline.scss",
    "chars": 8253,
    "preview": ".timeline-wrapper {\n  position: fixed;\n  box-sizing: border-box;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  height: auto;\n  b"
  },
  {
    "path": "src/scss/toolbar.scss",
    "chars": 13067,
    "preview": "@import \"burger\";\n@import \"tabs\";\n\n.toolbar-wrapper {\n  position: fixed;\n  top: 0px;\n  left: 0px;\n  z-index: $header;\n  "
  },
  {
    "path": "src/scss/video.scss",
    "chars": 1351,
    "preview": ".video-wrapper {\n  z-index: 1;\n  position: relative;\n  width: 740px;\n  height: 420px;\n  transition: opacity 500ms;\n  bac"
  },
  {
    "path": "src/selectors/__tests__/timeline.spec.js",
    "chars": 3856,
    "preview": "import initial from \"../../store/initial\";\nimport { advanceTo, clear } from \"jest-date-mock\";\nimport * as selectors from"
  },
  {
    "path": "src/selectors/helpers.js",
    "chars": 639,
    "preview": "/**\n * Some handy helpers\n */\n\n/**\n * Given an event and a time range,\n * returns true/false if the event falls within t"
  },
  {
    "path": "src/selectors/index.js",
    "chars": 13141,
    "preview": "import { createSelector } from \"reselect\";\nimport {\n  insetSourceFrom,\n  dateMin,\n  dateMax,\n  isLatitude,\n  isLongitude"
  },
  {
    "path": "src/store/index.js",
    "chars": 424,
    "preview": "import { createStore, applyMiddleware, compose } from \"redux\";\nimport thunk from \"redux-thunk\";\n\nimport rootReducer from"
  },
  {
    "path": "src/store/initial.js",
    "chars": 6524,
    "preview": "import { mergeDeepLeft } from \"ramda\";\n\nimport global, { colors } from \"../common/global\";\nimport copy from \"../common/d"
  },
  {
    "path": "src/store/plugins/urlState/applyUrlState.js",
    "chars": 471,
    "preview": "import { isEmptyObject } from \"../../../common/utilities\";\nimport { SCHEMA } from \"./schema\";\nimport URLState from \"./ur"
  },
  {
    "path": "src/store/plugins/urlState/index.js",
    "chars": 100,
    "preview": "export { applyUrlState } from \"./applyUrlState\";\nexport { urlStateMiddleware } from \"./middleware\";\n"
  },
  {
    "path": "src/store/plugins/urlState/middleware.js",
    "chars": 648,
    "preview": "import { SCHEMA } from \"./schema\";\nimport URLState from \"./urlState\";\n\nexport function urlStateMiddleware(store) {\n  ret"
  },
  {
    "path": "src/store/plugins/urlState/schema.js",
    "chars": 5255,
    "preview": "import {\n  TOGGLE_ASSOCIATIONS,\n  UPDATE_COLORING_SET,\n  UPDATE_SELECTED,\n  UPDATE_TIMERANGE,\n  UPDATE_MAP_VIEW,\n} from "
  },
  {
    "path": "src/store/plugins/urlState/urlState.js",
    "chars": 2615,
    "preview": "import dayjs from \"dayjs\";\nimport { isSchemaArray, SCHEMA, SCHEMA_TYPES } from \"./schema\";\n\nexport class URLState {\n  co"
  },
  {
    "path": "src/test/App.test.jsx",
    "chars": 364,
    "preview": "import { render, screen } from \"@testing-library/react\";\nimport { Provider } from \"react-redux\";\n\nimport store from \"../"
  },
  {
    "path": "test/__mocks__/fileMock.js",
    "chars": 34,
    "preview": "module.exports = \"test-file-stub\";"
  },
  {
    "path": "test/__mocks__/styleMock.js",
    "chars": 210,
    "preview": "// @see https://github.com/keyz/identity-obj-proxy\nconst identityObject = new Proxy(\n  {},\n  {\n    get(_, key) {\n      r"
  },
  {
    "path": "test/setup.js",
    "chars": 87,
    "preview": "require(\"@testing-library/jest-dom\");\n\n// HACK\nglobal.fetch = () => Promise.resolve();\n"
  },
  {
    "path": "vite.config.js",
    "chars": 625,
    "preview": "import { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\n\n// https://vitejs.dev/config/\nexport def"
  }
]

About this extraction

This page contains the full source code of the bellingcat/ukraine-timemap GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 158 files (372.5 KB), approximately 100.0k tokens, and a symbol index with 406 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!