[
  {
    "path": ".dockerignore",
    "content": "node_modules/\nbuild/\nexample.config.js\n"
  },
  {
    "path": ".eslintrc.js",
    "content": "module.exports = {\n  root: true,\n  plugins: [],\n\n  parserOptions: {\n    sourceType: \"module\",\n    ecmaFeatures: {\n      jsx: true,\n    },\n  },\n  settings: {\n    react: {\n      version: \"detect\",\n    },\n  },\n  extends: [\n    \"eslint:recommended\",\n    \"plugin:react/recommended\",\n    \"plugin:react/jsx-runtime\",\n    \"plugin:react-hooks/recommended\",\n    \"prettier\",\n  ],\n  env: {\n    browser: true,\n    es2022: true,\n    jest: true,\n  },\n  rules: {\n    \"react/prop-types\": 0,\n  }\n};\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE.md",
    "content": "<!--- Hi, and thanks for contributing! -->\n\n<!--- Before opening a new issue, please search our existing issues to see if anyone else has had the-->\n<!--- same issue as you. Make sure to provide a general summary of the issue in the Title above! -->\n\nEnvironment\n-----------\n\n* Your version (in package.json) or git commit hash\n* Your operating system and version:\n\n<!--- Include any other relevant details about your environment and installation, including: configuration details, the url(s) being accessed, etc.. -->\n\nSteps to reproduce (for bugs only)\n-----------------------------\n<!--- If describing a bug, tell us what happens when the steps to reproduce are performed -->\n<!--- If possible, provide a curl command line and resulting output -->\n\n1.\n2.\n3.\n\nCurrent Behavior\n----------------\n\n<!--- If describing a bug, tell us what happens when the steps to reproduce are performed. -->\n<!--- If you're suggesting a change, describe the current behavior and why it needs improvement -->\n\nExpected Behavior\n-----------------\n\n<!--- If you're describing a bug, tell us what _should_ happen -->\n<!--- If you're suggesting a change/improvement, tell us how it _should_ work -->\n\n<!--- Thanks again for contributing! -->\n"
  },
  {
    "path": ".github/workflows/cd.yml",
    "content": "name: CD\non:\n  push:\n    branches: [ develop ]\n#  pull_request:\n#    branches: [ develop ]\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Trigger CD build\n        uses: peter-evans/repository-dispatch@v1\n        with:\n          token: ${{ secrets.CI_DISPATCH_TOKEN }}\n          repository: forensic-architecture/configs\n          event-type: remote-build\n          client-payload: '{\"runtime_args\": \"timemap\", \"branch\": \"${GITHUB_REF##*/}\"}'\n\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\non:\n  push:\n    branches: [ develop ]\n  pull_request:\n    branches: [ develop ]\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v2\n        with:\n          ref: ${{ github.head_ref }}\n      - uses: actions/setup-node@v2-beta\n        with:\n          node-version: '12'\n\n      - run: npm install\n      - run: cp example.config.js config.js\n      - run: npm test\n        env:\n          CI: true\n      - run: npm run lint\n        env:\n          CI: true\n"
  },
  {
    "path": ".gitignore",
    "content": ".idea/\nbuild/\nnode_modules/\n\ndev.config.js\n!config/webpack*.config.js\n!config/getHttpsConfig.js\n\n\ntags\ntags.lock\ntags.temp\n\n.eslintcache\n\nsrc/\\.DS_Store\nsrc/assets/fonts\n\n\\.DS_Store\n\ntags\n"
  },
  {
    "path": ".prettierrc",
    "content": ""
  },
  {
    "path": ".travis.yml",
    "content": "language: node_js\nnode_js:\n  - \"stable\"\ncache:\n  directories:\n    - node_modules\nbefore_script:\n  - cp example.config.js config.js\ninstall:\n  - npm install\nscript:\n  - npm run lint\n  - npm run build\n  - npm run test\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to timemap \n\nHello! 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 🏆\n\nContributions to this project are released to the public under the project's open source license.\n\nPlease 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.\n\n## What do I need to know to help?\n### Javascript / React / Redux\nIn order to contribute code upstream, you'll likely need to have a sense of ES6\nJavascript, React, and Redux. If these terms are new to you, or not as familiar\nas you might like, here's a good tutorial to get you up to speed:\n\n- [Building a voting app with Redux and React](https://teropa.info/blog/2015/09/10/full-stack-redux-tutorial.html)\n\n### Node JS and Docker\nTimemap doesn't actually use these technologies; but the main way of getting up\nand running with a data provider for timemap,\n[datasheet-server](https://github.com/bellingcat/datasheet-server),\ndoes, and so they're helpful to know.\n\n## Do I need to be an experienced JS developer? \nContributing can of course be about contributing code, but it can also take\nmany other forms. A great amount of work that remains to be done to make\ntimemap a usable community tool doesn't involve writing any code. The following\nare all very welcome contributions:\n\n- Writing, updating or correcting documentation\n- Fixing an open issue\n- Requesting a feature\n- Reporting a bug\n\nIf you're new to this project, you could check the issues that are tagged\n[\"good first issue\"](https://github.com/bellingcat/ukraine-timemap/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22).\n\nThese are a range of the issues that have come up in conversation for which we\nwould welcome community contributions. These are, however, by no means\nexhaustive! If you see a gap or have an idea, please open up an issue to\ndiscuss it with timemap's maintainers.\n\n## How do I make a contribution? \n\n1. Make sure you have a [GitHub account](https://github.com/signup/free)\n2. Fork the repository on GitHub. This is necessary so that you can push your\n    changes, as you can't do this directly on our repo.\n3. Get set up with a local instance of timemap and datasheet-server. The easiest\n    way to do this is by reading [this blog post on the forensic architecture website](https://forensic-architecture.org/investigation/timemap-for-cartographic-platforms).\n4. [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.\n   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.\n\nOnce you're set up with a local copy of timemap and datasheet-server, you can\nstart modifying code and making changes. \n\nWhen you're ready to submit a contribution, you can do it by making a pull\nrequest from a branch on your forked copy of timemap to this repository. You\ncan do this with the following steps:\n1. Push the changes to a remote repository. If the changes you have made\n   address a bug, you should name it `bug/{briefdesc}`, where `{briefdesc}` is\n   a hyphen-separated description of your change. If instead you are\n   contributing changes as a feature request, name it `feature/{briefdesc`}. If\n   in doubt, prefix your branch with `feature/`.\n2. Submit a pull request to the `develop` branch of `forensic-architecture/timemap`.\n3. Wait for the pull request to be reviewed by a maintainer.\n4. Make changes to the pull request if the reviewing maintainer recommends\n   them.\n5. Celebrate your success once your pull request is merged!\n\n## How do I validate my changes?\nWe are still working on a set of tests. Right now, it is enough to confirm that\nthe application runs as expected with `npm run dev`. If your changes introduce\nother issues, a maintainer will flag it in stage 3 of the submission process\nabove.\n\n## Credits \nThis contributing guide is based on the guidelines of both the \n[SuperCollider contributing guide](https://raw.githubusercontent.com/supercollider/supercollider/develop/CONTRIBUTING.md),\nand the [nteract contributing\nguide](https://github.com/nteract/nteract/blob/master/CONTRIBUTING.md) (two\nexcellent open source projects!).\n\nThanks to [Scott Carver](https://github.com/scztt) for advice on how to put\na guide together.\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM mhart/alpine-node:10.11\n\nLABEL authors=\"Lachlan Kermode <lk@forensic-architecture.org>\"\n\n# Install app dependencies\nCOPY package.json /www/package.json\nRUN cd /www; yarn\n\n# Copy app source\nCOPY . /www\nWORKDIR /www\nRUN yarn build\n\n# files available to copy at /www/build\n"
  },
  {
    "path": "LICENSE.md",
    "content": "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 the consequences for our society and planet.\n\nAs 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.\n\nWe 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.\n\nWe build software to further this vision of a just world, or at the very least, to not put that vision further from reach.\n\n**Terms**\n\n*Copyright* (c) 2019 Forensic Architecture. All rights reserved.\nRedistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:\n\n1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.\n\n2. 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.\n\n3. 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.\n\n4. This software must not be used by any organisation, website, product or service that:\n\n   a) lobbies for, promotes, or derives a majority of income from actions that support or contribute to:\n      * sex trafficking\n      * human trafficking\n      * slavery\n      * indentured servitude\n      * gambling\n      * tobacco\n      * adversely addictive behaviours\n      * nuclear energy\n      * warfare\n      * weapons manufacturing\n      * war crimes\n      * violence (except when required to protect public safety)\n      * burning of forests\n      * deforestation\n      * hate speech or discrimination based on age, gender, gender identity, race, sexuality, religion, nationality\n\n   b) lobbies against, or derives a majority of income from actions that discourage or frustrate:\n      * peace\n      * access to the rights set out in the Universal Declaration of Human Rights and the Convention on the Rights of the Child\n      * peaceful assembly and association (including worker associations)\n      * a safe environment or action to curtail the use of fossil fuels or prevent climate change\n      * democratic processes\n\n5. 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.\n\nWe define:\n\n**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.\n\n**Deforestation** to be the clearing, burning or destruction of 0.5 or more hectares of forests within a 1 year period.\n\nTHIS 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.\n\n**Attribution**\n\nDo No Harm License [Contributor Covenant][homepage], (pre 1.0),\navailable at https://github.com/raisely/NoHarm\n\n[homepage]: https://github.com/raisely/NoHarm\n\n"
  },
  {
    "path": "README.md",
    "content": "<h1 align=\"center\">Civilian Harm in Ukraine TimeMap</h1>\n\n<h2 align=\"center\">\n\tExplore it in <a href=\"https://ukraine.bellingcat.com/\">ukraine.bellingcat.com</a>\n\t<br/>\n\tDownload/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>\n</h2>\n\n<h3 align=\"center\">\nRead Bellingcat's article about this project in \n<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>,\n<a href=\"https://ru.bellingcat.com/novosti/2022/03/18/hospitals-bombed-and-apartments-destroyed-mapping-incidents-of-civilian-harm-in-ukraine-ru/\">Русский (Россия)</a>\n</h3>\n\n<p align=\"center\">\n<strong>\n\tTimeMap 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>.\n</strong>\n</p>\n<br>\n<br>\n\n![ukraine.bellingcat.com timemap preview](docs/example-timemap.png)\n\n## Development\n* `npm install` to setup\n* adjust any local configs in [config.js](config.js)\n* `CONFIG=config.js npm run dev` or `npm run dev` if the file is named config.js\n* For more info visit the [original repo](https://github.com/forensic-architecture/timemap)\n\n\n## Deployment\nThis project is now living in github pages and the API has switched to auto-updated S3 files.\nAccess it at https://bellingcat-embeds.ams3.cdn.digitaloceanspaces.com/production/ukr/timemap/api.json\n\nRelease with `npm run deploy`. \n\n## Contributing\nPlease 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. \n\n## Configurations\n\n<details>\n<summary>Documentation of <a href=\"config.js\">config.js</a> </summary>\n\n* `SERVER_ROOT` - points to the API base address\n* `XXXX_EXT` - points to the respective JSONs of the data, for events, sources, and associations\n* `API_DATA` - S3 file address that can be downloaded or integrated into external apps/visualizations\n* `DATE_FMT` and `TIME_FMT` - how to consume the events' date/time from the API\n* `store.app.map` - configures the initial map view and the UX limits\n* `store.app.cluster` - configures how clusters/bubbles are grouped into larger clusters, larger `radius` means bigger cluster bubbles\n* `store.app.timeline` - configure timeline ranges, zoom level options, and default range\n* `store.app.intro` - the intro panel that shows on start\n* `store.app.cover` - configuration for the full page cover, the `description` is a list of markdown entities, can also contain html\n* `store.ui.colors` and `store.ui.maxNumOfColors` are applied to filters, as they are selected\n\nEasiest way to deploy the static files is through \n* `nvm use 16`\n* `npm run build` (rather: `CI=false npm run build`)\n* copy the files to your server, for example to `/var/www/html`\n\n</details>\n"
  },
  {
    "path": "config.js",
    "content": "const one_day = 1440;\n\nconst config = {\n  title: \"ukraine\",\n  display_title: \"Civilian Harm\\nin Ukraine\",\n  SERVER_ROOT: \"https://bellingcat-embeds.ams3.cdn.digitaloceanspaces.com/production/ukr\",\n  EVENTS_EXT: \"/timemap/events.json\",\n  SOURCES_EXT: \"/timemap/sources.json\",\n  ASSOCIATIONS_EXT: \"/timemap/associations.json\",\n  API_DATA: \"https://bellingcat-embeds.ams3.cdn.digitaloceanspaces.com/production/ukr/timemap/api.json\",\n  // MEDIA_EXT: \"/api/media\",\n  DATE_FMT: \"M/D/YYYY\",\n  TIME_FMT: \"HH:mm\",\n\n  store: {\n    app: {\n      debug: true,\n      map: {\n        // anchor: [49.02421913, 31.43836003],\n        anchor: [48.3326259, 33.19951447],\n        maxZoom: 18,\n        minZoom: 4,\n        startZoom: 6,\n        // maxBounds: []\n      },\n      cluster: { radius: 50, minZoom: 5, maxZoom: 12 },\n      associations: {\n        defaultCategory: \"Weapon System\",\n      },\n      timeline: {\n        dimensions: {\n          height: 90,\n          contentHeight: 90,\n        },\n        zoomLevels: [\n          // { label: \"Zoom to 2 weeks\", duration: 14 * one_day },\n          // { label: \"Zoom to 1 month\", duration: 31 * one_day },\n          // { label: \"Zoom to 6 months\", duration: 6 * 31 * one_day },\n          { label: \"Zoom to 1 year\", duration: 12 * 31 * one_day },\n          { label: \"Zoom to 2 years\", duration: 2 * 12 * 31 * one_day },\n          { label: \"Zoom to 5 years\", duration: 5 * 12 * 31 * one_day },\n        ],\n        range: {\n          /**\n           * Initial date range shown on map load.\n           * Use [start, end] (strings in ISO 8601 format) for a fixed range.\n           * Use undefined for a dynamic initial range based on the browser time.\n           */\n          initial: [\"2022-02-01T00:00:00.000Z\", \"2025-08-31T23:59:59.999Z\"],\n          /** The number of days to show when using a dynamic initial range */\n          initialDaysShown: 31*12,\n          limits: {\n            /** Required. The lower bound of the range that can be accessed on the map. (ISO 8601) */\n            lower: \"2022-02-01T00:00:00.000Z\",\n            /**\n             * The upper bound of the range that can be accessed on the map.\n             * Defaults to current browser time if undefined.\n             */\n            upper: \"2025-08-14T23:59:59.999Z\",\n          },\n        },\n      },\n      intro: [\n        '<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>',\n        '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>.',\n        '<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>',\n      ],\n\n      flags: { isInfopoup: false, isCover: false },\n      cover: {\n        title: \"About and Methodology\",\n        exploreButton: \"BACK TO THE PLATFORM\",\n        description: [\n          \"## Scope of Research\",\n          \"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. \",\n          \"## Open Source Footage\",\n          \"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.\",\n          \"## Verification Level\",\n          \"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.\",\n          \"## Descriptions\",\n          \"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.\",\n          \"## Filters\",\n          \"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.\",\n          \"## Source Links/Embedding\",\n          \"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.\",\n          \"## Privacy concerns and respect for the dead \",\n          \"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.\",\n          \"## A Note on Bellingcat's Global Authentication Project\",\n          \"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.\",\n          \"## Feedback\",\n          \"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).\",\n        ],\n      },\n      toolbar: {\n        panels: {\n          categories: {\n            // TRUE: {\n            //   icon: \"public\",\n            //   label: \"Verified\",\n            //   description: \"todo\",\n            // },\n            // FALSE: {\n            //   icon: \"public\",\n            //   label: \"Unverified\",\n            //   description: \"todo\",\n            // }\n          },\n        },\n      },\n      spotlights: {},\n    },\n    ui: {\n      coloring: {\n        mode: \"STATIC\",\n        maxNumOfColors: 9,\n        defaultColor: \"#dfdfdf\",\n        colors: [\n          \"#7E57C2\",\n          \"#F57C00\",\n          \"#FFEB3B\",\n          \"#D34F73\",\n          \"#08B2E3\",\n          \"#A1887F\",\n          \"#90A4AE\",\n          \"#E57373\",\n          \"#80CBC4\",\n        ],\n      },\n      card: {\n        layout: {\n          template: \"sourced\",\n        },\n      },\n      carto: {\n        eventRadius: 8,\n      },\n      timeline: {\n        eventRadius: 9,\n      },\n      tiles: {\n        current: \"https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}\",\n        default: \"https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}\",\n        satellite: \"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}\"\n      },\n    },\n    features: {\n      USE_CATEGORIES: false,\n      CATEGORIES_AS_FILTERS: false,\n      COLOR_BY_CATEGORY: false,\n      COLOR_BY_ASSOCIATION: true,\n      USE_ASSOCIATIONS: true,\n      USE_FULLSCREEN: true,\n      USE_DOWNLOAD: true,\n      USE_SOURCES: true,\n      USE_SPOTLIGHTS: false,\n      USE_SHAPES: false,\n      USE_COVER: true,\n      USE_INTRO: false,\n      USE_SATELLITE_OVERLAY_TOGGLE: true,\n      USE_SEARCH: false,\n      USE_SITES: false,\n      ZOOM_TO_TIMEFRAME_ON_TIMELINE_CLICK: one_day,\n      FETCH_EXTERNAL_MEDIA: false,\n      USE_MEDIA_CACHE: false,\n      GRAPH_NONLOCATED: false,\n      NARRATIVE_STEP_STYLES: false,\n      CUSTOM_EVENT_FIELDS: [],\n    },\n  },\n};\n\nexport default config;\n"
  },
  {
    "path": "docs/configuration.md",
    "content": "# Configuration\n\n**NOTE: WIP. These settings are currently slightly out of date.**\n\nIn 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.\n\nThe 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: \n\n| Option  | Description | Type | Nullable |\n| ------- | ----------- | ---- | -------- |\n| title | Title of the application, display in the toolbar | String | No |\n| SERVER_ROOT | Base URI for the server | String | No |\n| EVENT_EXT | Endpoint for events, which will be concatenated with SERVER_ROOT | String | No |\n| EVENT_DESC_ROOT | Endpoint for additional metadata for each individual event, concatenated to SERVER_ROOT | String | Yes |\n| CATEGORY_EXT | Endpoint for categories, concatenated with SERVER_ROOT | String | Yes |\n| NARRATIVE_EXT | Endpoint for narratives, concatenated with SERVER_ROOT | String | No |\n| FILTER_TREE_EXT | Endpoint for filters, concatenated with SERVER_ROOT | String | Yes |\n| SITES_EXT | Endpoint for sites, concatenated with SERVER_ROOT | String | Yes |\n| MAP_ANCHOR | Geographic coordinates for original map anchor | Array of numbers | No |\n| features.USE_ASSOCIATIONS | Enable / Disable filters | boolean | No |\n| features.USE_SITES | Enable / Disable sites | boolean | No |\n\nIn 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.\n\n### Data requirements\n\nThis section outlines the data requirements for each HTTP endpoint.\n\nThe sum total of data that is fetched asynchronously in a timemap instance is\nreferred to as the application `domain`. The base endpoint for the domain-- and\nthe paths to required and optional endpoints-- are configured through\na `config.js` file in timemap's root folder (explained in the next section).\n\n#### Required endpoints\n\n1. **Events**: incidents mapped in time and space are called `events`. They must include the following fields:\n\n```json\n[\n  {\n    \"desc\":\"SOME DESCRIPTION TEXT\",\n    \"date\":\"8/23/2011\",\n    \"time\":\"18:30\",\n    \"location\":\"LOCATION_NAME\",\n    \"lat\":\"17.810358\",\n    \"long\":\"-18.2251664\",\n    \"source\":\"\",\n    \"filters\": \"\",\n    \"category\": \"\"\n  }\n]\n```\n\n\n2. **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.\n\n```json\n[\n  {\n    \"category\":\"Category 00\",\n    \"category_label\":\"Category Label\",\n    \"group\":\"category_group00\",\n    \"group_label\":\"Events\"\n  }\n]\n```\n\n#### Optional endpoints\n\n3. **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.\n\n```json\n{\n   \"key\":\"filters\",\n   \"children\": {\n      \"filter0\": {\n         \"key\": \"filter0 \",\n         \"children\": {\n            \"filter00\": {\n               \"key\": \"filter00\",\n               \"children\": {\n                 \"filter001\": {\n                    \"key\": \"filter001\",\n                    \"children\": {}\n                 }\n               }\n            },\n            \"filter01\": {\n               \"key\": \"filter01\",\n               \"children\": {}\n            }\n         }\n      },\n      \"filter1\": {\n         \"key\": \"filter1\",\n         \"children\": {\n            \"filter10\": {\n               \"key\": \"filter10\",\n               \"children\": {}\n            }\n         }\n      }\n   }\n}\n```\n\n4. **Sites**: sites are labels on the map, aiming to highlight particularly relevant locations that should not be a function of time or filters.\n\n```json\n[\n  {\n    \"id\":\"1\",\n    \"description\":\"SITE_DESCRIPTION\",\n    \"site\":\"SITE_LABEL\",\n    \"latitude\":\"17.810358\",\n    \"longitude\":\"-18.2251664\"\n  }\n]\n```\n\n\n"
  },
  {
    "path": "docs/custom-covers.md",
    "content": "## Using Timemap with a Custom Cover\n\nBy default, instances of Timemap use no cover. By setting the `USE_COVER` flag\nto true in [config.js][], however, you can use explanatory text and videos that are \ndisplayed when your instance is first loaded.\n\nThe structure you need to specify in `app.cover` in the Redux store overrides\nin config.js is outlined below:\n\n```js\nconst theCover = {\n  // The video that plays in the background of the cover. Will otherwise be plain black\n  bgVideo: \"https://url-for-background-video.mp4\",\n  // Titles sit at the top of the screen\n  title: \"My Custom Timemap Instance\",\n  subtitle: \"Mapping Events in my personal open source investigation\",\n  subsubtitle: \"January 2020\",\n  // The main text on the cover. This string is markdown, so you can include links and styles.\n  description: \"A brief description of what the platform shows. [Links](https://forensic-architecture.org) can be written in markdown.\",\n    // Header videos sit above the 'EXPLORE' button, and can include a basic description, as well as translations.\n    headerVideos: [\n    {\n      buttonTitle: \"ABOUT\",\n      desc: \"This film details the investigation's methodology and findings at a high level.\",\n      file: \"\"https://url-to-video.mp4,\n      poster: \"https://url-for-thumbnail.png\",\n      title: \"About the Investigation\",\n      translations: [\n        {\n          code: \"ITA\",\n          desc: \"Italian translation\",\n          paths: [\"https://url-to-video.mp4\"],\n          title: \"Translated Title\",\n        },\n        {\n          code: \"RUS\",\n          desc: \"Russian translation\",\n          paths: [\"https://url-to-video.mp4\"],\n          title: \"Translated Title\",\n        }\n      ]\n    },\n    {\n      buttonTitle: \"HOW TO USE\",\n      desc: \"This step-by-step guide explores the way that the platform arranges and presents information.\",\n      file: \"\"https://url-to-video.mp4,\n      poster: \"https://url-for-thumbnail.png\",\n      title: \"How to Use the Platform\"\n    }\n  ],\n  // These videos sit at the bottom of the page, beneath the rest of the\n  // content. The max length of the list is 4 for stylistic reasons, any later\n  // indices will not be shown.\n  videos: [\n    {\n      buttonTitle: \"VERIFICATION:\",\n      buttonSubtitle: \"How we verified data\",\n      desc: \"This video shows how we verified the data.\",\n      file: \"https://url-to-video.mp4\",\n      poster: \"https://url-for-thumbnail.png\",\n      title: \"Verifying data in this investigation\"\n    },\n    {\n      buttonTitle: \"VERIFICATION:\",\n      buttonSubtitle: \"How we verified data\",\n      desc: \"This video shows how we verified the data.\",\n      file: \"https://url-to-video.mp4\",\n      poster: \"https://url-for-thumbnail.png\",\n      title: \"Verifying data in this investigation\"\n    },\n    {\n      buttonTitle: \"VERIFICATION:\",\n      buttonSubtitle: \"How we verified data\",\n      desc: \"This video shows how we verified the data.\",\n      file: \"https://url-to-video.mp4\",\n      poster: \"https://url-for-thumbnail.png\",\n      title: \"Verifying data in this investigation\"\n    },\n    {\n      buttonTitle: \"VERIFICATION:\",\n      buttonSubtitle: \"How we verified data\",\n      desc: \"This video shows how we verified the data.\",\n      file: \"https://url-to-video.mp4\",\n      poster: \"https://url-for-thumbnail.png\",\n      title: \"Verifying data in this investigation\"\n    }\n  ]\n}\n\nmodule.exports = {\n  // ... other values in config.js\n  store: {\n    app: {\n      cover: theCover,\n      // ...\n    }\n    // ... \n  }\n}\n```\n"
  },
  {
    "path": "example.config.js",
    "content": "const config = {\n  title: 'example',\n  display_title: 'example',\n  SERVER_ROOT: 'http://localhost:4040',\n  EVENTS_EXT: '/api/timemap_data/export_events/deeprows',\n  ASSOCIATIONS_EXT: '/api/timemap_data/export_associations/deeprows',\n  SOURCES_EXT: '/api/timemap_data/export_sources/deepids',\n  SITES_EXT: '',\n  SHAPES_EXT: '',\n  DATE_FMT: 'MM/DD/YYYY',\n  TIME_FMT: 'hh:mm',\n  store: {\n    app: {\n      map: {\n        anchor: [31.356397, 34.784818]\n      }\n    },\n    features: {\n      COLOR_BY_ASSOCIATION: true,\n      USE_ASSOCIATIONS: true,\n      USE_SOURCES: true,\n      USE_COVER: false,\n      GRAPH_NONLOCATED: false,\n      HIGHLIGHT_GROUPS: false\n    }\n  }\n}\n\nexport default config;\n"
  },
  {
    "path": "index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n\n<head>\n  <meta charset=\"utf-8\">\n  <title>Civilian Harm in Ukraine Timemap - Bellingcat</title>\n  <link rel=\"icon\" type=\"image/x-icon\" href=\"favicon.ico\">\n  \n  <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n  <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n  <link href=\"https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap\" rel=\"stylesheet\">\n\n  <link href=\"https://fonts.googleapis.com/icon?family=Material+Icons\" rel=\"stylesheet\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <script defer data-domain=\"ukraine.bellingcat.com\" src=\"https://plausible.io/js/script.js\"></script>\n  <!-- Styles are always go to the head, never the body -->\n  <style>\n    @media (hover: none) {\n      #id {\n        display: none;\n      }\n\n      #nodisplay {\n        display: block;\n      }\n    }\n\n    @media (hover: hover) {\n      #nodisplay {\n        display: none;\n      }\n    }\n  </style>\n</head>\n\n<body>\n  <div class=\"page\">\n    <div id=\"explore-app\"></div>\n    <div id=\"nodisplay\">\n      If you see this message wait up to 30s, otherwise please revisit on a desktop device.\n    </div>\n  </div>\n  <script type=\"module\" src=\"/src/index.jsx\"></script>\n</body>\n\n</html>\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"timemap\",\n  \"version\": \"0.1.0\",\n  \"description\": \"\",\n  \"homepage\": \"https://bellingcat.github.io/ukraine-timemap\",\n  \"private\": true,\n  \"engines\": {\n    \"node\": \"18\"\n  },\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"preview\": \"vite preview\",\n    \"dev:wsl\": \"vite --host\",\n    \"test\": \"vitest\",\n    \"eslint\": \"eslint src --ext jsx\",\n    \"lint\": \"prettier --check \\\"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\\\"\",\n    \"lint:fix\": \"prettier --write \\\"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\\\"\",\n    \"predeploy\": \"vite build\",\n    \"deploy\": \"gh-pages -d build\"\n  },\n  \"dependencies\": {\n    \"@json2csv/plainjs\": \"^6.1.2\",\n    \"d3\": \"^7.4.2\",\n    \"dayjs\": \"^1.11.0\",\n    \"joi\": \"^17.1.1\",\n    \"leaflet\": \"^1.0.3\",\n    \"marked\": \"^4.2.5\",\n    \"object-hash\": \"^3.0.0\",\n    \"ramda\": \"^0.28.0\",\n    \"react\": \"^18.0.0\",\n    \"react-device-detect\": \"^2.2.2\",\n    \"react-dom\": \"^18.0.0\",\n    \"react-image\": \"^4.0.3\",\n    \"react-redux\": \"^8.0.5\",\n    \"react-tabs\": \"^6.0.0\",\n    \"redux\": \"^4.0.0\",\n    \"redux-thunk\": \"^2.2.0\",\n    \"reselect\": \"^4.1.7\",\n    \"screenfull\": \"^6.0.2\",\n    \"scriptjs\": \"^2.5.9\",\n    \"supercluster\": \"^7.1.5\",\n    \"video-react\": \"^0.16.0\"\n  },\n  \"devDependencies\": {\n    \"@testing-library/jest-dom\": \"^5.11.6\",\n    \"@testing-library/react\": \"^13.4.0\",\n    \"@vitejs/plugin-react\": \"^3.0.1\",\n    \"eslint\": \"^8.31.0\",\n    \"eslint-config-prettier\": \"^8.6.0\",\n    \"eslint-plugin-react\": \"^7.32.0\",\n    \"eslint-plugin-react-hooks\": \"^4.6.0\",\n    \"gh-pages\": \"^6.0.0\",\n    \"husky\": \"^8.0.3\",\n    \"jest-date-mock\": \"^1.0.8\",\n    \"lint-staged\": \"^13.1.0\",\n    \"prettier\": \"^2.2.1\",\n    \"sass\": \"^1.57.1\",\n    \"vite\": \"^4.0.4\",\n    \"vitest\": \"^0.27.1\"\n  },\n  \"lint-staged\": {\n    \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\": [\n      \"prettier --write\"\n    ]\n  },\n  \"husky\": {\n    \"hooks\": {\n      \"pre-commit\": \"lint-staged\"\n    }\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">0.2%\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 1 chrome version\",\n      \"last 1 firefox version\",\n      \"last 1 safari version\"\n    ]\n  }\n}\n"
  },
  {
    "path": "public/CNAME",
    "content": "ukraine.bellingcat.com"
  },
  {
    "path": "public/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n    <meta charset=\"utf-8\" />\n    <title>Civilian Harm in Ukraine</title>\n    <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.\">\n    <link href=\"https://fonts.googleapis.com/icon?family=Material+Icons\" rel=\"stylesheet\" />\n    <script defer data-domain=\"ukraine.bellingcat.com\" src=\"https://plausible.io/js/script.js\"></script>\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n</head>\n\n<body>\n    <style>\n        @media (hover: none) {\n            #id {\n                display: none;\n            }\n\n            #nodisplay {\n                display: block;\n            }\n        }\n\n        @media (hover: hover) {\n            #nodisplay {\n                display: none;\n            }\n        }\n    </style>\n    <div class=\"page\">\n        <div class=\"page\">\n            <div id=\"explore-app\"></div>\n        </div>\n        <div id=\"nodisplay\">\n            If you see this message wait up to 30s, otherwise please revisit on a desktop device.\n        </div>\n    </div>\n</body>\n\n</html>"
  },
  {
    "path": "src/actions/index.js",
    "content": "import { urlFromEnv } from \"../common/utilities\";\n\n// TODO: relegate these URLs entirely to environment variables\n// const CONFIG_URL = urlFromEnv('CONFIG_EXT')\nconst EVENT_DATA_URL = urlFromEnv(\"EVENTS_EXT\");\nconst ASSOCIATIONS_URL = urlFromEnv(\"ASSOCIATIONS_EXT\");\nconst SOURCES_URL = urlFromEnv(\"SOURCES_EXT\");\nconst SITES_URL = urlFromEnv(\"SITES_EXT\");\nconst REGIONS_URL = urlFromEnv(\"REGIONS_EXT\");\nconst SHAPES_URL = urlFromEnv(\"SHAPES_EXT\");\n\nconst domainMsg = (domainType) =>\n  `Something went wrong fetching ${domainType}. Check the URL or try disabling them in the config file.`;\n\nexport function fetchDomain() {\n  const notifications = [];\n\n  function handleError(message) {\n    notifications.push({\n      message,\n      type: \"error\",\n    });\n    return [];\n  }\n\n  return (dispatch, getState) => {\n    const features = getState().features;\n    dispatch(toggleFetchingDomain());\n\n    // let configPromise = Promise.resolve([])\n    // if (features.USE_REMOTE_CONFIG) {\n    //   configPromise = fetch(CONFIG_URL)\n    //     .then(response => response.json())\n    //     .catch(() => handleError(\"Couldn't find data at the config URL you specified.\"))\n    // }\n\n    // NB: EVENT_DATA_URL is a list, and so results are aggregated\n    const eventPromise = Promise.all(\n      EVENT_DATA_URL.map((url) =>\n        fetch(url)\n          .then((response) => response.json())\n          .catch(() => handleError(\"events\"))\n      )\n    ).then((results) => results.flatMap((t) => t));\n\n    let associationsPromise = Promise.resolve([]);\n    if (features.USE_ASSOCIATIONS) {\n      if (!ASSOCIATIONS_URL) {\n        associationsPromise = Promise.resolve(\n          handleError(\n            \"USE_ASSOCIATIONS is true, but you have not provided a ASSOCIATIONS_EXT\"\n          )\n        );\n      } else {\n        associationsPromise = fetch(ASSOCIATIONS_URL)\n          .then((response) => response.json())\n          .catch(() => handleError(domainMsg(\"associations\")));\n      }\n    }\n\n    let sourcesPromise = Promise.resolve([]);\n    if (features.USE_SOURCES) {\n      if (!SOURCES_URL) {\n        sourcesPromise = Promise.resolve(\n          handleError(\n            \"USE_SOURCES is true, but you have not provided a SOURCES_EXT\"\n          )\n        );\n      } else {\n        sourcesPromise = fetch(SOURCES_URL)\n          .then((response) => response.json())\n          .catch(() => handleError(domainMsg(\"sources\")));\n      }\n    }\n\n    let sitesPromise = Promise.resolve([]);\n    if (features.USE_SITES) {\n      sitesPromise = fetch(SITES_URL)\n        .then((response) => response.json())\n        .catch(() => handleError(domainMsg(\"sites\")));\n    }\n\n    let regionsPromise = Promise.resolve([]);\n    if (features.USE_REGIONS) {\n      regionsPromise = fetch(REGIONS_URL)\n        .then((response) => response.json())\n        .catch(() => handleError(domainMsg(\"regions\")));\n    }\n\n    let shapesPromise = Promise.resolve([]);\n    if (features.USE_SHAPES) {\n      shapesPromise = fetch(SHAPES_URL)\n        .then((response) => response.json())\n        .catch(() => handleError(domainMsg(\"shapes\")));\n    }\n\n    return Promise.all([\n      eventPromise,\n      associationsPromise,\n      sourcesPromise,\n      sitesPromise,\n      regionsPromise,\n      shapesPromise,\n    ])\n      .then((response) => {\n        const result = {\n          events: response[0],\n          associations: response[1],\n          sources: response[2],\n          sites: response[3],\n          regions: response[4],\n          shapes: response[5],\n          notifications,\n        };\n        if (\n          Object.values(result).some((resp) => resp.hasOwnProperty(\"error\"))\n        ) {\n          throw new Error(\n            \"Some URLs returned negative. If you are in development, check the server is running\"\n          );\n        }\n        dispatch(toggleFetchingDomain());\n        dispatch(setInitialCategories(result.associations));\n        dispatch(setInitialShapes(result.shapes));\n        return result;\n      })\n      .catch((err) => {\n        dispatch(fetchError(err.message));\n        dispatch(toggleFetchingDomain());\n        // TODO: handle this appropriately in React hierarchy\n        alert(err.message);\n      });\n  };\n}\n\nexport const FETCH_ERROR = \"FETCH_ERROR\";\nexport function fetchError(message) {\n  return {\n    type: FETCH_ERROR,\n    message,\n  };\n}\n\nexport const UPDATE_DOMAIN = \"UPDATE_DOMAIN\";\nexport function updateDomain(payload) {\n  return {\n    type: UPDATE_DOMAIN,\n    payload,\n  };\n}\n\nexport function fetchSource(source) {\n  return (dispatch) => {\n    if (!SOURCES_URL) {\n      dispatch(fetchSourceError(\"No source extension specified.\"));\n    } else {\n      dispatch(toggleFetchingSources());\n\n      fetch(`${SOURCES_URL}`)\n        .then((response) => {\n          if (!response.ok) {\n            throw new Error(\n              \"No sources are available at the URL specified in the config specified.\"\n            );\n          } else {\n            return response.json();\n          }\n        })\n        .catch((err) => {\n          dispatch(fetchSourceError(err.message));\n          dispatch(toggleFetchingSources());\n        });\n    }\n  };\n}\n\nexport const UPDATE_HIGHLIGHTED = \"UPDATE_HIGHLIGHTED\";\nexport function updateHighlighted(highlighted) {\n  return {\n    type: UPDATE_HIGHLIGHTED,\n    highlighted: highlighted,\n  };\n}\n\nexport const UPDATE_SELECTED = \"UPDATE_SELECTED\";\nexport function updateSelected(selected) {\n  return {\n    type: UPDATE_SELECTED,\n    selected: selected,\n  };\n}\n\nexport const UPDATE_DISTRICT = \"UPDATE_DISTRICT\";\nexport function updateDistrict(district) {\n  return {\n    type: UPDATE_DISTRICT,\n    district,\n  };\n}\n\nexport const CLEAR_FILTER = \"CLEAR_FILTER\";\nexport function clearFilter(filter) {\n  return {\n    type: CLEAR_FILTER,\n    filter,\n  };\n}\n\nexport const TOGGLE_ASSOCIATIONS = \"TOGGLE_ASSOCIATIONS\";\nexport function toggleAssociations(association, value, shouldColor) {\n  return {\n    type: TOGGLE_ASSOCIATIONS,\n    association,\n    value,\n    shouldColor,\n  };\n}\n\nexport const TOGGLE_SHAPES = \"TOGGLE_SHAPES\";\nexport function toggleShapes(shape) {\n  return {\n    type: TOGGLE_SHAPES,\n    shape,\n  };\n}\n\nexport const SET_LOADING = \"SET_LOADING\";\nexport function setLoading() {\n  return {\n    type: SET_LOADING,\n  };\n}\n\nexport const SET_NOT_LOADING = \"SET_NOT_LOADING\";\nexport function setNotLoading() {\n  return {\n    type: SET_NOT_LOADING,\n  };\n}\n\nexport const SET_INITIAL_CATEGORIES = \"SET_INITIAL_CATEGORIES\";\nexport function setInitialCategories(values) {\n  return {\n    type: SET_INITIAL_CATEGORIES,\n    values,\n  };\n}\n\nexport const SET_INITIAL_SHAPES = \"SET_INITIAL_SHAPES\";\nexport function setInitialShapes(values) {\n  return {\n    type: SET_INITIAL_SHAPES,\n    values,\n  };\n}\n\nexport const UPDATE_TIMERANGE = \"UPDATE_TIMERANGE\";\nexport function updateTimeRange(timerange) {\n  return {\n    type: UPDATE_TIMERANGE,\n    timerange,\n  };\n}\n\nexport const UPDATE_DIMENSIONS = \"UPDATE_DIMENSIONS\";\nexport function updateDimensions(dims) {\n  return {\n    type: UPDATE_DIMENSIONS,\n    dims,\n  };\n}\n\nexport const UPDATE_NARRATIVE = \"UPDATE_NARRATIVE\";\nexport function updateNarrative(narrative) {\n  return {\n    type: UPDATE_NARRATIVE,\n    narrative,\n  };\n}\n\nexport const UPDATE_NARRATIVE_STEP_IDX = \"UPDATE_NARRATIVE_STEP_IDX\";\nexport function updateNarrativeStepIdx(idx) {\n  return {\n    type: UPDATE_NARRATIVE_STEP_IDX,\n    idx,\n  };\n}\n\nexport const UPDATE_SOURCE = \"UPDATE_SOURCE\";\nexport function updateSource(source) {\n  return {\n    type: UPDATE_SOURCE,\n    source,\n  };\n}\n\nexport const UPDATE_COLORING_SET = \"UPDATE_COLORING_SET\";\nexport function updateColoringSet(coloringSet) {\n  return {\n    type: UPDATE_COLORING_SET,\n    coloringSet,\n  };\n}\n\nexport const UPDATE_TICKS = \"UPDATE_TICKS\";\nexport function updateTicks(ticks) {\n  return {\n    type: UPDATE_TICKS,\n    ticks,\n  };\n}\n\n// UI\n\nexport const TOGGLE_SITES = \"TOGGLE_SITES\";\nexport function toggleSites() {\n  return {\n    type: TOGGLE_SITES,\n  };\n}\n\nexport const TOGGLE_FETCHING_DOMAIN = \"TOGGLE_FETCHING_DOMAIN\";\nexport function toggleFetchingDomain() {\n  return {\n    type: TOGGLE_FETCHING_DOMAIN,\n  };\n}\n\nexport const TOGGLE_FETCHING_SOURCES = \"TOGGLE_FETCHING_SOURCES\";\nexport function toggleFetchingSources() {\n  return {\n    type: TOGGLE_FETCHING_SOURCES,\n  };\n}\n\nexport const TOGGLE_LANGUAGE = \"TOGGLE_LANGUAGE\";\nexport function toggleLanguage(language) {\n  return {\n    type: TOGGLE_LANGUAGE,\n    language,\n  };\n}\n\nexport const CLOSE_TOOLBAR = \"CLOSE_TOOLBAR\";\nexport function closeToolbar() {\n  return {\n    type: CLOSE_TOOLBAR,\n  };\n}\n\nexport const TOGGLE_INFOPOPUP = \"TOGGLE_INFOPOPUP\";\nexport function toggleInfoPopup() {\n  return {\n    type: TOGGLE_INFOPOPUP,\n  };\n}\n\nexport const TOGGLE_INTROPOPUP = \"TOGGLE_INTROPOPUP\";\nexport function toggleIntroPopup() {\n  return {\n    type: TOGGLE_INTROPOPUP,\n  };\n}\n\nexport const TOGGLE_NOTIFICATIONS = \"TOGGLE_NOTIFICATIONS\";\nexport function toggleNotifications() {\n  return {\n    type: TOGGLE_NOTIFICATIONS,\n  };\n}\n\nexport const MARK_NOTIFICATIONS_READ = \"MARK_NOTIFICATIONS_READ\";\nexport function markNotificationsRead() {\n  return {\n    type: MARK_NOTIFICATIONS_READ,\n  };\n}\n\nexport const TOGGLE_COVER = \"TOGGLE_COVER\";\nexport function toggleCover() {\n  return {\n    type: TOGGLE_COVER,\n  };\n}\n\nexport const TOGGLE_TILE_OVERLAY = \"TOGGLE_TILE_OVERLAY\";\nexport function toggleTileOverlay() {\n  return {\n    type: TOGGLE_TILE_OVERLAY,\n  };\n}\n\nexport const UPDATE_SEARCH_QUERY = \"UPDATE_SEARCH_QUERY\";\nexport function updateSearchQuery(searchQuery) {\n  return {\n    type: UPDATE_SEARCH_QUERY,\n    searchQuery,\n  };\n}\n\n// ERRORS\n\nexport const FETCH_SOURCE_ERROR = \"FETCH_SOURCE_ERROR\";\nexport function fetchSourceError(msg) {\n  return {\n    type: FETCH_SOURCE_ERROR,\n    msg,\n  };\n}\n\nexport const TOGGLE_SATELLITE_VIEW = \"TOGGLE_SATELLITE_VIEW\";\nexport function toggleSatelliteView() {\n  return {\n    type: TOGGLE_SATELLITE_VIEW,\n  };\n}\n\nexport const REHYDRATE_STATE = \"REHYDRATE_STATE\";\nexport function rehydrateState() {\n  return {\n    type: REHYDRATE_STATE,\n  };\n}\n\nexport const UPDATE_MAP_VIEW = \"UPDATE_MAP_VIEW\";\nexport function updateMapView(lat, lng, zoom) {\n  return {\n    type: UPDATE_MAP_VIEW,\n    lat,\n    lng,\n    zoom,\n  };\n}\n"
  },
  {
    "path": "src/common/constants.js",
    "content": "export const ASSOCIATION_MODES = {\n  CATEGORY: \"CATEGORY\",\n  NARRATIVE: \"NARRATIVE\",\n  FILTER: \"FILTER\",\n};\n\nexport const SHAPE = \"SHAPE\";\n\nexport const DEFAULT_TAB_ICONS = {\n  CATEGORY: \"widgets\",\n  NARRATIVE: \"timeline\",\n  FILTER: \"filter_list\",\n  SHAPE: \"change_history\",\n  DOWNLOAD: \"download\",\n};\n\nexport const AVAILABLE_SHAPES = {\n  STAR: \"STAR\",\n  DIAMOND: \"DIAMOND\",\n  PENTAGON: \"PENTAGON\",\n  SQUARE: \"SQUARE\",\n  DOT: \"DOT\",\n  BAR: \"BAR\",\n  TRIANGLE: \"TRIANGLE\",\n};\n\nexport const POLYGON_CLIP_PATH = {\n  STAR: \"polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)\",\n  DIAMOND: \"polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)\",\n  PENTAGON: \"polygon(50% 0%, 100% 38%, 82% 100%, 18% 100%, 0% 38%)\",\n  TRIANGLE: \"polygon(50% 0%, 0% 100%, 100% 100%)\",\n};\n\nexport const DEFAULT_CHECKBOX_COLOR = \"#ffffff\";\n"
  },
  {
    "path": "src/common/data/copy.json",
    "content": "{\n  \"es-MX\": {\n    \"tiles\": {\n      \"default\": \"Mapa\",\n      \"satellite\": \"Sat\"\n    },\n    \"loading\": \"Cargando...\",\n    \"legend\": {\n      \"view2d\": {\n        \"paragraphs\": [\n          \"Seleccionando una serie de filtros verá aparecer eventos en el mapa y en la línea del tiempo.\",\n          \"Cada evento estará coloreado según la persona que dio el testimonio del evento.\"\n        ],\n        \"colors\": [\n          {\n            \"class\": \"category_group00\",\n            \"label\": \"Categoría Grupo 00\"\n          },\n          {\n            \"class\": \"category_group01\",\n            \"label\": \"Categoría Grupo 01\"\n          },\n          {\n            \"class\": \"category_group02\",\n            \"label\": \"Categoría Grupo 02\"\n          },\n          {\n            \"class\": \"category_group03\",\n            \"label\": \"Categoría Grupo 03\"\n          },\n          {\n            \"class\": \"other\",\n            \"label\": \"Otras categorías\"\n          }\n        ]\n      },\n      \"default\": {\n        \"header\": \"Ayudas para explorar la plataforma\",\n        \"intro\": [\n          \"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.\",\n          \"Puede acercarse en el mapa *(zoom)* haciendo *scroll* con el ratón o haciendo clic en un grupo de puntos.\",\n          \"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.\",\n          \"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.\",\n          \"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.\"\n        ],\n        \"notation\": \"Cuando un circulo combina colores significa que hay varios eventos en esa misma ubicación.\",\n        \"arrows\": \"Usar las flechas izquierda/derecha en el teclado para moverse entre eventos cronológicamente.\"\n      }\n    },\n    \"toolbar\": {\n      \"title\": \"Título\",\n      \"filters\": \"Filtros\",\n      \"explore_by_filter__title\": \"Explorar por filtros\",\n      \"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).\",\n      \"panels\": {\n        \"mentions\": {\n          \"title\": \"Personas\",\n          \"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).\"\n        },\n        \"categories\": {\n          \"title\": \"Testimonios\",\n          \"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).\"\n        },\n        \"search\": {\n          \"title\": \"Directorio de etiquetas\",\n          \"placeholder\": \"Búsqueda\"\n        }\n      }\n    },\n    \"timeline\": {\n      \"zoomLevels\": [\n        {\n          \"label\": \"20 años\",\n          \"duration\": 10512000\n        },\n        {\n          \"label\": \"2 años\",\n          \"duration\": 1051200\n        },\n        {\n          \"label\": \"3 meses\",\n          \"duration\": 129600\n        },\n        {\n          \"label\": \"3 días\",\n          \"duration\": 4320\n        },\n        {\n          \"label\": \"12 horas\",\n          \"duration\": 720\n        },\n        {\n          \"label\": \"1 hora\",\n          \"duration\": 60\n        }\n      ],\n      \"labels_title\": \"Testimonios\",\n      \"labels\": [\n        \"Testimonio Grupo 00\",\n        \"Testimonio Grupo 01\",\n        \"Testimonio Grupo 02\",\n        \"Testimonio Grupo 03\",\n        \"Otras categorias\"\n      ],\n      \"info\": \"%n eventos ocurridos entre\",\n      \"default_categories_label\": \"Eventos\"\n    },\n    \"cardstack\": {\n      \"date_title\": \"Fecha incidente\",\n      \"location_title\": \"Ubicación\",\n      \"summary_title\": \"Resumen\",\n      \"header\": \"eventos seleccionados\",\n      \"unknown_location\": \"Ubicación desconocida\",\n      \"unknown_time\": \"Día y hora desconocida\",\n      \"timestamp\": \"Día y hora\",\n      \"estimated\": \"aproximado\",\n      \"location\": \"Ubicación\",\n      \"incident_type\": \"Tipo de acción\",\n      \"description\": \"Hechos\",\n      \"people\": \"Personas en el evento\",\n      \"sources\": \"Fuentes\",\n      \"category\": \"Según el testimonio de\",\n      \"communication\": \"Comunicación\",\n      \"transmitter\": \"Transmisor\",\n      \"receiver\": \"Receptor\",\n      \"warning\": \"(!) HECHOS CUESTIONADOS\"\n    }\n  },\n  \"en-US\": {\n    \"tiles\": {\n      \"default\": \"Map\",\n      \"satellite\": \"Sat\"\n    },\n    \"loading\": \"Loading...\",\n    \"legend\": {\n      \"view2d\": {\n        \"paragraphs\": [\n          \"Selecting a series of filters, you will be able to explore events on the map of Iguala and on the timeline.\",\n          \"Each event is colored according the person that gave category of the event.\"\n        ],\n        \"colors\": [\n          {\n            \"class\": \"category_group00\",\n            \"label\": \"Category Group 00\"\n          },\n          {\n            \"class\": \"category_group01\",\n            \"label\": \"Category Group 01\"\n          },\n          {\n            \"class\": \"category_group02\",\n            \"label\": \"Category Group 02\"\n          },\n          {\n            \"class\": \"category_group03\",\n            \"label\": \"Category Group 03\"\n          },\n          {\n            \"class\": \"other\",\n            \"label\": \"Other categories\"\n          }\n        ]\n      },\n      \"default\": {\n        \"header\": \"Navigating the Platform\",\n        \"intro\": [\n          \"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.\",\n          \"Zoom in either with a mouse-scroll or by clicking a ‘cluster’ dot.\",\n          \"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.\",\n          \"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.\",\n          \"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.\"\n        ],\n        \"notation\": \"Combinations of colours within a circle indicate multiple events in a single location.\",\n        \"arrows\": \"Use the left/right arrows on the keboard to move back and forth between events in time.\"\n      }\n    },\n    \"toolbar\": {\n      \"title\": \"TITLE\",\n      \"panels\": {\n        \"mentions\": {\n          \"title\": \"Mentions\",\n          \"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)\"\n        },\n        \"categories\": {\n          \"title\": \"Testimonies\",\n          \"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).\"\n        },\n        \"search\": {\n          \"title\": \"Directory of filters\",\n          \"placeholder\": \"Search\"\n        }\n      },\n      \"narratives\": \"Narratives\",\n      \"narratives_label\": \"Narratives\",\n      \"explore_by_narrative__title\": \"Explore events by narrative\",\n      \"explore_by_narrative__description\": \"Follow a path through the data, from one key event to the next.\",\n      \"filters\": \"Filters\",\n      \"filters_label\": \"Filters\",\n      \"explore_by_filter__title\": \"Explore by filter\",\n      \"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>\",\n      \"categories\": \"Categories\",\n      \"categories_label\": \"Categories\",\n      \"explore_by_category__title\": \"Explore events by category\",\n      \"explore_by_category__description\": \"\",\n      \"shapes\": \"Shapes\",\n      \"shapes_label\": \"Shapes\",\n      \"explore_by_shapes__title\": \"Explore events by shape breakdown\",\n      \"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\",\n      \"fullscreen_enter\": \"Fullscreen\",\n      \"fullscreen_exit\": \"Exit Fullscreen\",\n      \"download\": {\n        \"button\": \"Download\",\n        \"panel\": {\n          \"title\": \"Download events\",\n          \"description\": \"Export the most recent available events in different formats.\",\n          \"formats\": {\n            \"api\": {\n              \"label\": \"API\",\n              \"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.\"\n            },\n            \"csv\": {\n              \"label\": \"CSV\",\n              \"description\": \"CSV file where sources and filters are concatenated into a single column each due to data structure limitations.\"\n            },\n            \"json\": {\n              \"label\": \"JSON\",\n              \"description\": \"JSON file where each event is a structured object containing nested arrays of sources and filters.\"\n            }\n          }\n        }\n      }\n    },\n    \"timeline\": {\n      \"labels_title\": \"Testimonies\",\n      \"labels\": [\n        \"Testimony Group 00\",\n        \"Testimony Group 01\",\n        \"Testimony Group 02\",\n        \"Testimony Group 03\",\n        \"Other\"\n      ],\n      \"info\": \"Showing <span>%n events</span> that occurred between\",\n      \"reset\": \"reset dates\",\n      \"default_categories_label\": \"\"\n    },\n    \"cardstack\": {\n      \"header\": \"selected events\",\n      \"timestamp\": \"Day and time\",\n      \"unknown_location\": \"Unknown location\",\n      \"estimated\": \"estimated\",\n      \"unknown_time\": \"Unknown time\",\n      \"location\": \"Localization\",\n      \"incident_type\": \"Type of action\",\n      \"description\": \"Summary\",\n      \"filters\": \"Filters\",\n      \"nofilters\": \"No known filters for this event.\",\n      \"sources\": \"Sources\",\n      \"unknown_source\": \"The information for this source could not be retrieved.\",\n      \"category\": \"Category\",\n      \"communication\": \"Communication\",\n      \"transmitter\": \"Transmitter\",\n      \"receiver\": \"Receiver\",\n      \"warning\": \"(!) Highly questioned\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/common/data/es-MX.json",
    "content": "{\n  \"dateTime\": \"%x, %X\",\n  \"date\": \"%d/%m/%Y\",\n  \"time\": \"%-I:%M:%S %p\",\n  \"periods\": [\"AM\", \"PM\"],\n  \"days\": [\n    \"domingo\",\n    \"lunes\",\n    \"martes\",\n    \"miércoles\",\n    \"jueves\",\n    \"viernes\",\n    \"sábado\"\n  ],\n  \"shortDays\": [\"dom\", \"lun\", \"mar\", \"mié\", \"jue\", \"vie\", \"sáb\"],\n  \"months\": [\n    \"enero\",\n    \"febrero\",\n    \"marzo\",\n    \"abril\",\n    \"mayo\",\n    \"junio\",\n    \"julio\",\n    \"agosto\",\n    \"septiembre\",\n    \"octubre\",\n    \"noviembre\",\n    \"diciembre\"\n  ],\n  \"shortMonths\": [\n    \"ene\",\n    \"feb\",\n    \"mar\",\n    \"abr\",\n    \"may\",\n    \"jun\",\n    \"jul\",\n    \"ago\",\n    \"sep\",\n    \"oct\",\n    \"nov\",\n    \"dic\"\n  ]\n}\n"
  },
  {
    "path": "src/common/global.js",
    "content": "export const colors = {\n  fa_red: \"#eb443e\",\n  yellow: \"#ffd800\",\n  black: \"#000\",\n  white: \"#fff\",\n};\n\nconst exports = {\n  fallbackEventColor: colors.fa_red,\n  darkBackground: colors.black,\n  primaryHighlight: colors.fa_red,\n  secondaryHighlight: colors.white,\n};\n\nexport default exports;\n"
  },
  {
    "path": "src/common/utilities.js",
    "content": "import config from \"../../config\";\nimport customParseFormat from \"dayjs/plugin/customParseFormat\";\nimport dayjs from \"dayjs\";\nimport hash from \"object-hash\";\nimport { timeFormatDefaultLocale } from \"d3\";\nimport esMxData from \"./data/es-MX.json\";\n\nimport { ASSOCIATION_MODES, POLYGON_CLIP_PATH } from \"./constants\";\n\ndayjs.extend(customParseFormat);\n\nconst DATE_FMT = config.DATE_FMT ?? \"MM/DD/YYYY\";\nconst TIME_FMT = config.TIME_FMT ?? \"HH:mm\";\n\nexport const language = config.store.app.language || \"en-US\";\n\nexport function getPathLeaf(path) {\n  const splitPath = path.split(\"/\");\n  return splitPath[splitPath.length - 1];\n}\n\nexport function calcDatetime(date, time) {\n  if (!time) time = \"00:00\";\n  const dt = dayjs(`${date} ${time}`, `${DATE_FMT} ${TIME_FMT}`);\n  return dt.toDate();\n}\n\nexport function getCoordinatesForPercent(radius, percent) {\n  const x = radius * Math.cos(2 * Math.PI * percent);\n  const y = radius * Math.sin(2 * Math.PI * percent);\n  return [x, y];\n}\n\n/**\n * This function takes the array of percentages: [0.5, 0.5, ...]\n * and maps it by index to the set of colors ['#fff', '#000', ...]\n * If there aren't enough colors in the set, it raises an error for the user\n *\n * Return value:\n * ex. {'#fff': 0.5, '#000': 0.5, ...} */\nexport function zipColorsToPercentages(colors, percentages) {\n  if (colors.length < percentages.length) {\n    throw new Error(\"You must declare an appropriate number of filter colors\");\n  }\n\n  return percentages.reduce((map, percent, idx) => {\n    map[colors[idx]] = percent;\n    return map;\n  }, {});\n}\n\n/**\n * Compare two arrays of scalars\n * @param {array} arr1: array of numbers\n * @param {array} arr2: array of numbers\n */\nexport function areEqual(arr1, arr2) {\n  return (\n    arr1.length === arr2.length &&\n    arr1.every((element, index) => {\n      return element === arr2[index];\n    })\n  );\n}\n\n/**\n * Return whether the variable is neither null nor undefined\n * @param {object} variable\n */\nexport function isNotNullNorUndefined(variable) {\n  return typeof variable !== \"undefined\" && variable !== null;\n}\n\n/*\n * Taken from: https://stackoverflow.com/questions/1026069/how-do-i-make-the-first-letter-of-a-string-uppercase-in-javascript\n */\nexport function capitalize(string) {\n  return string.charAt(0).toUpperCase() + string.slice(1);\n}\n\nexport function trimAndEllipse(string, stringNum) {\n  if (string.length > stringNum) {\n    return string.substring(0, 120) + \"...\";\n  }\n  return string;\n}\n\n/**\n * Takes the complete set of filters, and according to their paths places them at the correct node\n *\n * Returns a nested object where:\n * Key: filter_path_0/filter_path_1/filter_path_2...\n * Value: {...nested children}\n */\nexport function aggregateFilterPaths(filters) {\n  function insertPath(\n    children = {},\n    [headOfPath, ...remainder],\n    accumulatedPath\n  ) {\n    const childKey = Object.keys(children).find((path) => {\n      const pathLeaf = getPathLeaf(path);\n      return pathLeaf === headOfPath;\n    });\n    accumulatedPath.push(headOfPath);\n    const accumulatedPlusHead = accumulatedPath.join(\"/\");\n    if (!childKey) children[accumulatedPlusHead] = {};\n    if (remainder.length > 0)\n      insertPath(children[accumulatedPlusHead], remainder, accumulatedPath);\n    return children;\n  }\n\n  const allPaths = [];\n  filters.forEach((filterItem) => allPaths.push(filterItem.filter_paths));\n  const aggregatedPaths = allPaths.reduce(\n    (children, path) => insertPath(children, path, []),\n    {}\n  );\n  return aggregatedPaths;\n}\n\n/**\n * From the set of associations, grab a given filter's set of parents,\n * ie. all the elements in the path array before the idx where the filter is located.\n * If we can't find the filter by the ID, we know its a meta filter, so we look\n * through every association's given path attribute to find its location.\n *\n * Returns the list of parents: ex. ['Chemical', 'Tear Gas', ...]\n */\nexport function getFilterAncestors(filter) {\n  const splitFilter = filter.split(\"/\");\n  const ancestors = [];\n  splitFilter.forEach((f, index) => {\n    const accumulatedPath = splitFilter.slice(0, index + 1).join(\"/\");\n    ancestors.push(accumulatedPath);\n  });\n  // The last element here will be the leaf node aka the filter passed in\n  ancestors.pop();\n  return ancestors;\n}\n\n/**\n * Grabs the second to last element in the paths array for a given existing filter.\n * This is the filter's most immediate ancestor.\n */\nexport function getImmediateFilterParent(filter) {\n  const ancestors = getFilterAncestors(filter);\n  return ancestors[ancestors.length - 1];\n}\n\n/**\n * Grabs a given filter's siblings: the set of associations that share the same immediate filter parent.\n */\nexport function getFilterSiblings(allFilters, filterParent, filterKey) {\n  function findSiblings(filterPathObj, ancestors) {\n    if (ancestors.length === 0 || filterPathObj === {}) return {};\n    const nextAncestor = ancestors.shift();\n    if (Object.keys(filterPathObj).includes(nextAncestor)) {\n      const nextObjToSearch = filterPathObj[nextAncestor];\n      if (ancestors.length === 0) {\n        return nextObjToSearch;\n      } else {\n        return findSiblings(nextObjToSearch, ancestors);\n      }\n    }\n  }\n  const aggregatedFilters = aggregateFilterPaths(allFilters);\n  const ancestors = getFilterAncestors(filterKey);\n  const siblings = findSiblings(aggregatedFilters, ancestors);\n  return Object.keys(siblings).filter((sib) => sib !== filterKey);\n}\n\n/**\n * Looks at the current coloring set (ie. a map between sets of filters and colors) and configures where to add next set\n */\nexport function addToColoringSet(coloringSet, filters) {\n  const flattenedColoringSet = coloringSet.flatMap((f) => f);\n  const newColoringSet = filters.filter(\n    (k) => flattenedColoringSet.indexOf(k) === -1\n  );\n  return [...coloringSet, newColoringSet];\n}\n\n/**\n * Looks at the current coloring set (ie. a map between sets of filters and colors) and configures new sets based off of existing filters\n */\nexport function removeFromColoringSet(coloringSet, filters) {\n  const newColoringSets = coloringSet.map((set) =>\n    set.filter((s) => {\n      return !filters.includes(s);\n    })\n  );\n  return newColoringSets.filter((item) => item.length !== 0);\n}\n\nexport function getEventCategories(event, activeCategories) {\n  const eventCats = event.associations.filter(\n    (a) => a.mode === ASSOCIATION_MODES.CATEGORY\n  );\n  return eventCats.reduce((acc, val) => {\n    const activeCatTitle = activeCategories.find((cat) => cat === val.title);\n    if (activeCatTitle) acc.push(activeCatTitle);\n    return acc;\n  }, []);\n}\n\n/**\n * Takes a filter's path and concatenates it like so: Parent 1/Parent 2/Child\n */\nexport function createFilterPathString(filter) {\n  return filter.filter_paths.join(\"/\");\n}\n\n/**\n * Inset the full source represenation from 'allSources' into an event. The\n * function is 'curried' to allow easy use with maps. To use for a single\n * source, call with two sets of parentheses:\n *      const src = insetSourceFrom(sources)(anEvent)\n */\nexport function insetSourceFrom(allSources) {\n  return (event) => {\n    let sources;\n    if (!event.sources) {\n      sources = [];\n    } else {\n      sources = event.sources.map((id) => {\n        return allSources.hasOwnProperty(id) ? allSources[id] : null;\n      });\n    }\n    return {\n      ...event,\n      sources,\n    };\n  };\n}\n\n/**\n * Debugging function: put in place of a mapStateToProps function to\n * view that source modal by default\n */\nexport function injectSource(id) {\n  return (state) => {\n    return {\n      ...state,\n      app: {\n        ...state.app,\n        source: state.domain.sources[id],\n      },\n    };\n  };\n}\n\nconst API_ROOT =\n  import.meta.env.MODE === \"development\" ? \"\" : config.SERVER_ROOT;\n\nexport function urlFromEnv(ext) {\n  if (config[ext]) {\n    if (!Array.isArray(config[ext])) {\n      return [`${API_ROOT}${config[ext]}`];\n    } else {\n      return config[ext].map((suffix) => `${API_ROOT}${suffix}`);\n    }\n  } else {\n    return null;\n  }\n}\n\nexport function toggleFlagAC(flag) {\n  return (appState) => ({\n    ...appState,\n    flags: {\n      ...appState.flags,\n      [flag]: !appState.flags[flag],\n    },\n  });\n}\n\nexport function selectTypeFromPath(path) {\n  let type;\n  switch (true) {\n    case /\\.(png|jpg)$/.test(path):\n      type = \"Image\";\n      break;\n    case /\\.(mp4)$/.test(path):\n      type = \"Video\";\n      break;\n    case /\\.(md)$/.test(path):\n      type = \"Text\";\n      break;\n    default:\n      type = \"Unknown\";\n      break;\n  }\n  return { type, path };\n}\n\nexport function typeForPath(path) {\n  let type;\n  path = path.trim();\n  switch (true) {\n    case /\\.((png)|(jpg)|(jpeg))$/.test(path):\n      type = \"Image\";\n      break;\n    case /\\.(mp4)$/.test(path):\n      type = \"Video\";\n      break;\n    case /\\.(md)$/.test(path):\n      type = \"Text\";\n      break;\n    case /\\.(pdf)$/.test(path):\n      type = \"Document\";\n      break;\n    case /.+(twitter\\.com).+/.test(path):\n      type = \"Tweet\";\n      break;\n    case /.+(t\\.me).+/.test(path):\n      type = \"Telegram\";\n      break;\n\n    default:\n      type = \"Unknown\";\n      break;\n  }\n  return type;\n}\n\nexport function selectTypeFromPathWithPoster(path, poster) {\n  return { type: typeForPath(path), path, poster };\n}\n\nexport function isIdentical(obj1, obj2) {\n  return hash(obj1) === hash(obj2);\n}\n\nexport function calcOpacity(num) {\n  /* Events have opacity 0.5 by default, and get added to according to how many\n   * other events there are in the same render. The idea here is that the\n   * overlaying of events builds up a 'heat map' of the event space, where\n   * darker areas represent more events with proportion */\n  const base = num >= 1 ? 0.9 : 0;\n  return base + Math.min(0.5, 0.08 * (num - 1));\n}\n\nexport function calcClusterOpacity(pointCount, totalPoints) {\n  /* Clusters represent multiple events within a specific radius. The darker the cluster,\n  the larger the number of underlying events. We use a multiplication factor (50) here as well\n  to ensure that the larger clusters have an appropriately darker shading. */\n  return Math.min(0.85, 0.08 + (pointCount / totalPoints) * 50);\n}\n\nexport function calcClusterSize(pointCount, totalPoints) {\n  /* The larger the cluster size, the higher the count of points that the cluster represents.\n  Just like with opacity, we use a multiplication factor to ensure that clusters with higher point\n  counts appear larger. */\n  //TO-DO: Convert maxSize into a config var\n  const maxSize = totalPoints > 60 ? 60 : 35;\n  return Math.min(maxSize, 10 + (pointCount / totalPoints) * 100);\n}\n\nexport function calculateTotalClusterPoints(clusters) {\n  return clusters.reduce((total, cl) => {\n    if (cl && cl.properties && cl.properties.cluster) {\n      total += cl.properties.point_count;\n    }\n    return total;\n  }, 0);\n}\n\nexport function isLatitude(lat) {\n  return !!lat && isFinite(lat) && Math.abs(lat) <= 90;\n}\n\nexport function isLongitude(lng) {\n  return !!lng && isFinite(lng) && Math.abs(lng) <= 180;\n}\n\nexport function mapClustersToLocations(clusters, locations) {\n  return clusters.reduce((acc, cl) => {\n    const foundLocation = locations.find(\n      (location) => location.label === cl.properties.id\n    );\n    if (foundLocation) acc.push(foundLocation);\n    return acc;\n  }, []);\n}\n\n/**\n * Loops through a set of either locations or events\n * and calculates the proportionate percentage of every given association in relation to the coloring set\n */\nexport function calculateColorPercentages(set, coloringSet) {\n  if (coloringSet.length === 0) return [1];\n  const associationMap = {};\n\n  for (const [idx, value] of coloringSet.entries()) {\n    for (const filter of value) {\n      associationMap[filter] = idx;\n    }\n  }\n\n  const associationCounts = new Array(coloringSet.length);\n  associationCounts.fill(0);\n\n  let totalAssociations = 0;\n\n  set.forEach((item) => {\n    let innerSet = \"events\" in item ? item.events : item;\n\n    if (!Array.isArray(innerSet)) innerSet = [innerSet];\n\n    innerSet.forEach((val) => {\n      val.associations.forEach((a) => {\n        const idx = associationMap[createFilterPathString(a)];\n        if (!idx && idx !== 0) return;\n        associationCounts[idx] += 1;\n        totalAssociations += 1;\n      });\n    });\n  });\n\n  if (totalAssociations === 0) return [1];\n\n  return associationCounts.map((count) => count / totalAssociations);\n}\n\n/**\n * Gets the idx of a given filter in relation to its position in the coloring set\n *\n * Example coloringSet = [['Chemical', 'Tear Gas'], ['Procedural', 'Destruction of property']]\n */\nexport function getFilterIdxFromColorSet(filter, coloringSet) {\n  let filterIdx = -1;\n  coloringSet.map((set, idx) => {\n    const foundIdx = set.indexOf(filter);\n    if (foundIdx !== -1) filterIdx = idx;\n    return null;\n  });\n  return filterIdx;\n}\n\nexport const dateMin = function () {\n  return Array.prototype.slice.call(arguments).reduce(function (a, b) {\n    return a < b ? a : b;\n  });\n};\n\nexport const dateMax = function () {\n  return Array.prototype.slice.call(arguments).reduce(function (a, b) {\n    return a > b ? a : b;\n  });\n};\n\n/** Taken from\n * https://stackoverflow.com/questions/22697936/binary-search-in-javascript\n * **/\nexport function binarySearch(ar, el, compareFn) {\n  let m = 0;\n  let n = ar.length - 1;\n  while (m <= n) {\n    const k = (n + m) >> 1;\n    const cmp = compareFn(el, ar[k]);\n    if (cmp > 0) {\n      m = k + 1;\n    } else if (cmp < 0) {\n      n = k - 1;\n    } else {\n      return k;\n    }\n  }\n  return -m - 1;\n}\n\nexport function makeNiceDate(datetime) {\n  if (datetime === null) return null;\n  // see https://stackoverflow.com/questions/3552461/how-to-format-a-javascript-date\n  const dateTimeFormat = new Intl.DateTimeFormat(language, {\n    year: \"numeric\",\n    month: \"long\",\n    day: \"2-digit\",\n  });\n  const [{ value: month }, , { value: day }, , { value: year }] =\n    dateTimeFormat.formatToParts(datetime);\n\n  return `${day} ${month}, ${year}`;\n}\n\n/**\n * Sets the default locale for d3 to format dates in each available language.\n */\nexport function setD3Locale() {\n  const languages = {\n    \"es-MX\": esMxData,\n  };\n\n  if (language !== \"es-US\" && languages[language]) {\n    timeFormatDefaultLocale(languages[language]);\n  }\n}\n\n/**\n * Gets the set of associated styles for a given shape type from the entire set of shapes\n * @param list shapes - The aggregated set of shapes\n * @param list activeShapes - The set of active shapes in the app\n */\nexport function mapStyleByShape(shapes, activeShapes) {\n  const styledShapes = shapes.map((s) => {\n    const { colour, shape, id } = s;\n    const style = {\n      checkboxStyles: {\n        background: activeShapes.includes(id) ? colour : \"black\",\n        border: \"none\",\n        clipPath: POLYGON_CLIP_PATH[shape],\n      },\n      containerStyles: {\n        background: colour,\n        clipPath: POLYGON_CLIP_PATH[shape],\n      },\n    };\n    s.styles = style;\n    return s;\n  });\n  return styledShapes;\n}\n\nexport function mapCategoriesToPaths(categories, panelCategories) {\n  const mappedCats = categories.reduce((acc, cat) => {\n    const type = cat.filter_paths[0];\n    if (!(type in acc)) {\n      acc[type] = [];\n    }\n    acc[type].push(cat);\n    return acc;\n  }, {});\n\n  const categoryMap =\n    panelCategories.length > 1 ? mappedCats : { default: categories };\n  return categoryMap;\n}\n\nexport function getCategoryIdxs(panelCategories, startingIdx) {\n  let idxCounter = startingIdx;\n  // If there are specified categories from the config, filter out the default value; else, leave the default value\n  const catTypes =\n    panelCategories.length > 1\n      ? panelCategories.filter((val) => val !== \"default\")\n      : panelCategories;\n  return catTypes.reduce((set, val) => {\n    set[val] = idxCounter;\n    idxCounter += 1;\n    return set;\n  }, {});\n}\n\nexport function getFilterIdx(\n  narrativesExist,\n  categoriesExist,\n  numCategoryPanels\n) {\n  if (narrativesExist && !categoriesExist) return 1;\n  else if (!narrativesExist && categoriesExist) return numCategoryPanels;\n  else if (narrativesExist && categoriesExist) return numCategoryPanels + 1;\n  else return 0;\n}\n\nexport function downloadAsFile(filename, content) {\n  let element = document.createElement(\"a\");\n  element.setAttribute(\n    \"href\",\n    `data:application/octet-stream;charset=utf-8,${encodeURIComponent(content)}`\n  );\n  element.setAttribute(\"download\", filename);\n\n  element.style.display = \"none\";\n  document.body.appendChild(element);\n\n  element.click();\n\n  document.body.removeChild(element);\n}\n\nexport const isEmptyString = (s) => s.length === 0;\nexport const isOdd = (num) => num % 2 !== 0;\n\nexport function isEmptyObject(o) {\n  return o == null || (typeof o === \"object\" && !Object.keys(o).length);\n}\n"
  },
  {
    "path": "src/components/App.jsx",
    "content": "import \"../scss/main.scss\";\nimport { Component } from \"react\";\nimport Layout from \"./Layout\";\n\nclass App extends Component {\n  render() {\n    return <Layout />;\n  }\n}\n\nexport default App;\n"
  },
  {
    "path": "src/components/InfoPopup.jsx",
    "content": "import Popup from \"./atoms/Popup\";\nimport copy from \"../common/data/copy.json\";\n\nconst Infopopup = ({ isOpen, onClose, language, styles }) => (\n  <Popup\n    title={copy[language].legend.default.header}\n    content={copy[language].legend.default.intro}\n    onClose={onClose}\n    isOpen={isOpen}\n    styles={styles}\n  />\n);\n\nexport default Infopopup;\n"
  },
  {
    "path": "src/components/Layout.jsx",
    "content": "import { Component } from \"react\";\n\nimport { bindActionCreators } from \"redux\";\nimport { connect } from \"react-redux\";\nimport * as actions from \"../actions\";\nimport * as selectors from \"../selectors\";\n\nimport Toolbar from \"./Toolbar\";\nimport InfoPopup from \"./InfoPopup\";\nimport Notification from \"./Notification\";\nimport TemplateCover from \"./TemplateCover\";\n\nimport Popup from \"./atoms/Popup\";\nimport StaticPage from \"./atoms/StaticPage\";\nimport MediaOverlay from \"./atoms/Media\";\nimport LoadingOverlay from \"./atoms/Loading\";\n\nimport Timeline from \"./time/Timeline\";\nimport Space from \"./space/Space\";\nimport Search from \"./controls/Search\";\nimport CardStack from \"./controls/CardStack\";\nimport NarrativeControls from \"./controls/NarrativeControls\";\n\nimport colors from \"../common/global\";\nimport { binarySearch, insetSourceFrom } from \"../common/utilities\";\n\nclass Dashboard extends Component {\n  constructor(props) {\n    super(props);\n\n    this.handleViewSource = this.handleViewSource.bind(this);\n    this.handleHighlight = this.handleHighlight.bind(this);\n    this.setNarrative = this.setNarrative.bind(this);\n    this.setNarrativeFromFilters = this.setNarrativeFromFilters.bind(this);\n    this.handleSelect = this.handleSelect.bind(this);\n    this.getCategoryColor = this.getCategoryColor.bind(this);\n    this.findEventIdx = this.findEventIdx.bind(this);\n    this.onKeyDown = this.onKeyDown.bind(this);\n    this.selectNarrativeStep = this.selectNarrativeStep.bind(this);\n  }\n\n  componentDidMount() {\n    this.props.actions.fetchDomain().then((domain) => {\n      this.props.actions.updateDomain({\n        domain,\n        features: this.props.features,\n      });\n      this.props.actions.rehydrateState();\n    });\n\n    // NOTE: hack to get the timeline to always show. Not entirely sure why\n    // this is necessary.\n    window.dispatchEvent(new Event(\"resize\"));\n  }\n\n  handleHighlight(highlighted) {\n    this.props.actions.updateHighlighted(highlighted || null);\n  }\n\n  handleViewSource(source) {\n    this.props.actions.updateSource(source);\n  }\n\n  findEventIdx(theEvent) {\n    const { events } = this.props.domain;\n    return binarySearch(events, theEvent, (theev, otherev) => {\n      return theev.datetime - otherev.datetime;\n    });\n  }\n\n  handleSelect(selected, axis) {\n    if (selected.length <= 0) {\n      this.props.actions.updateSelected([]);\n      return;\n    }\n\n    const matchedEvents = [];\n    const TIMELINE_AXIS = 0;\n    if (axis === TIMELINE_AXIS) {\n      matchedEvents.push(selected);\n      // find in events\n      const { events } = this.props.domain;\n      const idx = this.findEventIdx(selected);\n      // binary search can return event with different id\n      if (events[idx].id !== selected.id) {\n        matchedEvents.push(events[idx]);\n      }\n\n      // check events before\n      let ptr = idx - 1;\n      while (\n        ptr >= 0 &&\n        events[idx].datetime.getTime() === events[ptr].datetime.getTime()\n      ) {\n        if (events[ptr].id !== selected.id) {\n          matchedEvents.push(events[ptr]);\n        }\n        ptr -= 1;\n      }\n\n      // check events after\n      ptr = idx + 1;\n      while (\n        ptr < events.length &&\n        events[idx].datetime.getTime() === events[ptr].datetime.getTime()\n      ) {\n        if (events[ptr].id !== selected.id) {\n          matchedEvents.push(events[ptr]);\n        }\n        ptr += 1;\n      }\n    } else {\n      // Map..\n      const std = { ...selected };\n      delete std.sources;\n      Object.values(std).forEach((ev) => matchedEvents.push(ev));\n    }\n\n    this.props.actions.updateSelected(matchedEvents);\n  }\n\n  getCategoryColor(category) {\n    if (!this.props.features.USE_CATEGORIES) {\n      return colors.fallbackEventColor;\n    }\n\n    const cat = this.props.ui.style.categories[category];\n    if (cat) {\n      return cat;\n    } else {\n      return this.props.ui.style.categories.default;\n    }\n  }\n\n  setNarrative(narrative) {\n    // only handleSelect if narrative is not null and has associated events\n    if (narrative && narrative.steps.length >= 1) {\n      this.handleSelect([narrative.steps[0]]);\n    }\n    this.props.actions.updateNarrative(narrative);\n  }\n\n  setNarrativeFromFilters(withSteps) {\n    const { app, domain } = this.props;\n    let activeFilters = app.associations.filters;\n\n    if (activeFilters.length === 0) {\n      alert(\"No filters selected, cant narrativise\");\n      return;\n    }\n\n    activeFilters = activeFilters.map((f) => ({ name: f }));\n\n    const evs = domain.events.filter((ev) => {\n      let hasOne = false;\n      // add event if it has at least one matching filter\n      for (let i = 0; i < activeFilters.length; i++) {\n        if (ev.associations.includes(activeFilters[i].name)) {\n          hasOne = true;\n          break;\n        }\n      }\n      if (hasOne) return true;\n      return false;\n    });\n\n    if (evs.length === 0) {\n      alert(\"No associated events, cant narrativise\");\n      return;\n    }\n\n    const name = activeFilters.map((f) => f.name).join(\"-\");\n    const desc = activeFilters.map((f) => f.description).join(\"\\n\\n\");\n    this.setNarrative({\n      id: name,\n      label: name,\n      description: desc,\n      withLines: withSteps,\n      steps: evs.map(insetSourceFrom(domain.sources)),\n    });\n  }\n\n  selectNarrativeStep(idx) {\n    // Try to find idx if event passed rather than number\n    if (typeof idx !== \"number\") {\n      const e = idx[0] || idx;\n\n      if (this.props.app.associations.narrative) {\n        const { steps } = this.props.app.associations.narrative;\n        // choose the first event at a given location\n        const locationEventId = e.id;\n        const narrativeIdxObj = steps.find((s) => s.id === locationEventId);\n        const narrativeIdx = steps.indexOf(narrativeIdxObj);\n\n        if (narrativeIdx > -1) {\n          idx = narrativeIdx;\n        }\n      }\n    }\n\n    const { narrative } = this.props.app.associations;\n    if (narrative === null) return;\n\n    if (idx < narrative.steps.length && idx >= 0) {\n      const step = narrative.steps[idx];\n\n      this.handleSelect([step]);\n      this.props.actions.updateNarrativeStepIdx(idx);\n    }\n  }\n\n  onKeyDown(e) {\n    const { narrative, selected } = this.props.app;\n    const { events } = this.props.domain;\n\n    const prev = (idx) => {\n      if (narrative === null) {\n        this.handleSelect(events[idx - 1], 0);\n      } else {\n        this.selectNarrativeStep(this.props.narrativeIdx - 1);\n      }\n    };\n    const next = (idx) => {\n      if (narrative === null) {\n        this.handleSelect(events[idx + 1], 0);\n      } else {\n        this.selectNarrativeStep(this.props.narrativeIdx + 1);\n      }\n    };\n    if (selected.length > 0) {\n      const ev = selected[selected.length - 1];\n      const idx = this.findEventIdx(ev);\n      switch (e.keyCode) {\n        case 37: // left arrow\n        case 38: // up arrow\n          if (idx <= 0) return;\n          prev(idx);\n          break;\n        case 39: // right arrow\n        case 40: // down arrow\n          if (idx < 0 || idx >= this.props.domain.length - 1) return;\n          next(idx);\n          break;\n        default:\n      }\n    }\n  }\n\n  renderIntroPopup(styles) {\n    const { app, actions } = this.props;\n    const localStorageKey = \"rememberDismissedIntro2\"; // can be incremented when new data appears on the cover\n\n    let searchParams = new URLSearchParams(window.location.href.split(\"?\")[1]);\n    let rememberDismissedIntro =\n      localStorage.getItem(localStorageKey) === \"true\";\n    let forceShowIntro = searchParams.get(\"cover\") === \"true\";\n    if (\n      (forceShowIntro || !rememberDismissedIntro) &&\n      !searchParams.has(\"id\")\n    ) {\n      return (\n        <Popup\n          title=\"Introduction to the platform\"\n          theme=\"dark\"\n          isOpen={\n            app.flags.isIntropopup && searchParams.get(\"cover\") !== \"false\"\n          }\n          onClose={() => {\n            actions.toggleIntroPopup();\n            localStorage.setItem(localStorageKey, \"true\");\n          }}\n          content={app.intro}\n          styles={styles}\n        ></Popup>\n      );\n    } else {\n      return null;\n    }\n  }\n\n  render() {\n    const { actions, app, domain, timeline, features } = this.props;\n\n    const popupStyles = {};\n\n    return (\n      <div>\n        {\n          <Toolbar\n            isNarrative={!!app.associations.narrative}\n            domain={domain}\n            methods={{\n              onTitle: actions.toggleCover,\n              onSelectFilter: (filters) =>\n                actions.toggleAssociations(\"filters\", filters),\n              onCategoryFilter: (categories) =>\n                actions.toggleAssociations(\"categories\", categories),\n              onShapeFilter: actions.toggleShapes,\n              onSelectNarrative: this.setNarrative,\n            }}\n          />\n        }\n        <Space\n          kind={\"map\" in app ? \"map\" : \"space3d\"}\n          onKeyDown={this.onKeyDown}\n          methods={{\n            onSelectNarrative: this.setNarrative,\n            getCategoryColor: this.getCategoryColor,\n            onSelect: app.associations.narrative\n              ? this.selectNarrativeStep\n              : (ev) => this.handleSelect(ev, 1),\n          }}\n        />\n        {\n          <Timeline\n            onKeyDown={this.onKeyDown}\n            methods={{\n              onSelect: app.associations.narrative\n                ? this.selectNarrativeStep\n                : (ev) => this.handleSelect(ev, 0),\n              onUpdateTimerange: actions.updateTimeRange,\n              getCategoryColor: this.getCategoryColor,\n            }}\n          />\n        }\n        <CardStack\n          timelineDims={timeline.dimensions}\n          onViewSource={this.handleViewSource}\n          onSelect={\n            app.associations.narrative ? this.selectNarrativeStep : () => null\n          }\n          onHighlight={this.handleHighlight}\n          onToggleCardstack={() => actions.updateSelected([])}\n          getCategoryColor={this.getCategoryColor}\n        />\n        <NarrativeControls\n          narrative={\n            app.associations.narrative\n              ? {\n                  ...app.associations.narrative,\n                  current: this.props.narrativeIdx,\n                }\n              : null\n          }\n          methods={{\n            onNext: () => this.selectNarrativeStep(this.props.narrativeIdx + 1),\n            onPrev: () => this.selectNarrativeStep(this.props.narrativeIdx - 1),\n            onSelectNarrative: this.setNarrative,\n          }}\n        />\n        <InfoPopup\n          language={app.language}\n          styles={popupStyles}\n          isOpen={app.flags.isInfopopup}\n          onClose={actions.toggleInfoPopup}\n        />\n        {this.renderIntroPopup(popupStyles)}\n        {app.debug ? (\n          <Notification\n            isNotification={app.flags.isNotification}\n            notifications={domain.notifications}\n            onToggle={actions.markNotificationsRead}\n          />\n        ) : null}\n        {features.USE_SEARCH && (\n          <Search\n            narrative={app.narrative}\n            queryString={app.searchQuery}\n            events={domain.events}\n            onSearchRowClick={this.handleSelect}\n          />\n        )}\n        {app.source ? (\n          <MediaOverlay\n            source={app.source}\n            onCancel={() => {\n              actions.updateSource(null);\n            }}\n          />\n        ) : null}\n        <LoadingOverlay\n          isLoading={app.loading || app.flags.isFetchingDomain}\n          ui={app.flags.isFetchingDomain}\n          language={app.language}\n        />\n        {features.USE_COVER && (\n          <StaticPage showing={app.flags.isCover}>\n            {/* enable USE_COVER in config.js features, and customise your header */}\n            {/* pass 'actions.toggleCover' as a prop to your custom header */}\n            <TemplateCover\n              showing={app.flags.isCover}\n              showAppHandler={actions.toggleCover}\n            />\n          </StaticPage>\n        )}\n      </div>\n    );\n  }\n}\n\nfunction mapDispatchToProps(dispatch) {\n  return {\n    actions: bindActionCreators(actions, dispatch),\n  };\n}\n\nexport default connect(\n  (state) => ({\n    ...state,\n    timeline: {\n      dimensions: selectors.selectDimensions(state),\n    },\n    narrativeIdx: selectors.selectNarrativeIdx(state),\n    narratives: selectors.selectNarratives(state),\n    selected: selectors.selectSelected(state),\n  }),\n  mapDispatchToProps\n)(Dashboard);\n"
  },
  {
    "path": "src/components/Notification.jsx",
    "content": "import { Component } from \"react\";\n\nexport default class Notification extends Component {\n  constructor(props) {\n    super();\n    this.state = {\n      isExtended: false,\n    };\n  }\n\n  toggleDetails() {\n    this.setState({ isExtended: !this.state.isExtended });\n  }\n\n  renderItems(items) {\n    if (!items) return \"\";\n    return (\n      <div>\n        {items.map((item, idx) => {\n          if (item.error) {\n            return <p key={idx}>{item.error.message}</p>;\n          }\n          return null;\n        })}\n      </div>\n    );\n  }\n\n  renderNotificationContent(notification) {\n    const { type, message, items } = notification;\n\n    return (\n      <div>\n        <div className={`message ${type}`}>{message}</div>\n        <div className={`details ${this.state.isExtended}`}>\n          {items !== null ? this.renderItems(items) : \"\"}\n        </div>\n      </div>\n    );\n  }\n\n  render() {\n    if (!this.props.notifications) return null;\n    const notificationsToRender = this.props.notifications.filter(\n      (n) => !(\"isRead\" in n && n.isRead)\n    );\n    if (notificationsToRender.length > 0) {\n      return (\n        <div className=\"notification-wrapper\">\n          {this.props.notifications.map((notification, idx) => {\n            return (\n              <div\n                className=\"notification\"\n                onClick={() => this.toggleDetails()}\n                key={idx}\n              >\n                <button\n                  onClick={this.props.onToggle}\n                  className=\"side-menu-burg over-white is-active\"\n                >\n                  <span />\n                </button>\n                {this.renderNotificationContent(notification)}\n              </div>\n            );\n          })}\n        </div>\n      );\n    }\n    return <div />;\n  }\n}\n"
  },
  {
    "path": "src/components/Portal.jsx",
    "content": "import { Component } from \"react\";\nimport ReactDOM from \"react-dom\";\n\nclass Portal extends Component {\n  render() {\n    const { children, node } = this.props;\n    if (!node) return null;\n    return ReactDOM.createPortal(children, node);\n  }\n}\n\nexport default Portal;\n"
  },
  {
    "path": "src/components/TemplateCover.jsx",
    "content": "import { Component } from \"react\";\nimport { connect } from \"react-redux\";\nimport { Player } from \"video-react\";\nimport { marked } from \"marked\";\nimport MediaOverlay from \"./atoms/Media\";\n// import falogo from \"../assets/fa-logo.png\";\nimport bcatlogo from \"../assets/bellingcat-logo.png\";\nconst MEDIA_HIDDEN = -2;\n\n/**\n * Manages the presentation of props that come in from the store's app.cover.\n * These are documented in docs/custom-cover.md.\n * The component is a bit of a mess, keeping a lot of internal state and using\n * a couple of weird offset calculations... but it works for the time being.\n */\nclass TemplateCover extends Component {\n  constructor(props) {\n    super(props);\n    this.state = {\n      video: MEDIA_HIDDEN,\n      featureLang: 0,\n    };\n  }\n\n  getVideo(index, headerEndIndex) {\n    if (index < headerEndIndex) {\n      return this.props.cover.headerVideos[index];\n    } else if (index >= 0) {\n      return this.props.cover.videos[index - headerEndIndex];\n    } else {\n      return null;\n    }\n  }\n\n  onVideoClickHandler(index) {\n    const buffer = this.props.cover.headerVideos\n      ? this.props.cover.headerVideos.length\n      : 0;\n    return () => {\n      this.setState({\n        video: index + buffer,\n      });\n    };\n  }\n\n  renderFeature() {\n    const { featureVideo } = this.props.cover;\n    const { featureLang } = this.state;\n    const { translations } = featureVideo;\n    const source =\n      featureLang === 0\n        ? featureVideo\n        : {\n            ...translations[featureLang - 1],\n            poster: featureVideo.poster,\n          };\n\n    return (\n      <div>\n        <div className=\"banner-trans right-overlay\">\n          {translations &&\n            translations.map((trans, idx) => {\n              const langIdx = idx + 1; // default lang idx is 0\n              if (featureLang !== langIdx) {\n                return (\n                  <div\n                    key={trans.code}\n                    onClick={() => this.setState({ featureLang: langIdx })}\n                    className=\"trans-button\"\n                  >\n                    {trans.code}\n                  </div>\n                );\n              } else {\n                return (\n                  <div\n                    key=\"ENG\"\n                    onClick={() => this.setState({ featureLang: 0 })}\n                    className=\"trans-button\"\n                  >\n                    ENG\n                  </div>\n                );\n              }\n            })}\n        </div>\n\n        <Player\n          className=\"source-video\"\n          poster={source.poster}\n          playsInline\n          src={source.file}\n        />\n      </div>\n    );\n  }\n\n  renderHeaderVideos() {\n    const { headerVideos } = this.props.cover;\n    return (\n      <div className=\"row\">\n        {headerVideos.slice(0, 2).map((media, index) => (\n          <div\n            key={index}\n            className=\"cell plain\"\n            onClick={() => this.setState({ video: index })}\n          >\n            {media.buttonTitle}\n          </div>\n        ))}\n      </div>\n    );\n  }\n\n  renderButton(button, yellow) {\n    return (\n      <div className=\"row\">\n        <a className={`cell ${yellow ? \"yellow\" : \"plain\"}`} href={button.href}>\n          {button.title}\n        </a>\n      </div>\n    );\n  }\n\n  renderMediaOverlay() {\n    const video = this.getVideo(\n      this.state.video,\n      this.props.cover.headerVideos ? this.props.cover.headerVideos.length : 0\n    );\n    return (\n      <MediaOverlay\n        opaque\n        source={{\n          title: video.title,\n          desc: video.desc,\n          paths: [video.file],\n          poster: video.poster,\n        }}\n        translations={video.translations}\n        onCancel={() => this.setState({ video: MEDIA_HIDDEN })}\n      />\n    );\n  }\n\n  render() {\n    if (!this.props.cover) {\n      return (\n        <div className=\"default-cover-container\">\n          You haven't specified any cover props. Put them in the values that\n          overwrite the store in <code>app.cover</code>\n        </div>\n      );\n    }\n\n    const { videos, footerButton } = this.props.cover;\n    const { showing } = this.props;\n    return (\n      <div className=\"default-cover-container\">\n        <div className={showing ? \"cover-header\" : \"cover-header minimized\"}>\n          <a className=\"cover-logo-container\" href=\"https://bellingcat.com\">\n            <img className=\"cover-logo\" src={bcatlogo} alt=\"Bellingcat logo\" />\n          </a>\n        </div>\n        <div className=\"cover-content\">\n          {this.props.cover.bgVideo ? (\n            <div\n              className={`fullscreen-bg ${!this.props.showing ? \"hidden\" : \"\"}`}\n            >\n              <video\n                loop\n                muted\n                autoPlay\n                preload=\"auto\"\n                className=\"fullscreen-bg__video\"\n              >\n                <source src={this.props.cover.bgVideo} type=\"video/mp4\" />\n              </video>\n            </div>\n          ) : null}\n          <h2 dangerouslySetInnerHTML={{ __html: this.props.cover.title }} />\n          {this.props.cover.subtitle ? (\n            <h3 style={{ marginTop: 0 }}>{this.props.cover.subtitle}</h3>\n          ) : null}\n          {this.props.cover.subsubtitle ? (\n            <h5>{this.props.cover.subsubtitle}</h5>\n          ) : null}\n\n          {this.props.cover.featureVideo ? this.renderFeature() : null}\n          <div className=\"hero thin\">\n            {this.props.cover.headerVideos ? this.renderHeaderVideos() : null}\n            {this.props.cover.headerButton\n              ? this.renderButton(this.props.cover.headerButton)\n              : null}\n            <div className=\"row\">\n              <div className=\"cell yellow\" onClick={this.props.showAppHandler}>\n                {this.props.cover.exploreButton}\n              </div>\n            </div>\n          </div>\n\n          {Array.isArray(this.props.cover.description) ? (\n            this.props.cover.description.map((e, index) => (\n              <div\n                key={index}\n                className=\"md-container\"\n                dangerouslySetInnerHTML={{ __html: marked(e) }}\n              />\n            ))\n          ) : (\n            <div\n              className=\"md-container\"\n              dangerouslySetInnerHTML={{\n                __html: marked(this.props.cover.description),\n              }}\n            />\n          )}\n\n          {videos ? (\n            <div className=\"hero\">\n              <div className=\"row\">\n                {/* NOTE: only take first four videos, drop any others for style reasons */}\n                {videos &&\n                  videos.slice(0, 2).map((media, index) => (\n                    <div\n                      key={index}\n                      className=\"cell small\"\n                      onClick={this.onVideoClickHandler(index)}\n                    >\n                      {media.buttonTitle}\n                      <br />\n                      {media.buttonSubtitle}\n                    </div>\n                  ))}\n              </div>\n              <div className=\"row\">\n                {videos.length > 2 &&\n                  this.props.cover.videos.slice(2, 4).map((media, index) => (\n                    <div\n                      key={index}\n                      className=\"cell small\"\n                      onClick={this.onVideoClickHandler(index + 2)}\n                    >\n                      {media.buttonTitle}\n                      <br />\n                      {media.buttonSubtitle}\n                    </div>\n                  ))}\n              </div>\n            </div>\n          ) : null}\n          {footerButton ? (\n            <div className=\"hero\">\n              <div className=\"row\">{this.renderButton(footerButton)}</div>\n            </div>\n          ) : null}\n        </div>\n\n        {this.state.video !== MEDIA_HIDDEN ? this.renderMediaOverlay() : null}\n      </div>\n    );\n  }\n}\n\nfunction mapStateToProps(state) {\n  return {\n    cover: state.app.cover,\n  };\n}\n\nexport default connect(mapStateToProps)(TemplateCover);\n"
  },
  {
    "path": "src/components/Toolbar.jsx",
    "content": "import { Component } from \"react\";\nimport { connect } from \"react-redux\";\nimport { bindActionCreators } from \"redux\";\nimport * as actions from \"../actions\";\nimport * as selectors from \"../selectors\";\nimport config from \"../../config\";\n\nimport { Tabs, TabList, TabPanel } from \"react-tabs\";\nimport FilterListPanel from \"./controls/FilterListPanel\";\nimport CategoriesListPanel from \"./controls/CategoriesListPanel\";\nimport ShapesListPanel from \"./controls/ShapesListPanel\";\nimport BottomActions from \"./controls/BottomActions\";\nimport copy from \"../common/data/copy.json\";\nimport {\n  trimAndEllipse,\n  getImmediateFilterParent,\n  getFilterSiblings,\n  getFilterAncestors,\n  addToColoringSet,\n  removeFromColoringSet,\n  mapCategoriesToPaths,\n  getCategoryIdxs,\n  getFilterIdx,\n} from \"../common/utilities\";\nimport { ToolbarButton } from \"./controls/atoms/ToolbarButton\";\nimport { FullscreenToggle } from \"./controls/FullScreenToggle\";\nimport DownloadPanel from \"./controls/DownloadPanel\";\n\nclass Toolbar extends Component {\n  constructor(props) {\n    super(props);\n    this.onSelectFilter = this.onSelectFilter.bind(this);\n    this.state = { _selected: 0, _active: false };\n  }\n\n  selectTab(selected) {\n    let active = true;\n    if (this.state._selected === selected && this.state._active === true) {\n      active = false;\n    }\n    this.setState({ _selected: selected, _active: active });\n  }\n\n  onSelectFilter(key, matchingKeys) {\n    const { filters, activeFilters, coloringSet, maxNumOfColors } = this.props;\n\n    const parent = getImmediateFilterParent(key);\n    const isTurningOff = activeFilters.includes(key);\n\n    if (!isTurningOff) {\n      const updatedColoringSet = addToColoringSet(coloringSet, matchingKeys);\n      if (updatedColoringSet.length <= maxNumOfColors) {\n        this.props.actions.updateColoringSet(updatedColoringSet);\n      }\n    } else {\n      if (parent && activeFilters.includes(parent)) {\n        const siblings = getFilterSiblings(filters, parent, key);\n        let siblingsOff = true;\n        for (const sibling of siblings) {\n          if (activeFilters.includes(sibling)) {\n            siblingsOff = false;\n            break;\n          }\n        }\n\n        if (siblingsOff) {\n          const grandparentsOn = getFilterAncestors(key).filter((filt) =>\n            activeFilters.includes(filt)\n          );\n          matchingKeys = matchingKeys.concat(grandparentsOn);\n        }\n      }\n\n      const updatedColoringSet = removeFromColoringSet(\n        coloringSet,\n        matchingKeys\n      );\n      this.props.actions.updateColoringSet(updatedColoringSet);\n    }\n    this.props.methods.onSelectFilter(matchingKeys);\n    this.props.actions.updateSelected([]);\n  }\n\n  renderClosePanel() {\n    return (\n      <div\n        className=\"panel-header\"\n        onClick={() => this.selectTab(this.state._selected)}\n      >\n        <div className=\"caret\" />\n      </div>\n    );\n  }\n\n  goToNarrative(narrative) {\n    // this.selectTab(-1); // set all unselected within this component\n    this.props.methods.onSelectNarrative(narrative);\n  }\n\n  renderToolbarNarrativePanel() {\n    const { panels } = this.props.toolbarCopy;\n    return (\n      <TabPanel>\n        <h2>{panels.narratives.label}</h2>\n        <p>{panels.narratives.description}</p>\n        {this.props.narratives.map((narr) => {\n          return (\n            <div className=\"panel-action action\">\n              <button\n                onClick={() => {\n                  this.goToNarrative(narr);\n                }}\n              >\n                <p>{narr.id}</p>\n                <p>\n                  <small>{trimAndEllipse(narr.desc, 120)}</small>\n                </p>\n              </button>\n            </div>\n          );\n        })}\n      </TabPanel>\n    );\n  }\n\n  renderToolbarCategoriesPanel() {\n    const { categories: panelCategories } = this.props.toolbarCopy.panels;\n    const catMap = mapCategoriesToPaths(\n      this.props.categories,\n      Object.keys(panelCategories)\n    );\n\n    return (\n      <div>\n        {Object.keys(catMap).map((type) => {\n          const children = catMap[type];\n          return (\n            <TabPanel key={type}>\n              <CategoriesListPanel\n                categories={children}\n                activeCategories={this.props.activeCategories}\n                onCategoryFilter={this.props.methods.onCategoryFilter}\n                language={this.props.language}\n                title={panelCategories[type].label}\n                description={panelCategories[type].description}\n              />\n            </TabPanel>\n          );\n        })}\n      </div>\n    );\n  }\n\n  renderToolbarFilterPanel() {\n    const { panels } = this.props.toolbarCopy;\n    return (\n      <TabPanel>\n        <FilterListPanel\n          filters={this.props.filters}\n          activeFilters={this.props.activeFilters}\n          onSelectFilter={this.onSelectFilter}\n          language={this.props.language}\n          coloringSet={this.props.coloringSet}\n          filterColors={this.props.filterColors}\n          title={panels.filters.label}\n          description={panels.filters.description}\n        />\n      </TabPanel>\n    );\n  }\n\n  renderToolbarShapePanel() {\n    const { panels } = this.props.toolbarCopy;\n\n    if (this.props.features.USE_SHAPES) {\n      return (\n        <TabPanel>\n          <ShapesListPanel\n            shapes={this.props.shapes}\n            activeShapes={this.props.activeShapes}\n            onShapeFilter={this.props.methods.onShapeFilter}\n            language={this.props.language}\n            title={panels.shapes.label}\n            description={panels.shapes.description}\n          />\n        </TabPanel>\n      );\n    }\n  }\n\n  renderToolbarDownloadPanel() {\n    const { panels } = this.props.toolbarCopy;\n\n    return (\n      <TabPanel>\n        <DownloadPanel\n          language={this.props.language}\n          title={panels.download.label}\n          description={panels.download.description}\n          domain={this.props.domain}\n        />\n      </TabPanel>\n    );\n  }\n\n  renderToolbarTab(_selected, label, iconKey, key) {\n    return (\n      <ToolbarButton\n        key={key}\n        label={label}\n        iconKey={iconKey}\n        isActive={\n          this.state._selected === _selected && this.state._active === true\n        }\n        onClick={() => {\n          this.selectTab(_selected);\n        }}\n      />\n    );\n  }\n\n  renderToolbarCategoryTabs(idxs) {\n    const { categories: panelCategories } = this.props.toolbarCopy.panels;\n    return (\n      <div>\n        {Object.keys(idxs).map((key) => {\n          return this.renderToolbarTab(\n            idxs[key],\n            panelCategories[key].label,\n            panelCategories[key].icon,\n            key\n          );\n        })}\n      </div>\n    );\n  }\n\n  renderToolbarPanels() {\n    const { features, narratives } = this.props;\n    const classes =\n      this.state._active === true ? \"toolbar-panels\" : \"toolbar-panels folded\";\n    return (\n      <div className={classes}>\n        {this.renderClosePanel()}\n        {narratives && narratives.length !== 0\n          ? this.renderToolbarNarrativePanel()\n          : null}\n        {features.USE_CATEGORIES ? this.renderToolbarCategoriesPanel() : null}\n        {features.USE_ASSOCIATIONS ? this.renderToolbarFilterPanel() : null}\n        {features.USE_SHAPES ? this.renderToolbarShapePanel() : null}\n        {features.USE_DOWNLOAD ? this.renderToolbarDownloadPanel() : null}\n      </div>\n    );\n  }\n\n  renderToolbarNavs() {\n    if (this.props.narratives) {\n      return this.props.narratives.map((nar, idx) => {\n        const isActive =\n          idx === this.state._selected && this.state._active === true;\n\n        const classes = isActive ? \"toolbar-tab active\" : \"toolbar-tab\";\n\n        return (\n          <div\n            className={classes}\n            onClick={() => {\n              this.selectTab(idx);\n            }}\n          >\n            <div className=\"tab-caption\">{nar.label}</div>\n          </div>\n        );\n      });\n    }\n    return null;\n  }\n\n  renderToolbarTabs() {\n    const { features, narratives, toolbarCopy } = this.props;\n    const narrativesExist = narratives && narratives.length !== 0;\n    let title = copy[this.props.language].toolbar.title;\n    if (config.display_title) title = config.display_title;\n    const { panels } = toolbarCopy;\n\n    const narrativesIdx = 0;\n    const categoryIdxs = getCategoryIdxs(\n      Object.keys(panels.categories),\n      narrativesExist ? 1 : 0\n    );\n    const numCategoryPanels = Object.keys(categoryIdxs).length;\n    const filtersIdx = getFilterIdx(\n      narrativesExist,\n      features.USE_CATEGORIES,\n      numCategoryPanels || 0\n    );\n    const shapesIdx = filtersIdx + features.USE_SHAPES;\n    const downloadIdx = shapesIdx + features.USE_DOWNLOAD;\n\n    return (\n      <div className=\"toolbar\">\n        <div className=\"toolbar-header\" onClick={this.props.methods.onTitle}>\n          <p>{title}</p>\n        </div>\n        <div className=\"toolbar-tabs\">\n          <TabList>\n            {narrativesExist\n              ? this.renderToolbarTab(\n                  narrativesIdx,\n                  panels.narratives.label,\n                  panels.narratives.icon\n                )\n              : null}\n            {features.USE_CATEGORIES\n              ? this.renderToolbarCategoryTabs(categoryIdxs)\n              : null}\n            {features.USE_ASSOCIATIONS\n              ? this.renderToolbarTab(\n                  filtersIdx,\n                  panels.filters.label,\n                  panels.filters.icon\n                )\n              : null}\n            {features.USE_SHAPES\n              ? this.renderToolbarTab(\n                  shapesIdx,\n                  panels.shapes.label,\n                  panels.shapes.icon\n                )\n              : null}\n            {features.USE_DOWNLOAD\n              ? this.renderToolbarTab(\n                  downloadIdx,\n                  panels.download.label,\n                  panels.download.icon\n                )\n              : null}\n            {features.USE_FULLSCREEN && (\n              <FullscreenToggle language={this.props.language} />\n            )}\n          </TabList>\n        </div>\n        <BottomActions\n          info={{\n            enabled: this.props.infoShowing,\n            toggle: this.props.actions.toggleInfoPopup,\n          }}\n          sites={{\n            enabled: this.props.sitesShowing,\n            toggle: this.props.actions.toggleSites,\n          }}\n          cover={{\n            toggle: this.props.actions.toggleCover,\n          }}\n          features={this.props.features}\n        />\n\n        <div id=\"made-with\">\n          Made with{\" \"}\n          <a href=\"https://github.com/forensic-architecture/timemap\">TimeMap</a>\n          <br />\n          Free software from{\" \"}\n          <a href=\"https://forensic-architecture.org\">Forensic Architecture</a>\n        </div>\n      </div>\n    );\n  }\n\n  render() {\n    const { isNarrative } = this.props;\n\n    return (\n      <div\n        id=\"toolbar-wrapper\"\n        className={`toolbar-wrapper ${isNarrative ? \"narrative-mode\" : \"\"}`}\n      >\n        <Tabs onSelect={() => null} selectedIndex={this.state._selected}>\n          {this.renderToolbarTabs()}\n          {this.renderToolbarPanels()}\n        </Tabs>\n      </div>\n    );\n  }\n}\n\nfunction mapStateToProps(state) {\n  return {\n    filters: selectors.getFilters(state),\n    categories: selectors.getCategories(state),\n    narratives: selectors.selectNarratives(state),\n    shapes: selectors.getShapes(state),\n    language: state.app.language,\n    toolbarCopy: state.app.toolbar,\n    activeFilters: selectors.getActiveFilters(state),\n    activeCategories: selectors.getActiveCategories(state),\n    activeShapes: selectors.getActiveShapes(state),\n    viewFilters: state.app.associations.views,\n    narrative: state.app.associations.narrative,\n    sitesShowing: state.app.flags.isShowingSites,\n    infoShowing: state.app.flags.isInfopopup,\n    coloringSet: state.app.associations.coloringSet,\n    maxNumOfColors: state.ui.coloring.maxNumOfColors,\n    filterColors: state.ui.coloring.colors,\n    eventRadius: state.ui.eventRadius,\n    features: selectors.getFeatures(state),\n  };\n}\n\nfunction mapDispatchToProps(dispatch) {\n  return {\n    actions: bindActionCreators(actions, dispatch),\n  };\n}\n\nexport default connect(mapStateToProps, mapDispatchToProps)(Toolbar);\n"
  },
  {
    "path": "src/components/atoms/Checkbox.jsx",
    "content": "import { DEFAULT_CHECKBOX_COLOR } from \"../../common/constants\";\n\nconst Checkbox = ({ label, isActive, onClickCheckbox, color, styleProps }) => {\n  const checkboxColor = color ? color : DEFAULT_CHECKBOX_COLOR;\n  const baseStyles = {\n    checkboxStyles: {\n      background: isActive ? checkboxColor : \"none\",\n      border: `1px solid ${checkboxColor}`,\n    },\n  };\n  const containerStyles = styleProps ? styleProps.containerStyles : {};\n  const checkboxStyles = styleProps\n    ? styleProps.checkboxStyles\n    : baseStyles.checkboxStyles;\n\n  const generatedId = label.toLowerCase().replaceAll(\" \", \"-\");\n  const onClickCheckboxWrapper = (e) => {\n    // stop propagation in order to call method only one time\n    e.stopPropagation();\n    onClickCheckbox(e);\n  };\n  return (\n    <div\n      className={isActive ? \"item active\" : \"item\"}\n      onClick={onClickCheckboxWrapper}\n    >\n      <button id={generatedId} onClick={onClickCheckboxWrapper}>\n        <div className=\"border\" style={containerStyles}>\n          <div className=\"checkbox\" style={checkboxStyles} />\n        </div>\n      </button>\n      <label htmlFor={generatedId} style={{ color: color }}>\n        {label}\n      </label>\n    </div>\n  );\n};\n\nexport default Checkbox;\n"
  },
  {
    "path": "src/components/atoms/CoeventIcon.jsx",
    "content": "const CoeventIcon = ({ isEnabled, toggleMapViews }) => {\n  return (\n    <button onClick={() => toggleMapViews(\"coevents\")}>\n      <svg\n        className=\"coevents\"\n        x=\"0px\"\n        y=\"0px\"\n        width=\"30px\"\n        height=\"20px\"\n        viewBox=\"0 0 30 20\"\n        enableBackground=\"new 0 0 30 20\"\n      >\n        <polygon\n          strokeLinejoin=\"round\"\n          strokeMiterlimit=\"10\"\n          points=\"19.178,20 10.823,20 10.473,14.081\n          10,13.396 10,6.084 20,6.084 20,13.396 19.445,14.021 \"\n        />\n        <rect\n          className=\"no-fill\"\n          x=\"11.4\"\n          y=\"7.867\"\n          width=\"7.2\"\n          height=\"3.35\"\n        />\n        <line\n          strokeLinejoin=\"round\"\n          strokeMiterlimit=\"10\"\n          x1=\"12.125\"\n          y1=\"1\"\n          x2=\"12.125\"\n          y2=\"5.35\"\n        />\n        <rect x=\"11.4\" y=\"4.271\" width=\"1.496\" height=\"1.079\" />\n        <rect x=\"17.104\" y=\"4.271\" width=\"1.496\" height=\"1.079\" />\n      </svg>\n    </button>\n  );\n};\n\nexport default CoeventIcon;\n"
  },
  {
    "path": "src/components/atoms/ColoredMarkers.jsx",
    "content": "import { getCoordinatesForPercent } from \"../../common/utilities\";\n\nfunction ColoredMarkers({ radius, colorPercentMap, styles, className }) {\n  let cumulativeAngleSweep = 0;\n  const colors = Object.keys(colorPercentMap);\n\n  return (\n    <>\n      {colors.map((color, idx) => {\n        const colorPercent = colorPercentMap[color];\n\n        const [startX, startY] = getCoordinatesForPercent(\n          radius,\n          cumulativeAngleSweep\n        );\n\n        cumulativeAngleSweep += colorPercent;\n\n        const [endX, endY] = getCoordinatesForPercent(\n          radius,\n          cumulativeAngleSweep\n        );\n        // if the slices are less than 2, take the long arc\n        const largeArcFlag = colors.length === 1 || colorPercent > 0.5 ? 1 : 0;\n\n        // create an array and join it just for code readability\n        const arc = [\n          `M ${startX} ${startY}`, // Move\n          `A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endX} ${endY}`, // Arc\n          \"L 0 0 \", // Line\n          `L ${startX} ${startY} Z`, // Line\n        ].join(\" \");\n\n        const extraStyles = {\n          ...styles,\n          fill: color,\n        };\n\n        return (\n          <path\n            key={`arc_${idx}`}\n            className={className}\n            id={`arc_${idx}`}\n            d={arc}\n            style={extraStyles}\n          />\n        );\n      })}\n    </>\n  );\n}\n\nexport default ColoredMarkers;\n"
  },
  {
    "path": "src/components/atoms/Content.jsx",
    "content": "import { Player } from \"video-react\";\nimport { Img } from \"react-image\";\nimport Md from \"./Md\";\nimport Spinner from \"../atoms/Spinner\";\nimport NoSource from \"../atoms/NoSource\";\n\nconst Content = ({ media, viewIdx, translations, switchLanguage, langIdx }) => {\n  const el = document.querySelector(\".source-media-gallery\");\n  const shiftW = el ? el.getBoundingClientRect().width : 0;\n\n  function renderMedia(media) {\n    const { path, type, poster } = media;\n    switch (type) {\n      case \"Image\":\n        return (\n          <div className=\"source-image-container\">\n            <Img\n              className=\"source-image\"\n              src={path}\n              loader={\n                <div className=\"source-image-loader\">\n                  <Spinner />\n                </div>\n              }\n              unloader={<NoSource failedUrls={[path]} />}\n              onClick={() => window.open(path, \"_blank\")}\n            />\n          </div>\n        );\n      case \"Video\":\n        return (\n          <div className=\"media-player\">\n            <div className=\"banner-trans right-overlay\">\n              {translations\n                ? translations.map((trans, idx) =>\n                    langIdx !== idx + 1 ? (\n                      <div\n                        className=\"trans-button\"\n                        onClick={() => switchLanguage(idx + 1)}\n                      >\n                        {trans.code}\n                      </div>\n                    ) : (\n                      <div\n                        className=\"trans-button\"\n                        onClick={() => switchLanguage(0)}\n                      >\n                        EN\n                      </div>\n                    )\n                  )\n                : null}\n            </div>\n            <Player\n              poster={poster}\n              className=\"source-video\"\n              playsInline\n              src={path}\n            />\n          </div>\n        );\n      case \"Text\":\n        return (\n          <div className=\"source-text-container\">\n            <Md\n              path={path}\n              loader={<Spinner />}\n              unloader={() => this.renderError()}\n            />\n          </div>\n        );\n      case \"Document\":\n        return <iframe title={path} className=\"source-document\" src={path} />;\n      default:\n        return (\n          <NoSource\n            failedUrls={[\n              `Application does not support extension: ${path.split(\".\")[1]}`,\n            ]}\n          />\n        );\n    }\n  }\n\n  return (\n    <div\n      className=\"source-media-gallery\"\n      style={{ transform: `translate(${viewIdx * -shiftW}px)` }}\n    >\n      {media.map((m) => renderMedia(m))}\n    </div>\n  );\n};\n\nexport default Content;\n"
  },
  {
    "path": "src/components/atoms/Controls.jsx",
    "content": "const OverlayControls = ({ viewIdx, paths, onShiftHandler }) => {\n  const backArrow =\n    viewIdx !== 0 ? (\n      <div className=\"back\" onClick={() => onShiftHandler(-1)}>\n        <div className=\"centerer\">\n          <i className=\"material-icons\">arrow_left</i>\n        </div>\n      </div>\n    ) : null;\n  const forwardArrow =\n    viewIdx < paths.length - 1 ? (\n      <div className=\"next\" onClick={() => onShiftHandler(1)}>\n        <div className=\"centerer\">\n          <i className=\"material-icons\">arrow_right</i>\n        </div>\n      </div>\n    ) : null;\n\n  if (paths.length > 1) {\n    return (\n      <div className=\"media-gallery-controls\">\n        {backArrow}\n        {forwardArrow}\n      </div>\n    );\n  }\n  return <div className=\"media-gallery-controls\" />;\n};\n\nexport default OverlayControls;\n"
  },
  {
    "path": "src/components/atoms/CoverIcon.jsx",
    "content": "const CoverIcon = ({ isActive, isDisabled, onClickHandler }) => {\n  let classes = isActive ? \"action-button enabled\" : \"action-button\";\n  if (isDisabled) {\n    classes = \"action-button disabled\";\n  }\n\n  return (\n    <button className={classes} onClick={onClickHandler}>\n      <i className=\"material-icons\">home</i>\n    </button>\n  );\n};\n\nexport default CoverIcon;\n"
  },
  {
    "path": "src/components/atoms/InfoIcon.jsx",
    "content": "const CoverIcon = ({ isActive, isDisabled, onClickHandler }) => {\n  let classes = isActive ? \"action-button enabled\" : \"action-button\";\n  if (isDisabled) {\n    classes = \"action-button disabled\";\n  }\n\n  return (\n    <button className={classes} onClick={onClickHandler}>\n      <i className=\"material-icons\">info</i>\n    </button>\n  );\n};\n\nexport default CoverIcon;\n"
  },
  {
    "path": "src/components/atoms/Loading.jsx",
    "content": "import copy from \"../../common/data/copy.json\";\n\nconst LoadingOverlay = ({ isLoading, language }) => {\n  let classes = \"loading-overlay\";\n  classes += !isLoading ? \" hidden\" : \"\";\n\n  return (\n    <div id=\"loading-overlay\" className={classes}>\n      <div className=\"loading-wrapper\">\n        <span id=\"loading-text\" className=\"text\">\n          {copy[language].loading}\n        </span>\n        <div className=\"spinner\">\n          <div className=\"double-bounce1\" />\n          <div className=\"double-bounce2\" />\n        </div>\n      </div>\n    </div>\n  );\n};\n\nexport default LoadingOverlay;\n"
  },
  {
    "path": "src/components/atoms/Md.jsx",
    "content": "import { Component } from \"react\";\nimport PropTypes from \"prop-types\";\nimport { marked } from \"marked\";\n\nclass Md extends Component {\n  constructor(props) {\n    super(props);\n    this.state = { md: null, error: null };\n  }\n\n  componentDidMount() {\n    fetch(this.props.path)\n      .then((resp) => resp.text())\n      .then((text) => {\n        if (text.length <= 0) {\n          throw new Error();\n        }\n\n        this.setState({ md: marked(text) });\n      })\n      .catch(() => {\n        this.setState({ error: true });\n      });\n  }\n\n  render() {\n    if (this.state.md && !this.state.error) {\n      return (\n        <div\n          className=\"md-container\"\n          dangerouslySetInnerHTML={{ __html: this.state.md }}\n        />\n      );\n    } else if (this.state.error) {\n      return this.props.unloader || <div>Error: couldn't load source</div>;\n    } else {\n      return this.props.loader;\n    }\n  }\n}\n\nMd.propTypes = {\n  loader: PropTypes.func,\n  unloader: PropTypes.func.isRequired,\n  path: PropTypes.string.isRequired,\n};\n\nexport default Md;\n"
  },
  {
    "path": "src/components/atoms/Media.jsx",
    "content": "import { Component } from \"react\";\nimport { marked } from \"marked\";\nimport Content from \"./Content\";\nimport Controls from \"./Controls\";\nimport { selectTypeFromPathWithPoster } from \"../../common/utilities\";\n\n/*\n * Inside the SourceOverlay, both the currently displaying media and language\n * can be changed by the user. These are both managed in this component's React\n * state.\n */\nclass SourceOverlay extends Component {\n  constructor() {\n    super();\n    this.state = { mediaIdx: 0, langIdx: 0 };\n    this.onShiftGallery = this.onShiftGallery.bind(this);\n  }\n\n  getTypeCounts(media) {\n    return media.reduce(\n      (acc, vl) => {\n        acc[vl.type] += 1;\n        return acc;\n      },\n      { Image: 0, Video: 0, Text: 0 }\n    );\n  }\n\n  onShiftGallery(shift) {\n    // no more left\n    if (this.state.mediaIdx === 0 && shift === -1) return;\n    // no more right\n    if (\n      this.state.mediaIdx === this.props.source.paths.length - 1 &&\n      shift === 1\n    )\n      return;\n    this.setState({ mediaIdx: this.state.mediaIdx + shift });\n  }\n\n  switchLanguage(idx) {\n    this.setState({ langIdx: idx });\n  }\n\n  renderContent(source) {\n    const { url, title, paths, date, type, poster, description } = source;\n    const shortenedTitle = title.substring(0, 100);\n    return (\n      <>\n        <div className=\"mo-banner\">\n          <div className=\"mo-banner-close\" onClick={this.props.onCancel}>\n            <i className=\"material-icons\">close</i>\n          </div>\n\n          <h3 className=\"mo-banner-content\">{shortenedTitle}</h3>\n        </div>\n        <div className=\"mo-container\" onClick={(e) => e.stopPropagation()}>\n          <div className=\"mo-media-container\">\n            <Content\n              switchLanguage={(lang) => this.switchLanguage(lang)}\n              translations={this.props.translations}\n              langIdx={this.state.langIdx}\n              media={paths.map((p) => selectTypeFromPathWithPoster(p, poster))}\n              viewIdx={this.state.mediaIdx}\n            />\n          </div>\n        </div>\n\n        <div className=\"mo-footer\">\n          <Controls\n            paths={paths}\n            viewIdx={this.state.mediaIdx}\n            onShiftHandler={this.onShiftGallery}\n          />\n\n          <div className=\"mo-meta-container\">\n            {description ? (\n              <div className=\"mo-box-desc\">\n                <div\n                  className=\"md-container\"\n                  dangerouslySetInnerHTML={{ __html: marked(description) }}\n                />\n              </div>\n            ) : null}\n\n            {type || date || url ? (\n              <div className=\"mo-box\">\n                <div>\n                  {type ? <h4>Evidence type</h4> : null}\n                  {type ? (\n                    <p>\n                      <i className=\"material-icons left\">perm_media</i>\n                      {type}\n                    </p>\n                  ) : null}\n                </div>\n                <div>\n                  {date ? <h4>Date Published</h4> : null}\n                  {date ? (\n                    <p>\n                      <i className=\"material-icons left\">today</i>\n                      {date}\n                    </p>\n                  ) : null}\n                </div>\n                <div>\n                  {url ? <h4>Link</h4> : null}\n                  {url ? (\n                    <span>\n                      <i className=\"material-icons left\">link</i>\n                      <a href={url} target=\"_blank\" rel=\"noreferrer\">\n                        Link to original URL\n                      </a>\n                    </span>\n                  ) : null}\n                </div>\n              </div>\n            ) : null}\n          </div>\n        </div>\n      </>\n    );\n  }\n\n  renderIntlContent() {\n    const { langIdx } = this.state;\n    const { translations, source } = this.props;\n    let translated = null;\n    if (translations && translations.length && langIdx > 0) {\n      translated = translations[langIdx - 1];\n    }\n    if (translated) {\n      translated = {\n        ...translated,\n        poster: source.poster,\n        // NOTE: this is to allow a slightly nicer syntax when using the Media\n        // overlay in cover videos.\n        paths: translated.file ? [translated.file] : translated.paths,\n      };\n    }\n\n    return this.renderContent(langIdx === 0 ? source : translated);\n  }\n\n  render() {\n    if (typeof this.props.source !== \"object\") {\n      return this.renderError();\n    }\n\n    return (\n      <div className={`mo-overlay ${this.props.opaque ? \"opaque\" : \"\"}`}>\n        {this.renderIntlContent()}\n      </div>\n    );\n  }\n}\n\nexport default SourceOverlay;\n"
  },
  {
    "path": "src/components/atoms/NoSource.jsx",
    "content": "const NoSource = ({ failedUrls }) => {\n  return (\n    <div className=\"no-source-container\">\n      <div className=\"no-source-row\">\n        <p>\n          <i className=\"material-icons no-source-icon\">error</i>\n        </p>\n        <p>\n          No media found, as the original media has not yet been uploaded to the\n          platform.\n        </p>\n      </div>\n    </div>\n  );\n};\n\nexport default NoSource;\n"
  },
  {
    "path": "src/components/atoms/Popup.jsx",
    "content": "import { marked } from \"marked\";\n\nconst fontSize = window.innerWidth > 1000 ? 14 : 18;\n\nconst Popup = ({\n  content = [],\n  styles = {},\n  isOpen = true,\n  onClose,\n  title,\n  theme = \"light\",\n  children,\n}) => (\n  <div>\n    <div\n      className={`infopopup__bg ${isOpen ? \"\" : \"hidden\"}`}\n      onClick={onClose}\n    ></div>\n    <div\n      className={`infopopup ${isOpen ? \"\" : \"hidden\"} ${\n        theme === \"dark\" ? \"dark\" : \"light\"\n      }`}\n      style={{ ...styles, fontSize }}\n    >\n      <div className=\"legend-header\">\n        <button\n          onClick={onClose}\n          className=\"side-menu-burg over-white is-active\"\n        >\n          <span />\n        </button>\n        <h2>{title}</h2>\n      </div>\n      {content.map((t, idx) => (\n        <div key={idx} dangerouslySetInnerHTML={{ __html: marked(t) }} />\n      ))}\n      {children}\n    </div>\n  </div>\n);\n\nexport default Popup;\n"
  },
  {
    "path": "src/components/atoms/RefreshIcon.jsx",
    "content": "export default ({ isActive, isDisabled, onClickHandler }) => {\n  return (\n    <svg\n      className=\"reset\"\n      x=\"0px\"\n      y=\"0px\"\n      width=\"25px\"\n      height=\"25px\"\n      viewBox=\"7.5 7.5 25 25\"\n      enableBackground=\"new 7.5 7.5 25 25\"\n    >\n      <path\n        strokeWidth=\"2\"\n        strokeMiterlimit=\"10\"\n        d=\"M28.822,16.386c1.354,3.219,0.898,7.064-1.5,9.924\n      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\"\n      />\n      <polygon points=\"26.137,12.748 27.621,19.464 28.9,16.741 31.898,16.503\" />\n    </svg>\n  );\n};\n"
  },
  {
    "path": "src/components/atoms/RouteIcon.jsx",
    "content": "const RouteIcon = ({ isEnabled, toggleMapViews }) => {\n  return (\n    <button onClick={() => toggleMapViews(\"routes\")}>\n      <svg\n        x=\"0px\"\n        y=\"0px\"\n        width=\"30px\"\n        height=\"20px\"\n        viewBox=\"0 0 30 20\"\n        enableBackground=\"new 0 0 30 20\"\n      >\n        <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\" />\n        <polyline points=\"16.671,9.228 19.103,7.233 16.671,5.237 \" />\n      </svg>\n    </button>\n  );\n};\n\nexport default RouteIcon;\n"
  },
  {
    "path": "src/components/atoms/SitesIcon.jsx",
    "content": "const SitesIcon = ({ isActive, isDisabled, onClickHandler }) => {\n  let classes = isActive ? \"action-button enabled\" : \"action-button\";\n  if (isDisabled) {\n    classes = \"action-button disabled\";\n  }\n\n  return (\n    <button className={classes} onClick={onClickHandler}>\n      <i className=\"material-icons\">location_on</i>\n    </button>\n  );\n};\n\nexport default SitesIcon;\n"
  },
  {
    "path": "src/components/atoms/Spinner.jsx",
    "content": "const Spinner = ({ small }) => {\n  return (\n    <div className={`spinner ${small ? \"small\" : \"\"}`}>\n      <div className=\"double-bounce-overlay\" />\n      <div className=\"double-bounce\" />\n    </div>\n  );\n};\n\nexport default Spinner;\n"
  },
  {
    "path": "src/components/atoms/StaticPage.jsx",
    "content": "const StaticPage = ({ showing, children }) => (\n  <div className={`cover-container ${showing ? \"showing\" : \"\"}`}>\n    {children}\n  </div>\n);\n\nexport default StaticPage;\n"
  },
  {
    "path": "src/components/controls/BottomActions.jsx",
    "content": "import SitesIcon from \"../atoms/SitesIcon\";\nimport CoverIcon from \"../atoms/CoverIcon\";\n// import InfoIcon from \"../atoms/InfoIcon\";\n\nfunction BottomActions(props) {\n  function renderToggles() {\n    return (\n      <>\n        <div className=\"bottom-action-block\">\n          {props.features.USE_SITES ? (\n            <SitesIcon\n              isActive={props.sites.enabled}\n              onClickHandler={props.sites.toggle}\n            />\n          ) : null}\n        </div>\n        {/* ,\n        <div className=\"botttom-action-block\">\n          <InfoIcon\n            isActive={props.info.enabled}\n            onClickHandler={props.info.toggle}\n          />\n        </div>\n        , */}\n        <div className=\"botttom-action-block\">\n          {props.features.USE_COVER ? (\n            <CoverIcon onClickHandler={props.cover.toggle} />\n          ) : null}\n        </div>\n        <div style={{ fontSize: 9, paddingTop: 10 }}>\n          Made with{\" \"}\n          <a href=\"https://github.com/forensic-architecture/timemap\">TimeMap</a>\n          <br />\n          Free software from <br />{\" \"}\n          <a href=\"https://forensic-architecture.org\">Forensic Architecture</a>\n        </div>\n      </>\n    );\n  }\n\n  return <div className=\"bottom-actions\">{renderToggles()}</div>;\n}\n\nexport default BottomActions;\n"
  },
  {
    "path": "src/components/controls/Card.jsx",
    "content": "import { useState } from \"react\";\nimport CardText from \"./atoms/Text\";\nimport CardTime from \"./atoms/Time\";\nimport CardButton from \"./atoms/Button\";\nimport CardCaret from \"./atoms/Caret\";\nimport CardCustom from \"./atoms/CustomField\";\nimport CardMedia from \"./atoms/Media\";\n\nimport { makeNiceDate, isEmptyString } from \"../../common/utilities\";\nimport hash from \"object-hash\";\n\nexport const generateCardLayout = {\n  basic: ({ event }) => {\n    return [\n      [\n        {\n          kind: \"date\",\n          title: \"Reported Incident Date\",\n          value: event.datetime || event.date || ``,\n        },\n        {\n          kind: \"text\",\n          title: \"Location\",\n          value: event.location || `—`,\n        },\n        {\n          kind: \"text\",\n          title: \"id\",\n          value: event.civId || `—`,\n        },\n      ],\n      [{ kind: \"line-break\", times: 0.4 }],\n      [\n        {\n          kind: \"text\",\n          title: \"Summary\",\n          value: event.description || ``,\n          scaleFont: 1.1,\n        },\n      ],\n    ];\n  },\n  sourced: ({ event }) => {\n    return [\n      [\n        {\n          kind: \"date\",\n          title: \"Reported Incident Date\",\n          value: event.datetime || event.date || ``,\n        },\n        {\n          kind: \"text\",\n          title: \"Location\",\n          value: event.location || `—`,\n        },\n        {\n          kind: \"text\",\n          title: \"id\",\n          value: event.civId || `—`,\n        },\n      ],\n      [\n        {\n          kind: \"text\",\n          title: \"Summary\",\n          value: event.description || ``,\n          scaleFont: 1.1,\n        },\n      ],\n      [\n        {\n          kind: \"sources\",\n          values: event.sources.flatMap((source) => [\n            source.paths.map((p) => ({\n              kind: \"media\",\n              title: \"Media\",\n              value: [\n                { src: p, title: null, graphic: event.graphic === \"TRUE\" },\n              ],\n            })),\n          ]),\n        },\n      ],\n    ];\n  },\n};\n\nexport const Card = ({\n  content = [],\n  isLoading = true,\n  cardIdx = -1,\n  onSelect = () => {},\n  sources = [],\n  isSelected = false,\n  language = \"en-US\",\n}) => {\n  const [isOpen, setIsOpen] = useState(false);\n  const toggle = () => setIsOpen(!isOpen);\n\n  // NB: should be internationalized.\n  const renderTime = (field) => (\n    <CardTime\n      language={language}\n      timelabel={makeNiceDate(field.value)}\n      {...field}\n    />\n  );\n\n  const renderCaret = () =>\n    sources.length === 0 && (\n      <CardCaret toggle={() => toggle()} isOpen={isOpen} />\n    );\n\n  const renderMedia = ({ media, idx, cardIdx }) => {\n    return (\n      <CardMedia\n        key={idx}\n        cardIdx={cardIdx}\n        src={media.src}\n        title={media.title}\n        graphic={media.graphic}\n      />\n    );\n  };\n\n  function renderField(field, cardIdx) {\n    switch (field.kind) {\n      case \"media\":\n        return (\n          <div className=\"card-cell\">\n            {field.value.map((media, idx) => {\n              return renderMedia({ media, idx, cardIdx });\n            })}\n          </div>\n        );\n      case \"line\":\n        return (\n          <div style={{ height: `1rem`, width: `100%` }}>\n            <hr />\n          </div>\n        );\n      case \"line-break\":\n        return (\n          <div style={{ height: `${field.times || 1}rem`, width: `100%` }} />\n        );\n      case \"item\":\n        // this is like a span\n        return null;\n      case \"markdown\":\n        return <CardCustom {...field} />;\n      case \"tag\":\n        return (\n          <div\n            className=\"card-cell m0\"\n            style={{\n              textTransform: `uppercase`,\n              fontSize: `.8em`,\n              lineHeight: `.8em`,\n            }}\n          >\n            <div\n              style={{\n                display: \"flex\",\n                justifyContent: `flex-${field.align || `start`}`,\n              }}\n            >\n              {field.value}\n            </div>\n          </div>\n        );\n      case \"button\":\n        return (\n          <div className=\"card-cell\">\n            {field.title && <h4>{field.title}</h4>}\n            {/* <div className=\"card-row\"> */}\n            {field.value.map((t, idx) => (\n              <CardButton key={`card-button-${idx}`} {...t} />\n            ))}\n            {/* </div> */}\n          </div>\n        );\n      case \"text\":\n        return !isEmptyString(field.value) && <CardText {...field} />;\n      case \"date\":\n        return renderTime(field);\n      case \"links\":\n        return (\n          <div className=\"card-cell\">\n            {field.title && <h4>{field.title}</h4>}\n            <div className=\"card-row m0\">\n              {field.value.map(({ text, href }, idx) => (\n                <a href={href} key={`card-links-url-${idx}`}>\n                  {text}\n                </a>\n              ))}\n            </div>\n          </div>\n        );\n      case \"list\":\n        // Only render if some of the list's strings are non-empty\n        const shouldFieldRender =\n          !!field.value.length &&\n          !!field.value.filter((s) => !isEmptyString(s)).length;\n        return shouldFieldRender ? (\n          // <div className=\"card-cell\">\n          <div>\n            {field.title && <h4>{field.title}</h4>}\n            <div className=\"card-row m0\">\n              {field.value.map((t, idx) => (\n                <CardText key={`card-list-text-${idx}`} value={t} {...t} />\n              ))}\n            </div>\n          </div>\n        ) : null;\n      default:\n        return null;\n    }\n  }\n\n  function renderRow(row, cardIdx, salt) {\n    return (\n      <div className=\"card-row\" key={hash({ ...row, salt })}>\n        {row.map((field) => (\n          // src by src meaning wrapGrahpic must be called around a map of renderField for sources\n          <span key={hash({ ...field, row: row })}>\n            {renderField(field, cardIdx)}\n          </span>\n        ))}\n      </div>\n    );\n  }\n\n  // TODO: render afterCaret appropriately from props\n  sources = [];\n\n  return (\n    <li\n      key={hash(content)}\n      className={`event-card ${isSelected ? \"selected\" : \"\"}`}\n      onClick={onSelect}\n    >\n      {content.map((row, idx) => {\n        if (row[0].kind === \"sources\" && row[0].values.length > 0) {\n          return (\n            <div key={idx}>\n              <details open={true}>\n                <summary>\n                  <span className=\"summary-line\"></span>\n                  <span className=\"summary-text\">\n                    <span className=\"summary-show\">Show</span>{\" \"}\n                    <span className=\"summary-hide\">Hide</span> sources (\n                    {row[0].values.length})\n                  </span>\n                  <span className=\"summary-line\"></span>\n                </summary>\n                {row[0].values.map((r) => renderRow(r, cardIdx, row[0]))}\n              </details>\n            </div>\n          );\n        } else return renderRow(row, cardIdx);\n      })}\n\n      {/* {isOpen && (\n        <div className=\"card-bottomhalf\">\n          {sources.map(() => (\n            <div className=\"card-row\"></div>\n          ))}\n        </div>\n      )} */}\n      {sources.length > 0 ? renderCaret() : null}\n    </li>\n  );\n};\n"
  },
  {
    "path": "src/components/controls/CardStack.jsx",
    "content": "import { createRef, Component } from \"react\";\nimport { connect } from \"react-redux\";\nimport { generateCardLayout, Card } from \"./Card\";\n\nimport * as selectors from \"../../selectors\";\nimport { getFilterIdxFromColorSet } from \"../../common/utilities\";\nimport copy from \"../../common/data/copy.json\";\n\nclass CardStack extends Component {\n  constructor() {\n    super();\n    this.refs = {};\n    this.refCardStack = createRef();\n    this.refCardStackContent = createRef();\n  }\n\n  componentDidUpdate() {\n    const isNarrative = !!this.props.narrative;\n\n    if (isNarrative) {\n      this.scrollToCard();\n    }\n  }\n\n  scrollToCard() {\n    const duration = 500;\n    const element = this.refCardStack.current;\n    const cardScroll =\n      this.refs[this.props.narrative.current].current.offsetTop;\n\n    const start = element.scrollTop;\n    const change = cardScroll - start;\n    let currentTime = 0;\n    const increment = 20;\n\n    // t = current time\n    // b = start value\n    // c = change in value\n    // d = duration\n    Math.easeInOutQuad = function (t, b, c, d) {\n      t /= d / 2;\n      if (t < 1) return (c / 2) * t * t + b;\n      t -= 1;\n      return (-c / 2) * (t * (t - 2) - 1) + b;\n    };\n\n    const animateScroll = function () {\n      currentTime += increment;\n      const val = Math.easeInOutQuad(currentTime, start, change, duration);\n      element.scrollTop = val;\n      if (currentTime < duration) setTimeout(animateScroll, increment);\n    };\n    animateScroll();\n  }\n\n  renderCards(events, selections) {\n    // if no selections provided, select all\n    if (!selections) {\n      selections = events.map((e) => true);\n    }\n    this.refs = [];\n\n    const generateTemplate =\n      generateCardLayout[this.props.cardUI.layout.template];\n\n    return events.map((event, idx) => {\n      const thisRef = createRef();\n      this.refs[idx] = thisRef;\n\n      const content = generateTemplate({\n        event,\n        colors: this.props.colors,\n        coloringSet: this.props.coloringSet,\n        getFilterIdxFromColorSet,\n      });\n\n      return (\n        <Card\n          key={idx}\n          cardIdx={idx}\n          content={content}\n          language={this.props.language}\n          isLoading={this.props.isLoading}\n          isSelected={selections[idx]}\n        />\n      );\n    });\n  }\n\n  renderSelectedCards() {\n    const { selected } = this.props;\n\n    if (selected.length > 0) {\n      return this.renderCards(selected);\n    }\n    return null;\n  }\n\n  renderNarrativeCards() {\n    const { narrative } = this.props;\n    const showing = narrative.steps;\n\n    const selections = showing.map((_, idx) => idx === narrative.current);\n\n    return this.renderCards(showing, selections);\n  }\n\n  renderCardStackHeader() {\n    const headerLang = copy[this.props.language].cardstack.header;\n\n    return (\n      <div\n        id=\"card-stack-header\"\n        className=\"card-stack-header\"\n        onClick={() => this.props.onToggleCardstack()}\n      >\n        <button className=\"side-menu-burg is-active\">\n          <span />\n        </button>\n        <p className=\"header-copy top\">\n          {`${this.props.selected.length} ${headerLang}`}\n        </p>\n      </div>\n    );\n  }\n\n  renderCardStackContent() {\n    return (\n      <div\n        id=\"card-stack-content\"\n        className=\"card-stack-content scrollbar-black\"\n      >\n        <ul>{this.renderSelectedCards()}</ul>\n      </div>\n    );\n  }\n\n  renderNarrativeContent() {\n    return (\n      <div\n        id=\"card-stack-content\"\n        className=\"card-stack-content\"\n        ref={this.refCardStackContent}\n      >\n        <ul>{this.renderNarrativeCards()}</ul>\n      </div>\n    );\n  }\n\n  render() {\n    const { isCardstack, selected, narrative } = this.props;\n    if (selected.length > 0) {\n      if (!narrative) {\n        return (\n          <div\n            id=\"card-stack\"\n            className={`card-stack ${isCardstack ? \"\" : \" folded\"}`}\n          >\n            {this.renderCardStackHeader()}\n            {this.renderCardStackContent()}\n          </div>\n        );\n      } else {\n        return (\n          <div\n            id=\"card-stack\"\n            ref={this.refCardStack}\n            className={`card-stack narrative-mode\n            ${isCardstack ? \"\" : \" folded\"}`}\n          >\n            {this.renderNarrativeContent()}\n          </div>\n        );\n      }\n    }\n\n    return <div />;\n  }\n}\n\nfunction mapStateToProps(state) {\n  return {\n    narrative: selectors.selectActiveNarrative(state),\n    selected: selectors.selectSelected(state),\n    sourceError: state.app.errors.source,\n    language: state.app.language,\n    isCardstack: state.app.flags.isCardstack,\n    isLoading: state.app.flags.isFetchingSources,\n    cardUI: state.ui.card,\n    colors: state.ui.coloring.colors,\n    coloringSet: state.app.associations.coloringSet,\n    features: state.features,\n  };\n}\n\nexport default connect(mapStateToProps)(CardStack);\n"
  },
  {
    "path": "src/components/controls/CategoriesListPanel.jsx",
    "content": "import { marked } from \"marked\";\nimport PanelTree from \"./atoms/PanelTree\";\nimport { ASSOCIATION_MODES } from \"../../common/constants\";\n\nconst CategoriesListPanel = ({\n  categories,\n  activeCategories,\n  onCategoryFilter,\n  language,\n  title,\n  description,\n}) => {\n  return (\n    <div className=\"react-innertabpanel\">\n      <h2>{title}</h2>\n      <p\n        dangerouslySetInnerHTML={{\n          __html: marked(description),\n        }}\n      />\n      <PanelTree\n        data={categories}\n        activeValues={activeCategories}\n        onSelect={onCategoryFilter}\n        type={ASSOCIATION_MODES.CATEGORY}\n      />\n    </div>\n  );\n};\n\nexport default CategoriesListPanel;\n"
  },
  {
    "path": "src/components/controls/DownloadButton.jsx",
    "content": "import { Component } from \"react\";\nimport dayjs from \"dayjs\";\nimport { Parser } from \"@json2csv/plainjs\";\nimport copy from \"../../common/data/copy.json\";\nimport { downloadAsFile } from \"../../common/utilities\";\nimport config from \"../../../config\";\n\nexport class DownloadButton extends Component {\n  onDownload(format, domain) {\n    let filename = `ukr-civharm-${dayjs().format(\"YYYY-MM-DD\")}`;\n    if (format === \"api\") {\n      console.log(config[\"API_DATA\"])\n      window.open(config[\"API_DATA\"], '_blank');\n    }else if (format === \"csv\") {\n      let outputData = this.getCsvData(domain);\n      downloadAsFile(`${filename}.csv`, outputData);\n    } else if (format === \"json\") {\n      let outputData = this.getJsonData(domain);\n      downloadAsFile(`${filename}.json`, outputData);\n    }\n  }\n  getCsvData(domain) {\n    const { events, sources } = domain;\n    const exportEvents = events.map((e) => {\n      return {\n        id: e.civId,\n        date: e.date,\n        latitude: e.latitude,\n        longitude: e.longitude,\n        location: e.location,\n        description: e.description,\n        sources: e.sources.map((s) => sources[s].paths[0]).join(\",\"),\n        associations: e.associations\n          .map((a) => a.filter_paths.join(\"=\"))\n          .join(\",\"),\n      };\n    });\n    const parser = new Parser();\n    return parser.parse(exportEvents, { flatten: true });\n  }\n  getJsonData(domain) {\n    const { events, sources } = domain;\n    const exportEvents = events.map((e) => {\n      return {\n        id: e.civId,\n        date: e.date,\n        latitude: e.latitude,\n        longitude: e.longitude,\n        location: e.location,\n        description: e.description,\n        sources: e.sources.map((id) => {\n          const s = sources[id];\n          return {\n            id,\n            path: s.paths[0],\n            description: s.description,\n          };\n        }),\n        filters: e.associations.map((a) => {\n          return {\n            key: a.filter_paths[0],\n            value: a.filter_paths[1],\n          };\n        }),\n      };\n    });\n    return JSON.stringify(exportEvents);\n  }\n  render() {\n    const { language, domain, format } = this.props;\n    const textByFormat = copy[language].toolbar.download.panel.formats[format];\n\n    let description = <span className=\"download-description\">{textByFormat.description}</span>;\n    \n    if(format=='api'){\n      const endpoint = config[\"API_DATA\"];\n      description = <span className=\"download-description\">{textByFormat.description} <a href={endpoint}>Copy API endpoint link from here.</a></span>\n    }\n\n    return (\n      <div className=\"download-row\">\n        <span\n          className=\"download-button\"\n          key={`download-${format}`}\n          onClick={() => this.onDownload(format, domain)}\n        >\n          <i className=\"material-icons\">{\"download\"}</i>\n          <span className=\"tab-caption\">{textByFormat.label}</span>\n        </span>\n       {description}\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "src/components/controls/DownloadPanel.jsx",
    "content": "import { DownloadButton } from \"./DownloadButton\";\n\nconst DownloadPanel = ({ language, title, description, domain }) => {\n  return (\n    <div className=\"react-innertabpanel\">\n      <div className=\"sticky-header\">\n        <h2>{title}</h2>\n      </div>\n      <div\n        className=\"panel-description\"\n        dangerouslySetInnerHTML={{\n          __html: description,\n        }}\n      />\n      <hr />\n      <DownloadButton language={language} domain={domain} format=\"api\" />\n      <DownloadButton language={language} domain={domain} format=\"csv\" />\n      <DownloadButton language={language} domain={domain} format=\"json\" />\n    </div>\n  );\n};\n\nexport default DownloadPanel;\n"
  },
  {
    "path": "src/components/controls/FilterListPanel.jsx",
    "content": "import Checkbox from \"../atoms/Checkbox\";\nimport { marked } from \"marked\";\nimport {\n  aggregateFilterPaths,\n  getFilterIdxFromColorSet,\n  getPathLeaf,\n} from \"../../common/utilities\";\n\n/** recursively get an array of node keys to toggle */\nfunction getFiltersToToggle(filter, activeFilters) {\n  const [key, children] = filter;\n\n  const turningOff = activeFilters.includes(key);\n  const childKeys = Object.entries(children)\n    .flatMap((filter) => getFiltersToToggle(filter, activeFilters))\n    .filter((child) => activeFilters.includes(child) === turningOff);\n\n  childKeys.push(key);\n  return childKeys;\n}\n\nfunction FilterListPanel({\n  filters,\n  activeFilters,\n  onSelectFilter,\n  language,\n  coloringSet,\n  filterColors,\n  title,\n  description,\n}) {\n  function createNodeComponent(filter, depth) {\n    const [key, children] = filter;\n    const pathLeaf = getPathLeaf(key);\n    const matchingKeys = getFiltersToToggle(filter, activeFilters);\n    const idxFromColorSet = getFilterIdxFromColorSet(key, coloringSet);\n    const assignedColor =\n      idxFromColorSet !== -1 && activeFilters.includes(key)\n        ? filterColors[idxFromColorSet]\n        : \"\";\n\n    const styles = {\n      color: assignedColor,\n      marginLeft: `${depth * 20}px`,\n    };\n\n    return (\n      <li\n        key={pathLeaf.replace(/ /g, \"_\")}\n        className=\"filter-filter\"\n        style={{ ...styles }}\n      >\n        <Checkbox\n          label={pathLeaf}\n          isActive={activeFilters.includes(key)}\n          onClickCheckbox={(e) => {\n            e.preventDefault();\n            onSelectFilter(key, matchingKeys);\n          }}\n          color={assignedColor}\n        />\n        {Object.keys(children).length > 0 ? (\n          <ul>\n            {Object.entries(children).map((filter) =>\n              createNodeComponent(filter, depth + 1)\n            )}\n          </ul>\n        ) : null}\n      </li>\n    );\n  }\n\n  function renderTree(filters) {\n    const aggregatedFilterPaths = aggregateFilterPaths(filters);\n\n    return (\n      <div className=\"scrolled-area\">\n        {Object.entries(aggregatedFilterPaths).map((filter) =>\n          createNodeComponent(filter, 0)\n        )}\n      </div>\n    );\n  }\n\n  return (\n    <div>\n      <div className=\"sticky-header\">\n        <h2>{title}</h2>\n      </div>\n      <div\n        className=\"panel-description\"\n        dangerouslySetInnerHTML={{\n          __html: marked(description),\n        }}\n      />\n      {renderTree(filters)}\n    </div>\n  );\n}\n\nexport default FilterListPanel;\n"
  },
  {
    "path": "src/components/controls/FullScreenToggle.jsx",
    "content": "import { Component } from \"react\";\nimport screenfull from \"screenfull\";\nimport { ToolbarButton } from \"./atoms/ToolbarButton\";\nimport copy from \"../../common/data/copy.json\";\n\nexport class FullscreenToggle extends Component {\n  constructor(props) {\n    super(props);\n\n    this.onFullscreenStateChange = this.onFullscreenStateChange.bind(this);\n\n    this.state = {\n      isFullscreen: screenfull.isFullscreen,\n    };\n  }\n\n  componentDidMount() {\n    if (screenfull.on) screenfull.on(\"change\", this.onFullscreenStateChange);\n  }\n\n  componentWillUnmount() {\n    if (screenfull.off) screenfull.off(\"change\", this.onFullscreenStateChange);\n  }\n\n  onFullscreenStateChange(evt) {\n    this.setState({ isFullscreen: screenfull.isFullscreen });\n  }\n\n  onToggleFullscreen() {\n    screenfull.toggle().catch(console.warn);\n  }\n\n  render() {\n    if (!screenfull.isEnabled) return null;\n\n    const { language } = this.props;\n    const { isFullscreen } = this.state;\n\n    return (\n      <ToolbarButton\n        isActive={isFullscreen}\n        label={\n          isFullscreen\n            ? copy[language].toolbar.fullscreen_exit\n            : copy[language].toolbar.fullscreen_enter\n        }\n        iconKey={isFullscreen ? \"fullscreen_exit\" : \"fullscreen\"}\n        onClick={this.onToggleFullscreen}\n      />\n    );\n  }\n}\n"
  },
  {
    "path": "src/components/controls/NarrativeControls.jsx",
    "content": "import Card from \"./atoms/NarrativeCard\";\nimport Adjust from \"./atoms/NarrativeAdjust\";\nimport Close from \"./atoms/NarrativeClose\";\n\nconst NarrativeControls = ({ narrative, methods }) => {\n  if (!narrative) return null;\n\n  const { current, steps } = narrative;\n  const prevExists = current !== 0;\n  const nextExists = current < steps.length - 1;\n\n  return (\n    <>\n      <Card narrative={narrative} />\n      <Adjust\n        isDisabled={!prevExists}\n        direction=\"left\"\n        onClickHandler={methods.onPrev}\n      />\n      <Adjust\n        isDisabled={!nextExists}\n        direction=\"right\"\n        onClickHandler={methods.onNext}\n      />\n      <Close\n        onClickHandler={() => methods.onSelectNarrative(null)}\n        closeMsg=\"-- exit from narrative --\"\n      />\n    </>\n  );\n};\n\nexport default NarrativeControls;\n"
  },
  {
    "path": "src/components/controls/Search.jsx",
    "content": "import { Component } from \"react\";\n\nimport { bindActionCreators } from \"redux\";\nimport { connect } from \"react-redux\";\nimport * as actions from \"../../actions\";\n\nimport SearchRow from \"./atoms/SearchRow.jsx\";\n\nclass Search extends Component {\n  constructor(props) {\n    super(props);\n\n    this.state = {\n      isFolded: true,\n    };\n    this.onButtonClick = this.onButtonClick.bind(this);\n    this.updateSearchQuery = this.updateSearchQuery.bind(this);\n  }\n\n  onButtonClick() {\n    this.setState((prevState) => {\n      return { isFolded: !prevState.isFolded };\n    });\n  }\n\n  updateSearchQuery(e) {\n    const queryString = e.target.value;\n    this.props.actions.updateSearchQuery(queryString);\n  }\n\n  render() {\n    let searchResults;\n\n    const searchAttributes = [\"description\", \"location\", \"category\", \"date\"];\n\n    if (!this.props.queryString) {\n      searchResults = [];\n    } else {\n      searchResults = this.props.events.filter((event) =>\n        searchAttributes.some((attribute) =>\n          event[attribute]\n            .toLowerCase()\n            .includes(this.props.queryString.toLowerCase())\n        )\n      );\n    }\n\n    return (\n      <div\n        className={\n          \"search-outer-container\" +\n          (this.props.narrative ? \" narrative-mode \" : \"\")\n        }\n      >\n        <div id=\"search-bar-icon-container\" onClick={this.onButtonClick}>\n          <i className=\"material-icons\">search</i>\n        </div>\n        <div\n          className={\n            \"search-bar-overlay\" + (this.state.isFolded ? \" folded\" : \"\")\n          }\n        >\n          <div className=\"search-input-container\">\n            <input\n              className=\"search-bar-input\"\n              onChange={this.updateSearchQuery}\n              type=\"text\"\n            />\n            <i\n              id=\"close-search-overlay\"\n              className=\"material-icons\"\n              onClick={this.onButtonClick}\n            >\n              close\n            </i>\n          </div>\n          <div className=\"search-results\">\n            {searchResults.map((result) => {\n              return (\n                <SearchRow\n                  onSearchRowClick={this.props.onSearchRowClick}\n                  eventObj={result}\n                  query={this.props.queryString}\n                />\n              );\n            })}\n          </div>\n        </div>\n      </div>\n    );\n  }\n}\n\nfunction mapDispatchToProps(dispatch) {\n  return {\n    actions: bindActionCreators(actions, dispatch),\n  };\n}\n\nexport default connect((state) => state, mapDispatchToProps)(Search);\n"
  },
  {
    "path": "src/components/controls/ShapesListPanel.jsx",
    "content": "import { marked } from \"marked\";\nimport PanelTree from \"./atoms/PanelTree\";\nimport { mapStyleByShape } from \"../../common/utilities\";\nimport { SHAPE } from \"../../common/constants\";\n\nconst ShapesListPanel = ({\n  shapes,\n  activeShapes,\n  onShapeFilter,\n  language,\n  title,\n  description,\n}) => {\n  const styledShapes = mapStyleByShape(shapes, activeShapes);\n  return (\n    <div className=\"react-innertabpanel\">\n      <h2>{title}</h2>\n      <p\n        dangerouslySetInnerHTML={{\n          __html: marked(description),\n        }}\n      />\n      <PanelTree\n        data={styledShapes}\n        activeValues={activeShapes}\n        onSelect={onShapeFilter}\n        type={SHAPE}\n      />\n    </div>\n  );\n};\n\nexport default ShapesListPanel;\n"
  },
  {
    "path": "src/components/controls/atoms/Button.jsx",
    "content": "import PropTypes from \"prop-types\";\n\n/**\n * Primary UI component for user interaction\n */\nexport const Button = ({\n  primary,\n  backgroundColor,\n  borderRadius,\n  size,\n  label,\n  normalCursor,\n  ...props\n}) => {\n  const mode = primary ? \"button--primary\" : \"button--secondary\";\n  return (\n    <button\n      type=\"button\"\n      className={[\n        \"button\",\n        `button--${size}`,\n        mode,\n        normalCursor ? \"no-hover\" : \"\",\n      ].join(\" \")}\n      style={{ backgroundColor: backgroundColor, borderRadius: borderRadius }}\n      {...props}\n    >\n      {label}\n    </button>\n  );\n};\n\nButton.propTypes = {\n  /**\n   * Is this the principal call to action on the page?\n   */\n  primary: PropTypes.bool,\n  /**\n   * What background color to use\n   */\n  backgroundColor: PropTypes.string,\n  /**\n   * How much rounded are they?\n   */\n  borderRadius: PropTypes.string,\n  /**\n   * How large should the button be?\n   */\n  size: PropTypes.oneOf([\"small\", \"medium\", \"large\"]),\n  /**\n   * Button contents\n   */\n  label: PropTypes.string.isRequired,\n  /**\n   * Optional click handler\n   */\n  onClick: PropTypes.func,\n};\n\nButton.defaultProps = {\n  backgroundColor: \"red\",\n  borderRadius: \"0%\",\n  primary: false,\n  size: \"medium\",\n  onClick: undefined,\n};\n\nconst CardButton = ({\n  text,\n  color = \"#000\",\n  onClick = () => {},\n  normalCursor,\n}) => (\n  <Button\n    size={\"small\"}\n    backgroundColor={color}\n    borderRadius={\"12px\"}\n    primary={false}\n    label={text}\n    onClick={onClick}\n    normalCursor={normalCursor}\n  />\n);\n\nexport default CardButton;\n"
  },
  {
    "path": "src/components/controls/atoms/Caret.jsx",
    "content": "const CardCaret = ({ isOpen, toggle }) => {\n  let classes = isOpen ? \"arrow-down\" : \"arrow-down folded\";\n\n  return (\n    <div className=\"card-toggle\" onClick={toggle}>\n      <p>\n        <i className={classes} />\n      </p>\n    </div>\n  );\n};\n\nexport default CardCaret;\n"
  },
  {
    "path": "src/components/controls/atoms/CustomField.jsx",
    "content": "import { marked } from \"marked\";\n\n// TODO could this be a security vulnerability?\nconst CardCustomField = ({ title, value }) => (\n  <div className=\"card-cell\">\n    {title ? <h4>{title}</h4> : null}\n    <div dangerouslySetInnerHTML={{ __html: marked(`${value}`) }} />\n  </div>\n);\n\nexport default CardCustomField;\n"
  },
  {
    "path": "src/components/controls/atoms/Media.jsx",
    "content": "import { useRef } from \"react\";\nimport { useCallback } from \"react\";\nimport { typeForPath } from \"../../../common/utilities\";\nimport TwitterTweet from'./TwitterTweet'\nimport TelegramPostEmbed from \"./TelegramEmbed\";\n\nconst TITLE_LENGTH = 50;\n// TODO should videos\n//    - play inline\n//    - appear zoomed out/in\n//    - only show cover image and then lightbox when clicked\n//    - show video control plane?\n// TODO landscape image doesn't fit in box properly\nconst Media = ({ cardIdx, src, title, graphic }) => {\n  const wrapGraphic = (content) => {\n    if (!graphic) return content;\n\n    const contentId = `graphic${cardIdx}`;\n    const overlayId = `overlay-${contentId}`;\n    return (\n      <div>\n        <div className={`card-cell media source-graphic ${overlayId}`}>\n          <h4\n            onClick={() => {\n              Array.from(document.querySelectorAll(\".\" + contentId)).map(\n                (o) => (o.style.display = \"block\")\n              );\n              // Array.from(document.querySelectorAll(\".\" + overlayId)).map(o => o.remove())\n              Array.from(document.querySelectorAll(\".\" + overlayId)).map(\n                (o) => (o.style.display = \"none\")\n              );\n              // document.getElementById(contentId).style.display = \"block\"\n            }}\n          >\n            Graphic content\n            <br />\n            Click here to show\n          </h4>\n        </div>\n        <span className={contentId} style={{ display: \"none\" }}>\n          {content}\n        </span>\n      </div>\n    );\n  };\n  const videoRef = useRef();\n  const onVideoStart = useCallback(() => {\n    return videoRef.current?.play();\n  }, []);\n  const onVideoStop = useCallback(() => {\n    return videoRef.current?.pause();\n  }, []);\n\n  const type = typeForPath(src);\n  const formattedTitle =\n    title && title.length > TITLE_LENGTH\n      ? `${title.slice(0, TITLE_LENGTH + 1)}...`\n      : title;\n\n  switch (type) {\n    case \"Video\":\n      return wrapGraphic(\n        <div className=\"card-cell media\">\n          {title && <h4 title={title}>{formattedTitle}</h4>}\n          <video\n            onMouseEnter={onVideoStart}\n            onMouseLeave={onVideoStop}\n            ref={videoRef}\n            // controls\n            // controlsList=\"nodownload noremoteplayback\"\n            disablePictureInPicture\n          >\n            <source src={src} />\n          </video>\n        </div>\n      );\n    case \"Image\":\n      return wrapGraphic(\n        <div className=\"card-cell media\">\n          {title && <h4 title={title}>{formattedTitle}</h4>}\n          <div className=\"img-wrapper\">\n            <img\n              src={src}\n              alt=\"an inline photograph for the event card component\"\n            />\n          </div>\n        </div>\n      );\n\n    case \"Telegram\":\n      if (src.includes(\"https://t.me/c/\")) {\n        return <div>Private <a href={src}>telegram post</a></div>\n      }\n      try {\n        return wrapGraphic(\n          <div className=\"card-cell media embedded\">\n            <TelegramPostEmbed src={src} />\n          </div>\n        );\n      } catch (error) {\n        return <div>Unable to display <a href={src}>telegram post</a></div>\n      }\n\n    case \"Tweet\":\n      const tweetIdRegex =\n        /https?:\\/\\/(mobile\\.){0,1}twitter.com\\/[0-9a-zA-Z_]{1,20}\\/status\\/([0-9]*)/;\n      const match = tweetIdRegex.exec(src);\n      if (!match || match.length < 2) {\n        return null;\n      }\n      const tweetId = match[match.length - 1];\n      try {\n        return wrapGraphic(\n          <div className=\"card-cell media embedded\">\n            <TwitterTweet\n              tweetId={tweetId}\n              options={{ conversation: \"none\" }}\n            />\n          </div>\n        );\n      } catch (error) {\n        return <div>Unable to display <a href={src}>tweet</a></div>\n      }\n    default:\n      if (src === \"HIDDEN\") {\n        return (\n          <div className=\"card-cell media source-hidden\">\n            <h4>\n              Source hidden\n              <br />\n              Privacy concerns\n            </h4>\n          </div>\n        );\n      } else {\n        return <div><a href={src}>other source</a></div>\n      }\n  }\n};\n\nexport default Media;\n"
  },
  {
    "path": "src/components/controls/atoms/NarrativeAdjust.jsx",
    "content": "const Adjust = ({ isDisabled, direction, onClickHandler }) => {\n  return (\n    <div\n      className={`narrative-adjust ${direction}`}\n      onClick={!isDisabled ? onClickHandler : null}\n    >\n      <i className={`material-icons ${isDisabled ? \"disabled\" : \"\"}`}>\n        {`chevron_${direction}`}\n      </i>\n    </div>\n  );\n};\n\nexport default Adjust;\n"
  },
  {
    "path": "src/components/controls/atoms/NarrativeCard.jsx",
    "content": "import { connect } from \"react-redux\";\nimport { selectActiveNarrative } from \"../../../selectors\";\n\nfunction NarrativeCard({ narrative }) {\n  // no display if no narrative\n  const { steps, current } = narrative;\n\n  if (steps[current]) {\n    return (\n      <div className=\"narrative-info\">\n        <div className=\"narrative-info-header\">\n          <div className=\"count-container\">\n            <div className=\"count\">\n              {current + 1}/{steps.length}\n            </div>\n          </div>\n          <div>\n            <h3>{narrative.label}</h3>\n          </div>\n        </div>\n\n        {/* <i className='material-icons left'>location_on</i> */}\n        {/* {_renderActions(current, steps)} */}\n        <div className=\"narrative-info-desc\">\n          <p>{narrative.description}</p>\n        </div>\n      </div>\n    );\n  } else {\n    return null;\n  }\n}\n\nfunction mapStateToProps(state) {\n  return {\n    narrative: selectActiveNarrative(state),\n  };\n}\nexport default connect(mapStateToProps)(NarrativeCard);\n"
  },
  {
    "path": "src/components/controls/atoms/NarrativeClose.jsx",
    "content": "const Close = ({ onClickHandler, closeMsg }) => {\n  return (\n    <div className=\"narrative-close\" onClick={onClickHandler}>\n      <button className=\"side-menu-burg is-active\">\n        <span />\n      </button>\n      <div className=\"close-text\">{closeMsg}</div>\n    </div>\n  );\n};\n\nexport default Close;\n"
  },
  {
    "path": "src/components/controls/atoms/PanelTree.jsx",
    "content": "import Checkbox from \"../../atoms/Checkbox\";\nimport { ASSOCIATION_MODES } from \"../../../common/constants\";\n\nconst PanelTree = ({ data, activeValues, onSelect, type }) => {\n  // If the parent panel is of type 'CATEGORY': filter on title. If panel is 'SHAPE': filter on id\n  const onSelectionType = type === ASSOCIATION_MODES.CATEGORY ? \"title\" : \"id\";\n  return (\n    <div>\n      {data.map((val) => {\n        return (\n          <li\n            key={val.title.replace(/ /g, \"_\")}\n            className=\"filter-filter active\"\n          >\n            <Checkbox\n              label={val.title}\n              isActive={activeValues.includes(val[onSelectionType])}\n              onClickCheckbox={() => onSelect(val[onSelectionType])}\n              styleProps={val.styles}\n            />\n          </li>\n        );\n      })}\n    </div>\n  );\n};\n\nexport default PanelTree;\n"
  },
  {
    "path": "src/components/controls/atoms/SearchRow.jsx",
    "content": "const SearchRow = ({ query, eventObj, onSearchRowClick }) => {\n  const { description, location, date } = eventObj;\n  function getHighlightedText(text, highlight) {\n    // Split text on highlight term, include term itself into parts, ignore case\n    const parts = text.split(new RegExp(`(${highlight})`, \"gi\"));\n    return (\n      <span>\n        {parts.map((part) =>\n          part.toLowerCase() === highlight.toLowerCase() ? (\n            <span style={{ backgroundColor: \"yellow\", color: \"black\" }}>\n              {part}\n            </span>\n          ) : (\n            part\n          )\n        )}\n      </span>\n    );\n  }\n\n  function getShortDescription(text, searchQuery) {\n    const regexp = new RegExp(\n      `(([^ ]* ){0,6}[a-zA-Z]*${searchQuery.toLowerCase()}[a-zA-Z]*( [^ ]*){0,5})`,\n      \"gm\"\n    );\n    const parts = text.toLowerCase().match(regexp);\n    for (let x = 0; x < (parts ? parts.length : 0); x++) {\n      parts[x] = \"...\" + parts[x];\n    }\n    const firstLine = [text.match(\"(([^ ]* ){0,10})\", \"m\")[0]];\n    return parts || firstLine;\n  }\n\n  return (\n    <div className=\"search-row\" onClick={() => onSearchRowClick([eventObj])}>\n      <div className=\"location-date-container\">\n        <div className=\"date-container\">\n          <i className=\"material-icons\">event</i>\n          <p>{getHighlightedText(date, query)}</p>\n        </div>\n        <div className=\"location-container\">\n          <i className=\"material-icons\">location_on</i>\n          <p>{getHighlightedText(location, query)}</p>\n        </div>\n      </div>\n      <p>\n        {getShortDescription(description, query).map((match) => {\n          return (\n            <span>\n              {getHighlightedText(match, query)}...\n              <br />\n            </span>\n          );\n        })}\n      </p>\n    </div>\n  );\n};\n\nexport default SearchRow;\n"
  },
  {
    "path": "src/components/controls/atoms/TelegramEmbed.jsx",
    "content": "/*\n * Adapted from https://github.com/cudr/react-telegram-embed\n */\nimport { Component } from \"react\";\n\nconst styles = {\n  width: \"100%\",\n  frameBorder: \"0\",\n  scrolling: \"no\",\n  border: \"none\",\n  overflow: \"hidden\",\n};\n\nconst containerStyles = {};\n\n/**\n * Simple Component for Telegram embedding\n * @extends Component\n */\n\nclass TelegramEmbed extends Component {\n  constructor(props) {\n    super(props);\n\n    this.state = {\n      src: this.props.src,\n      id: \"\",\n      height: \"80px\",\n    };\n    this.messageHandler = this.messageHandler.bind(this);\n    this.urlObj = document.createElement(\"a\");\n  }\n\n  componentDidMount() {\n    window.addEventListener(\"message\", this.messageHandler);\n\n    this.iFrame.addEventListener(\"load\", () => {\n      this.checkFrame(this.state.id);\n    });\n  }\n\n  componentWillUnmount() {\n    window.removeEventListener(\"message\", this.messageHandler);\n  }\n\n  messageHandler({ data, source }) {\n    if (\n      !data ||\n      typeof data !== \"string\" ||\n      source !== this.iFrame.contentWindow\n    ) {\n      return;\n    }\n\n    const action = JSON.parse(data);\n\n    if (action.event === \"resize\" && action.height) {\n      this.setState({\n        height: action.height + \"px\",\n      });\n    }\n  }\n\n  checkFrame(id) {\n    this.iFrame.contentWindow.postMessage(\n      JSON.stringify({ event: \"visible\", frame: id }),\n      \"*\"\n    );\n  }\n\n  UNSAFE_componentWillReceiveProps({ src }) {\n    if (this.state.src !== src) {\n      this.urlObj.href = src;\n      const id = `telegram-post${this.urlObj.pathname.replace(\n        /[^a-z0-9_]/gi,\n        \"-\"\n      )}`;\n\n      this.setState({ src, id }, () => this.checkFrame(id));\n    }\n  }\n\n  render() {\n    const { src, height } = this.state;\n    const { container } = this.props;\n    const embedSrc = new URL(src);\n    embedSrc.searchParams.append(\"embed\", \"1\");\n\n    return (\n      <div data-sharing-id={container} style={containerStyles}>\n        <iframe\n          title={src}\n          ref={(node) => (this.iFrame = node)}\n          src={embedSrc.toString()}\n          height={height}\n          id={\n            \"telegram-post\" + this.urlObj.pathname.replace(/[^a-z0-9_]/gi, \"-\")\n          }\n          style={styles}\n        />\n      </div>\n    );\n  }\n}\n\nexport default TelegramEmbed;\n"
  },
  {
    "path": "src/components/controls/atoms/Text.jsx",
    "content": "import { useState } from \"react\";\n\nconst CardText = ({ title, value, hoverValue = null }) => {\n  const [showHover, setShowHover] = useState(false);\n\n  return (\n    <div className=\"card-cell\">\n      {title ? <h4>{title}</h4> : null}\n      <div\n        className=\"card-cell__text\"\n        style={{\n          width: `fit-content`,\n        }}\n      >\n        <div\n          onMouseOver={() => hoverValue && setShowHover(true)}\n          onMouseOut={() => hoverValue && setShowHover(false)}\n        >\n          {showHover ? (\n            <span\n              style={{\n                pointerEvents: `none`,\n                opacity: 0.8,\n              }}\n            >\n              <em>{hoverValue}</em>\n            </span>\n          ) : (\n            <div\n              style={{\n                pointerEvents: `none`,\n                display: `inline-block`,\n                height: `1.1rem`,\n                borderBottom: hoverValue && `1px rgb(235, 68, 62) dashed`,\n              }}\n            >\n              {value}\n            </div>\n          )}\n        </div>\n        {/* {!showHover && value} */}\n      </div>\n    </div>\n  );\n};\n\nexport default CardText;\n"
  },
  {
    "path": "src/components/controls/atoms/Time.jsx",
    "content": "import copy from \"../../../common/data/copy.json\";\nimport { isNotNullNorUndefined } from \"../../../common/utilities\";\n\nconst CardTime = ({ title = \"Timestamp\", timelabel, language, precision }) => {\n  const unknownLang = copy[language].cardstack.unknown_time;\n\n  if (isNotNullNorUndefined(timelabel)) {\n    return (\n      <div className=\"card-cell\">\n        {/* <i className=\"material-icons left\">today</i> */}\n        <h4>{title}</h4>\n        {timelabel}\n        {precision && precision !== \"\" ? ` - ${precision}` : null}\n      </div>\n    );\n  } else {\n    return (\n      <div className=\"card-cell\">\n        {/* <i className=\"material-icons left\">today</i> */}\n        <h4>{title}</h4>\n        {unknownLang}\n      </div>\n    );\n  }\n};\n\nexport default CardTime;\n"
  },
  {
    "path": "src/components/controls/atoms/ToolbarButton.jsx",
    "content": "export function ToolbarButton({ isActive, iconKey, onClick, label }) {\n  return (\n    <div\n      className={isActive ? \"toolbar-tab active\" : \"toolbar-tab\"}\n      key={iconKey}\n      onClick={onClick}\n    >\n      <i className=\"material-icons\">{iconKey}</i>\n      <div className=\"tab-caption\">{label}</div>\n    </div>\n  );\n}\n\n// https://github.com/reactjs/react-tabs#set-tabsrole\nToolbarButton.tabsRole = \"Tab\";\n"
  },
  {
    "path": "src/components/controls/atoms/TwitterTweet.jsx",
    "content": "import React from 'react';\nimport script from 'scriptjs';\n/**\n * vite changes led to this error: https://github.com/saurabhnemade/react-twitter-embed/issues/105\n * applied same solution, and dropped https://github.com/saurabhnemade/react-twitter-embed\n */\n\nvar methodName$5 = 'createTweet';\nvar twitterWidgetJs = 'https://platform.twitter.com/widgets.js';\n\nconst TwitterTweet = (props) => {\n\tvar ref = React.useRef(null);\n\n\tvar _React$useState = React.useState(true),\n\t\tloading = _React$useState[0],\n\t\tsetLoading = _React$useState[1];\n\n\tReact.useEffect(function () {\n\t\tvar isComponentMounted = true;\n\n\t\tscript(twitterWidgetJs, 'twitter-embed', function () {\n\t\t\tif (!window.twttr) {\n\t\t\t\tconsole.error('Failure to load window.twttr, aborting load');\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (isComponentMounted) {\n\t\t\t\tif (!window.twttr.widgets[methodName$5]) {\n\t\t\t\t\tconsole.error(\"Method \" + methodName$5 + \" is not present anymore in twttr.widget api\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\twindow.twttr.widgets[methodName$5](props.tweetId, ref === null || ref === void 0 ? void 0 : ref.current, props.options).then(function (element) {\n\t\t\t\t\tsetLoading(false);\n\n\t\t\t\t\tif (props.onLoad) {\n\t\t\t\t\t\tprops.onLoad(element);\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t}\n\t\t});\n\t\treturn function () {\n\t\t\tisComponentMounted = false;\n\t\t};\n\t}, []);\n\treturn React.createElement(React.Fragment, null, loading && React.createElement(React.Fragment, null, props.placeholder), React.createElement(\"div\", {\n\t\tref: ref\n\t}));\n};\nexport default TwitterTweet;\n"
  },
  {
    "path": "src/components/space/Space.jsx",
    "content": "import MapCarto from \"./carto/Map\";\n// import Map3d from \"./3d/Map\";\n\nconst Space = (props) => {\n  switch (props.kind) {\n    // case \"3d\":\n    //   return <Map3d {...props} />;\n    default:\n      return <MapCarto {...props} />;\n  }\n};\n\nexport default Space;\n"
  },
  {
    "path": "src/components/space/carto/Map.jsx",
    "content": "/* global L */\nimport { bindActionCreators } from \"redux\";\nimport \"leaflet\";\nimport { createRef, Component } from \"react\";\nimport { flushSync } from \"react-dom\";\nimport Supercluster from \"supercluster\";\nimport { isMobileOnly } from \"react-device-detect\";\n\nimport { connect } from \"react-redux\";\nimport config from \"../../../../config\";\nimport * as actions from \"../../../actions\";\nimport * as selectors from \"../../../selectors\";\n\nimport Sites from \"./atoms/Sites\";\nimport Regions from \"./atoms/Regions\";\nimport Events from \"./atoms/Events\";\nimport Clusters from \"./atoms/Clusters\";\nimport SelectedEvents from \"./atoms/SelectedEvents\";\nimport Portal from \"../../Portal\";\nimport Narratives from \"./atoms/Narratives\";\nimport DefsMarkers from \"./atoms/DefsMarkers\";\nimport SatelliteOverlayToggle from \"./atoms/SatelliteOverlayToggle\";\nimport LoadingOverlay from \"../../atoms/Loading\";\n\nimport {\n  mapClustersToLocations,\n  isIdentical,\n  isLatitude,\n  isLongitude,\n  calculateTotalClusterPoints,\n  calcClusterSize,\n} from \"../../../common/utilities\";\n\nclass Map extends Component {\n  constructor() {\n    super();\n    this.projectPoint = this.projectPoint.bind(this);\n    this.onClusterSelect = this.onClusterSelect.bind(this);\n    this.loadClusterData = this.loadClusterData.bind(this);\n    this.getClusterChildren = this.getClusterChildren.bind(this);\n    this.svgRef = createRef();\n    this.map = null;\n    this.superclusterIndex = null;\n    this.tileLayer = null;\n    this.state = {\n      mapTransformX: 0,\n      mapTransformY: 0,\n      indexLoaded: false,\n      clusters: [],\n    };\n    this.styleLocation = this.styleLocation.bind(this);\n    this.syncMapViewToUrl = this.syncMapViewToUrl.bind(this);\n  }\n\n  componentDidMount() {\n    if (this.map === null) {\n      this.initializeMap();\n      this.initializeTileLayer();\n    }\n    window.dispatchEvent(new Event(\"resize\"));\n  }\n\n  componentDidUpdate(prevProps) {\n    if (prevProps.ui.tile !== this.props.ui.tile && this.map) {\n      this.initializeTileLayer();\n    }\n  }\n\n  UNSAFE_componentWillReceiveProps(nextProps) {\n    if (!isIdentical(nextProps.domain.locations, this.props.domain.locations)) {\n      this.loadClusterData(nextProps.domain.locations);\n    }\n\n    // Update map view if anchor or zoom changed (e.g., from URL state rehydration)\n    const { anchor: nextAnchor, startZoom: nextZoom } = nextProps.app.map;\n    const { anchor: currAnchor, startZoom: currZoom } = this.props.app.map;\n    if (\n      this.map &&\n      (!isIdentical(nextAnchor, currAnchor) || nextZoom !== currZoom)\n    ) {\n      const currentCenter = this.map.getCenter();\n      const currentZoom = this.map.getZoom();\n      // Only update if the values actually differ from the current map state\n      if (\n        Math.abs(currentCenter.lat - nextAnchor[0]) > 0.00001 ||\n        Math.abs(currentCenter.lng - nextAnchor[1]) > 0.00001 ||\n        currentZoom !== nextZoom\n      ) {\n        this.map.setView(nextAnchor, nextZoom, { animate: false });\n      }\n    }\n\n    // Set appropriate zoom for narrative\n    const { bounds } = nextProps.app.map;\n    if (!isIdentical(bounds, this.props.app.map.bounds) && bounds !== null) {\n      this.map.fitBounds(bounds);\n    } else {\n      if (!isIdentical(nextProps.app.selected, this.props.app.selected)) {\n        // Fly to first  of events selected\n        const eventPoint =\n          nextProps.app.selected.length > 0 ? nextProps.app.selected[0] : null;\n\n        if (\n          eventPoint !== null &&\n          eventPoint.latitude &&\n          eventPoint.longitude\n        ) {\n          // this.map.setView([eventPoint.latitude, eventPoint.longitude])\n          this.map.setView(\n            [eventPoint.latitude, eventPoint.longitude],\n            this.map.getZoom(),\n            {\n              animate: true,\n              pan: {\n                duration: 0.7,\n              },\n            }\n          );\n        }\n      }\n    }\n  }\n\n  /**\n   * Initialize the base tile layer based on the ui state\n   */\n  initializeTileLayer() {\n    if (!this.map) {\n      return;\n    }\n\n    const url = this.props.ui.tile;\n    /**\n     * If a tile layer already exists, we update its url. Otherwise, we create it and add it to the map.\n     */\n    if (this.tileLayer) {\n      this.tileLayer.setUrl(url);\n    } else {\n      this.tileLayer = L.tileLayer(url);\n      this.tileLayer.addTo(this.map);\n    }\n  }\n\n  initializeMap() {\n    /**\n     * Creates a Leaflet map\n     */\n    const { map: mapConfig, cluster: clusterConfig } = this.props.app;\n\n    const map = L.map(this.props.ui.dom.map)\n      .setView(mapConfig.anchor, mapConfig.startZoom)\n      .setMinZoom(mapConfig.minZoom)\n      .setMaxZoom(mapConfig.maxZoom)\n      .setMaxBounds(mapConfig.maxBounds);\n    // This assumes your map is the constant 'map'\n    map.attributionControl.addAttribution(\n      `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`\n    );\n\n    // Initialize supercluster index\n    this.superclusterIndex = new Supercluster(clusterConfig);\n\n    map.keyboard.disable();\n    map.zoomControl.remove();\n\n    map.on(\"moveend\", () => {\n      this.alignLayers();\n      this.updateClusters();\n      this.syncMapViewToUrl();\n    });\n\n    map.on(\"zoomend viewreset\", () => {\n      this.map.dragging.enable();\n      this.map.doubleClickZoom.enable();\n      this.map.scrollWheelZoom.enable();\n      flushSync(() => {\n        this.alignLayers();\n        this.updateClusters();\n      });\n    });\n    map.on(\"zoomstart\", () => {\n      if (this.svgRef.current !== null)\n        this.svgRef.current.classList.add(\"hide\");\n    });\n    map.on(\"zoomend\", () => {\n      if (this.svgRef.current !== null)\n        this.svgRef.current.classList.remove(\"hide\");\n    });\n    window.addEventListener(\"resize\", () => {\n      this.alignLayers();\n    });\n\n    this.map = map;\n  }\n\n  getMapDetails() {\n    const bounds = this.map.getBounds();\n    const bbox = [\n      bounds.getWest(),\n      bounds.getSouth(),\n      bounds.getEast(),\n      bounds.getNorth(),\n    ];\n    const zoom = this.map.getZoom();\n    return [bbox, zoom];\n  }\n\n  syncMapViewToUrl() {\n    if (!this.map) return;\n    const center = this.map.getCenter();\n    const zoom = this.map.getZoom();\n    // Round to 5 decimal places for cleaner URLs\n    const lat = Math.round(center.lat * 100000) / 100000;\n    const lng = Math.round(center.lng * 100000) / 100000;\n    this.props.actions.updateMapView(lat, lng, zoom);\n  }\n\n  updateClusters() {\n    const [bbox, zoom] = this.getMapDetails();\n    if (this.superclusterIndex && this.state.indexLoaded) {\n      this.setState({\n        clusters: this.superclusterIndex.getClusters(bbox, zoom),\n      });\n    }\n  }\n\n  loadClusterData(locations) {\n    if (locations && locations.length > 0 && this.superclusterIndex) {\n      const convertedLocations = locations.reduce((acc, loc) => {\n        const { longitude, latitude } = loc;\n        const validCoordinates = isLatitude(latitude) && isLongitude(longitude);\n        if (validCoordinates) {\n          const feature = {\n            type: \"Feature\",\n            properties: {\n              cluster: false,\n              id: loc.label,\n            },\n            geometry: {\n              type: \"Point\",\n              coordinates: [longitude, latitude],\n            },\n          };\n          acc.push(feature);\n        }\n        return acc;\n      }, []);\n      this.superclusterIndex.load(convertedLocations);\n      this.setState({ indexLoaded: true }, () => {\n        this.updateClusters();\n      });\n    } else {\n      this.setState({ clusters: [] });\n    }\n  }\n\n  getClusterChildren(clusterId) {\n    if (this.superclusterIndex) {\n      try {\n        const children = this.superclusterIndex.getLeaves(\n          clusterId,\n          Infinity,\n          0\n        );\n        return mapClustersToLocations(children, this.props.domain.locations);\n      } catch (err) {\n        return [];\n      }\n    }\n    return [];\n  }\n\n  getSelectedClusters() {\n    const { selected } = this.props.app;\n    const selectedIds = selected.map((sl) => sl.id);\n\n    if (this.state.clusters && this.state.clusters.length > 0) {\n      return this.state.clusters.reduce((acc, cl) => {\n        if (cl.properties.cluster) {\n          const children = this.getClusterChildren(cl.properties.cluster_id);\n          if (children && children.length > 0) {\n            children.forEach((child) => {\n              const clusterPresent =\n                acc.findIndex((item) => item.id === cl.id) >= 0;\n              if (selectedIds.includes(child.id) && !clusterPresent) {\n                acc.push(cl);\n              }\n            });\n          }\n        }\n        return acc;\n      }, []);\n    }\n    return [];\n  }\n\n  alignLayers() {\n    const mapNode = document.querySelector(\".leaflet-map-pane\");\n    if (mapNode === null) return { transformX: 0, transformY: 0 };\n\n    // We'll get the transform of the leaflet container,\n    // which will let us offset the SVG by the same quantity\n    const transform = window\n      .getComputedStyle(mapNode)\n      .getPropertyValue(\"transform\");\n\n    // Offset with leaflet map transform boundaries\n    this.setState({\n      mapTransformX: +transform.split(\",\")[4],\n      mapTransformY: +transform.split(\",\")[5].split(\")\")[0],\n    });\n  }\n\n  projectPoint(location) {\n    const latLng = new L.LatLng(location[0], location[1]);\n    return {\n      x: this.map.latLngToLayerPoint(latLng).x + this.state.mapTransformX,\n      y: this.map.latLngToLayerPoint(latLng).y + this.state.mapTransformY,\n    };\n  }\n\n  onClusterSelect({ id, latitude, longitude }) {\n    const expansionZoom = Math.max(\n      this.superclusterIndex.getClusterExpansionZoom(parseInt(id)),\n      this.superclusterIndex.options.minZoom\n    );\n    const zoomLevelsToSkip = 2;\n    const zoomToFly = Math.max(\n      expansionZoom + zoomLevelsToSkip,\n      this.props.app.cluster.maxZoom\n    );\n\n    this.map.dragging.disable();\n    this.map.doubleClickZoom.disable();\n    this.map.scrollWheelZoom.disable();\n    this.map.flyTo(new L.LatLng(latitude, longitude), zoomToFly);\n  }\n\n  getClientDims() {\n    const boundingClient = document\n      .querySelector(`#${this.props.ui.dom.map}`)\n      .getBoundingClientRect();\n\n    return {\n      width: boundingClient.width,\n      height: boundingClient.height,\n    };\n  }\n\n  renderTiles() {\n    const pane = this.map.getPanes().overlayPane;\n    const { width, height } = this.getClientDims();\n\n    return this.map ? (\n      <Portal node={pane}>\n        <svg\n          ref={this.svgRef}\n          width={width}\n          height={height}\n          style={{\n            transform: `translate3d(${-this.state.mapTransformX}px, ${-this\n              .state.mapTransformY}px, 0)`,\n          }}\n          className=\"leaflet-svg\"\n        />\n      </Portal>\n    ) : null;\n  }\n\n  renderSites() {\n    return (\n      <Sites\n        sites={this.props.domain.sites}\n        projectPoint={this.projectPoint}\n        isEnabled={this.props.app.views.sites}\n      />\n    );\n  }\n\n  renderRegions() {\n    return (\n      <Regions\n        svg={this.svgRef.current}\n        regions={this.props.domain.regions}\n        projectPoint={this.projectPoint}\n        styles={this.props.ui.regions}\n      />\n    );\n  }\n\n  renderNarratives() {\n    const hasNarratives = this.props.domain.narratives.length > 0;\n    return (\n      <Narratives\n        svg={this.svgRef.current}\n        narratives={\n          hasNarratives\n            ? this.props.domain.narratives\n            : [this.props.app.narrative]\n        }\n        projectPoint={this.projectPoint}\n        narrative={this.props.app.narrative}\n        styles={this.props.ui.narratives}\n        onSelectNarrative={this.props.methods.onSelectNarrative}\n        features={this.props.features}\n      />\n    );\n  }\n\n  /**\n   * Determines additional styles on the <circle> for each location.\n   * A location consists of an array of events (see selectors). The function\n   * also has full access to the domain and redux state to derive values if\n   * necessary. The function should return an array, where the value at the\n   * first index is a styles object for the SVG at the location, and the value\n   * at the second index is an optional additional component that renders in\n   * the <g/> div.\n   */\n  styleLocation(location) {\n    return [null, null];\n  }\n\n  styleCluster(cluster) {\n    return [null, null];\n  }\n\n  renderEvents() {\n    /*\n    Uncomment below to filter out the locations already present in a cluster.\n    Leaving these lines commented out renders all the locations on the map, regardless of whether or not they are clustered\n    */\n\n    const individualClusters = this.state.clusters.filter(\n      (cl) => !cl.properties.cluster\n    );\n    const filteredLocations = mapClustersToLocations(\n      individualClusters,\n      this.props.domain.locations\n    );\n\n    return (\n      <Events\n        svg={this.svgRef.current}\n        events={this.props.domain.events}\n        locations={filteredLocations}\n        // locations={this.props.domain.locations}\n        styleLocation={this.styleLocation}\n        categories={this.props.domain.categories}\n        projectPoint={this.projectPoint}\n        selected={this.props.app.selected}\n        highlighted={this.props.app.highlighted}\n        narrative={this.props.app.narrative}\n        onSelect={this.props.methods.onSelect}\n        getCategoryColor={this.props.methods.getCategoryColor}\n        eventRadius={this.props.ui.eventRadius}\n        coloringSet={this.props.app.coloringSet}\n        filterColors={this.props.ui.filterColors}\n        features={this.props.features}\n      />\n    );\n  }\n\n  renderClusters() {\n    const allClusters = this.state.clusters.filter(\n      (cl) => cl.properties.cluster\n    );\n    return (\n      <Clusters\n        svg={this.svgRef.current}\n        styleCluster={this.styleCluster}\n        projectPoint={this.projectPoint}\n        clusters={allClusters}\n        isRadial={this.props.ui.radial}\n        onSelect={this.onClusterSelect}\n        coloringSet={this.props.app.coloringSet}\n        getClusterChildren={this.getClusterChildren}\n        filterColors={this.props.ui.filterColors}\n        highlighted={this.props.app.highlighted}\n      />\n    );\n  }\n\n  renderSelected() {\n    const selectedClusters = this.getSelectedClusters();\n    const totalMarkers = [];\n\n    this.props.app.selected.forEach((s) => {\n      const { latitude, longitude } = s;\n      totalMarkers.push({\n        latitude,\n        longitude,\n        radius: this.props.ui.eventRadius,\n      });\n    });\n\n    const totalClusterPoints = calculateTotalClusterPoints(this.state.clusters);\n\n    selectedClusters.forEach((cl) => {\n      if (cl.properties.cluster) {\n        const { coordinates } = cl.geometry;\n        totalMarkers.push({\n          latitude: String(coordinates[1]),\n          longitude: String(coordinates[0]),\n          radius: calcClusterSize(\n            cl.properties.point_count,\n            totalClusterPoints\n          ),\n        });\n      }\n    });\n\n    return (\n      <SelectedEvents\n        svg={this.svgRef.current}\n        selected={totalMarkers}\n        projectPoint={this.projectPoint}\n        styles={this.props.ui.mapSelectedEvents}\n      />\n    );\n  }\n\n  renderMarkers() {\n    return (\n      <Portal node={this.svgRef.current}>\n        <DefsMarkers />\n      </Portal>\n    );\n  }\n\n  render() {\n    const { isShowingSites, isFetchingDomain } = this.props.app.flags;\n    const checkMobile = isMobileOnly || window.innerWidth < 600;\n\n    const classes =\n      (this.props.app.narrative\n        ? \"map-wrapper narrative-mode\"\n        : \"map-wrapper\") + (checkMobile ? \" mobile\" : \"\");\n    const innerMap = this.map ? (\n      <>\n        {this.renderTiles()}\n        {this.renderMarkers()}\n        {isShowingSites ? this.renderSites() : null}\n        {this.renderRegions()}\n        {this.renderNarratives()}\n        {this.renderEvents()}\n        {this.renderClusters()}\n        {this.renderSelected()}\n      </>\n    ) : null;\n\n    return (\n      <div className={classes} onKeyDown={this.props.onKeyDown} tabIndex=\"0\">\n        <div id={this.props.ui.dom.map} />\n        <LoadingOverlay\n          isLoading={this.props.app.loading || isFetchingDomain}\n          ui={isFetchingDomain}\n          language={this.props.app.language}\n        />\n        {this.props.features.USE_SATELLITE_OVERLAY_TOGGLE && (\n          <SatelliteOverlayToggle\n            isUsingSatellite={this.props.ui.isUsingSatellite}\n            toggleSatellite={this.props.actions.toggleTileOverlay}\n          />\n        )}\n        {innerMap}\n      </div>\n    );\n  }\n}\n\nfunction mapStateToProps(state) {\n  return {\n    domain: {\n      locations: selectors.selectLocations(state),\n      narratives: selectors.selectNarratives(state),\n      categories: selectors.getCategories(state),\n      sites: selectors.selectSites(state),\n      regions: selectors.selectRegions(state),\n    },\n    app: {\n      views: state.app.associations.views,\n      selected: selectors.selectSelected(state),\n      highlighted: state.app.highlighted,\n      map: state.app.map,\n      cluster: state.app.cluster,\n      language: state.app.language,\n      loading: state.app.loading,\n      narrative: state.app.associations.narrative,\n      coloringSet: state.app.associations.coloringSet,\n      flags: {\n        isShowingSites: state.app.flags.isShowingSites,\n        isFetchingDomain: state.app.flags.isFetchingDomain,\n      },\n    },\n    ui: {\n      tile: selectors.getTile(state),\n      isUsingSatellite: selectors.isUsingSatellite(state),\n      dom: state.ui.dom,\n      narratives: state.ui.style.narratives,\n      mapSelectedEvents: state.ui.style.selectedEvents,\n      regions: state.ui.style.regions,\n      eventRadius: state.ui.eventRadius,\n      radial: state.ui.style.clusters.radial,\n      filterColors: state.ui.coloring.colors,\n    },\n    features: selectors.getFeatures(state),\n  };\n}\n\nfunction mapDispatchToProps(dispatch) {\n  return {\n    actions: bindActionCreators(actions, dispatch),\n  };\n}\n\nexport default connect(mapStateToProps, mapDispatchToProps)(Map);\n"
  },
  {
    "path": "src/components/space/carto/atoms/Clusters.jsx",
    "content": "import { useState } from \"react\";\nimport colors from \"../../../../common/global\";\nimport ColoredMarkers from \"../../../atoms/ColoredMarkers\";\nimport Portal from \"../../../Portal\";\nimport {\n  calcClusterOpacity,\n  calcClusterSize,\n  isLatitude,\n  isLongitude,\n  calculateColorPercentages,\n  zipColorsToPercentages,\n  calculateTotalClusterPoints,\n} from \"../../../../common/utilities\";\n\nconst HIGHLIGHT_COLOR = \"#E31A1B\";\n\nconst DefsClusters = () => (\n  <defs>\n    <radialGradient id=\"clusterGradient\">\n      <stop offset=\"10%\" stopColor=\"red\" />\n      <stop offset=\"90%\" stopColor=\"transparent\" />\n    </radialGradient>\n  </defs>\n);\n\nfunction Cluster({\n  cluster,\n  size,\n  projectPoint,\n  totalPoints,\n  styles,\n  renderHover,\n  onClick,\n  getClusterChildren,\n  coloringSet,\n  filterColors,\n  highlighted,\n}) {\n  /**\n  {\n    geometry: {\n      coordinates: [longitude, latitude]\n    },\n    properties: {\n      cluster: true|false,\n      cluster_id: int,\n      point_count: int,\n      point_count_abbreviated: int\n    },\n    type: \"Feature\"\n  }\n  */\n  const { cluster_id: clusterId } = cluster.properties;\n\n  const individualChildren = getClusterChildren(clusterId);\n\n  // Calculate percentage of highlighted events in this cluster\n  const allEvents = individualChildren.flatMap((loc) => loc.events);\n  const totalEvents = allEvents.length;\n  const highlightedEvents =\n    highlighted && highlighted.length > 0\n      ? allEvents.filter((event) => highlighted.includes(event.civId))\n      : [];\n  const highlightedCount = highlightedEvents.length;\n  const highlightedPercent = totalEvents > 0 ? highlightedCount / totalEvents : 0;\n\n  let colorPercentMap;\n  if (highlightedPercent === 1) {\n    // All events are highlighted\n    colorPercentMap = { [HIGHLIGHT_COLOR]: 1 };\n  } else if (highlightedPercent > 0) {\n    // Mix of highlighted and non-highlighted events\n    const nonHighlightedChildren = individualChildren.map((loc) => ({\n      ...loc,\n      events: loc.events.filter(\n        (event) => !highlighted || !highlighted.includes(event.civId)\n      ),\n    })).filter((loc) => loc.events.length > 0);\n\n    const colorPercentages = calculateColorPercentages(\n      nonHighlightedChildren,\n      coloringSet\n    );\n    // Scale down the category percentages and add highlight percentage\n    const scaledPercentages = colorPercentages.map(\n      (p) => p * (1 - highlightedPercent)\n    );\n    colorPercentMap = zipColorsToPercentages(filterColors, scaledPercentages);\n    colorPercentMap[HIGHLIGHT_COLOR] = highlightedPercent;\n  } else {\n    // No highlighted events\n    const colorPercentages = calculateColorPercentages(\n      individualChildren,\n      coloringSet\n    );\n    colorPercentMap = zipColorsToPercentages(filterColors, colorPercentages);\n  }\n\n  const { coordinates } = cluster.geometry;\n  const [longitude, latitude] = coordinates;\n  const { x, y } = projectPoint([latitude, longitude]);\n  const [hovered, setHovered] = useState(false);\n  if (!isLatitude(latitude) || !isLongitude(longitude)) return null;\n\n  return (\n    <svg>\n      <g\n        className=\"cluster-event\"\n        transform={`translate(${x}, ${y})`}\n        onClick={(e) => onClick({ id: clusterId, latitude, longitude })}\n        onMouseEnter={() => setHovered(true)}\n        onMouseLeave={() => setHovered(false)}\n      >\n        <ColoredMarkers\n          radius={size}\n          colorPercentMap={colorPercentMap}\n          styles={{\n            ...styles,\n          }}\n          className=\"cluster-event-marker\"\n        />\n        {hovered ? renderHover(cluster) : null}\n      </g>\n    </svg>\n  );\n}\n\nfunction ClusterEvents({\n  projectPoint,\n  onSelect,\n  getClusterChildren,\n  coloringSet,\n  isRadial,\n  svg,\n  clusters,\n  filterColors,\n  selected,\n  highlighted,\n}) {\n  const totalPoints = calculateTotalClusterPoints(clusters);\n\n  const styles = {\n    fill: isRadial ? \"url('#clusterGradient')\" : colors.fallbackEventColor,\n    stroke: colors.darkBackground,\n    strokeWidth: 0,\n  };\n\n  function renderHover(txt, circleSize) {\n    return (\n      <>\n        <text\n          textAnchor=\"middle\"\n          y=\"3px\"\n          style={{ fontWeight: \"bold\", fill: \"black\", zIndex: 10000 }}\n        >\n          {txt}\n        </text>\n        <circle\n          className=\"event-hover\"\n          cx=\"0\"\n          cy=\"0\"\n          r={circleSize + 2}\n          stroke={colors.primaryHighlight}\n          fillOpacity=\"0.0\"\n        />\n      </>\n    );\n  }\n\n  return (\n    <Portal node={svg}>\n      <svg>\n        <g className=\"cluster-locations\">\n          {isRadial ? <DefsClusters /> : null}\n          {clusters.map((c, idx) => {\n            const pointCount = c.properties.point_count;\n            const clusterSize = calcClusterSize(pointCount, totalPoints);\n            return (\n              <Cluster\n                key={idx}\n                onClick={onSelect}\n                getClusterChildren={getClusterChildren}\n                coloringSet={coloringSet}\n                cluster={c}\n                filterColors={filterColors}\n                highlighted={highlighted}\n                size={clusterSize}\n                projectPoint={projectPoint}\n                totalPoints={totalPoints}\n                styles={{\n                  ...styles,\n                  fillOpacity: calcClusterOpacity(pointCount, totalPoints),\n                }}\n                renderHover={() => renderHover(pointCount, clusterSize)}\n              />\n            );\n          })}\n        </g>\n      </svg>\n    </Portal>\n  );\n}\n\nexport default ClusterEvents;\n"
  },
  {
    "path": "src/components/space/carto/atoms/DefsMarkers.jsx",
    "content": "const MapDefsMarkers = () => (\n  <svg>\n    <defs>\n      <marker\n        id=\"arrow\"\n        viewBox=\"0 0 6 6\"\n        refX=\"3\"\n        refY=\"3\"\n        markerWidth=\"6\"\n        markerHeight=\"6\"\n        orient=\"auto\"\n      >\n        <path d=\"M0,3v-3l6,3l-6,3z\" style={{ fill: \"red\" }} />\n      </marker>\n      <marker\n        id=\"arrow-off\"\n        viewBox=\"0 0 6 6\"\n        refX=\"3\"\n        refY=\"3\"\n        markerWidth=\"6\"\n        markerHeight=\"6\"\n        orient=\"auto\"\n      >\n        <path\n          d=\"M0,3v-3l6,3l-6,3z\"\n          style={{ fill: \"black\", fillOpacity: 0.2 }}\n        />\n      </marker>\n    </defs>\n  </svg>\n);\n\nexport default MapDefsMarkers;\n"
  },
  {
    "path": "src/components/space/carto/atoms/Events.jsx",
    "content": "import colors from \"../../../../common/global\";\nimport ColoredMarkers from \"../../../atoms/ColoredMarkers\";\nimport Portal from \"../../../Portal\";\nimport hash from \"object-hash\";\nimport {\n  calcOpacity,\n  calculateColorPercentages,\n  zipColorsToPercentages,\n} from \"../../../../common/utilities\";\n\nconst HIGHLIGHT_COLOR = \"#E31A1B\";\n\nfunction MapEvents({\n  getCategoryColor,\n  categories,\n  projectPoint,\n  styleLocation,\n  selected,\n  highlighted,\n  narrative,\n  onSelect,\n  svg,\n  locations,\n  eventRadius,\n  coloringSet,\n  filterColors,\n  features,\n}) {\n  function handleEventSelect(e, location) {\n    const events = e.shiftKey\n      ? selected.concat(location.events)\n      : location.events;\n    onSelect(events);\n  }\n\n  function renderBorder() {\n    return (\n      <>\n        <circle\n          className=\"event-hover\"\n          cx=\"0\"\n          cy=\"0\"\n          r=\"10\"\n          stroke={colors.primaryHighlight}\n          fillOpacity=\"0.0\"\n        />\n      </>\n    );\n  }\n\n  function renderLocationSlicesByAssociation(location) {\n    const styles = {\n      stroke: colors.darkBackground,\n      strokeWidth: 0,\n      fillOpacity: narrative ? 1 : calcOpacity(location.events.length),\n    };\n\n    // Calculate percentage of highlighted events\n    const totalEvents = location.events.length;\n    const highlightedEvents =\n      highlighted && highlighted.length > 0\n        ? location.events.filter((event) => highlighted.includes(event.civId))\n        : [];\n    const highlightedCount = highlightedEvents.length;\n    const highlightedPercent = highlightedCount / totalEvents;\n\n    // Get non-highlighted events for category color calculation\n    const nonHighlightedEvents = location.events.filter(\n      (event) => !highlighted || !highlighted.includes(event.civId)\n    );\n\n    let colorPercentMap;\n    if (highlightedPercent === 1) {\n      // All events are highlighted\n      colorPercentMap = { [HIGHLIGHT_COLOR]: 1 };\n    } else if (highlightedPercent > 0) {\n      // Mix of highlighted and non-highlighted events\n      const nonHighlightedLocation = { ...location, events: nonHighlightedEvents };\n      const colorPercentages = calculateColorPercentages(\n        [nonHighlightedLocation],\n        coloringSet\n      );\n      // Scale down the category percentages and add highlight percentage\n      const scaledPercentages = colorPercentages.map(\n        (p) => p * (1 - highlightedPercent)\n      );\n      colorPercentMap = zipColorsToPercentages(filterColors, scaledPercentages);\n      colorPercentMap[HIGHLIGHT_COLOR] = highlightedPercent;\n    } else {\n      // No highlighted events\n      const colorPercentages = calculateColorPercentages([location], coloringSet);\n      colorPercentMap = zipColorsToPercentages(filterColors, colorPercentages);\n    }\n\n    return (\n      <ColoredMarkers\n        radius={eventRadius}\n        colorPercentMap={colorPercentMap}\n        styles={{\n          ...styles,\n        }}\n        className=\"location-event-marker\"\n      />\n    );\n  }\n\n  function renderLocation(location) {\n    /**\n    {\n      events: [...],\n      label: 'Location name',\n      latitude: '47.7',\n      longitude: '32.2'\n    }\n    */\n    if (!location.latitude || !location.longitude) return null;\n    const { x, y } = projectPoint([location.latitude, location.longitude]);\n\n    // in narrative mode, only render events in narrative\n    // TODO: move this to a selector\n    if (narrative) {\n      const { steps } = narrative;\n      const onlyIfInNarrative = (e) => steps.map((s) => s.id).includes(e.id);\n      const eventsInNarrative = location.events.filter(onlyIfInNarrative);\n\n      if (eventsInNarrative.length <= 0) {\n        return null;\n      }\n    }\n\n    const customStyles = styleLocation ? styleLocation(location) : null;\n    const extraRender = () => <>{customStyles[1]}</>;\n\n    const isSelected = selected.reduce((acc, event) => {\n      return (\n        acc ||\n        (event.latitude === location.latitude &&\n          event.longitude === location.longitude)\n      );\n    }, false);\n\n    return (\n      <svg key={hash(location)}>\n        <g\n          className={`location-event ${narrative ? \"no-hover\" : \"\"}`}\n          transform={`translate(${x}, ${y})`}\n          onClick={(e) => handleEventSelect(e, location)}\n        >\n          {renderLocationSlicesByAssociation(location)}\n          {extraRender ? extraRender() : null}\n          {isSelected ? null : renderBorder()}\n        </g>\n      </svg>\n    );\n  }\n\n  return (\n    <Portal node={svg}>\n      <svg>\n        <g className=\"event-locations\">{locations.map(renderLocation)}</g>\n      </svg>\n    </Portal>\n  );\n}\n\nexport default MapEvents;\n"
  },
  {
    "path": "src/components/space/carto/atoms/Narratives.jsx",
    "content": "import Portal from \"../../../Portal\";\n// import { concatStatic } from 'rxjs/operator/concat'\n// import { single } from 'rxjs/operator/single'\n\nconst defaultStyles = {\n  strokeOpacity: 1,\n  strokeWidth: 0,\n  strokeDasharray: \"none\",\n  stroke: \"none\",\n};\n\nfunction MapNarratives({\n  styles,\n  onSelectNarrative,\n  svg,\n  narrative,\n  narratives,\n  projectPoint,\n  features,\n}) {\n  function getNarrativeStyle(narrativeId) {\n    const styleName =\n      narrativeId && narrativeId in styles ? narrativeId : \"default\";\n    return styles[styleName];\n  }\n\n  const narrativesExist = narratives && narratives.length !== 0;\n\n  function hasNoLocation(step) {\n    return step.latitude === \"\" || step.longitude === \"\";\n  }\n\n  function _renderNarrativeStepArrow(p1, p2, styles) {\n    const distance = Math.sqrt(\n      (p2.x - p1.x) * (p2.x - p1.x) + (p2.y - p1.y) * (p2.y - p1.y)\n    );\n    const theta = Math.atan2(p2.y - p1.y, p2.x - p1.x); // Angle of narrative step line\n    const alpha = Math.atan2(1, 2); // Angle of arrow overture\n    const edge = 10; // Arrow edge length\n    const offset = distance < 24 ? distance / 2 : 24;\n\n    // Arrow corners\n    const coord0 = {\n      x: p2.x - offset * Math.cos(theta),\n      y: p2.y - offset * Math.sin(theta),\n    };\n    const coord1 = {\n      x: coord0.x - edge * Math.cos(-theta - alpha),\n      y: coord0.y + edge * Math.sin(-theta - alpha),\n    };\n    const coord2 = {\n      x: coord0.x - edge * Math.cos(-theta + alpha),\n      y: coord0.y + edge * Math.sin(-theta + alpha),\n    };\n\n    return (\n      <path\n        className=\"narrative-step-arrow\"\n        d={`\n        M ${coord0.x} ${coord0.y}\n        L ${coord1.x} ${coord1.y}\n        L ${coord2.x} ${coord2.y} Z\n      `}\n        style={{\n          ...styles,\n          fillOpacity: styles.strokeOpacity,\n          fill: styles.stroke,\n        }}\n      />\n    );\n  }\n\n  function _renderNarrativeStep(p1, p2, styles) {\n    const { stroke, strokeWidth, strokeDasharray, strokeOpacity } = styles;\n\n    return (\n      <g>\n        <line\n          className=\"narrative-step\"\n          x1={p1.x}\n          x2={p2.x}\n          y1={p1.y}\n          y2={p2.y}\n          markerStart=\"none\"\n          onClick={(n) => onSelectNarrative(n)}\n          style={{\n            strokeWidth,\n            strokeDasharray,\n            strokeOpacity,\n            stroke,\n          }}\n        />\n        {stroke !== \"none\" ? _renderNarrativeStepArrow(p1, p2, styles) : \"\"}\n      </g>\n    );\n  }\n\n  function renderBetweenSteps(step1, step2, extraStyles) {\n    // don't draw if one of the steps has no location, or not in narrative\n    if (hasNoLocation(step1) || hasNoLocation(step2)) {\n      return null;\n    }\n\n    // don't draw if something else is up\n    if (!step1 || !step2) {\n      return null;\n    }\n\n    const p1 = projectPoint([step1.latitude, step1.longitude]);\n    const p2 = projectPoint([step2.latitude, step2.longitude]);\n\n    return _renderNarrativeStep(p1, p2, {\n      ...defaultStyles,\n      ...(extraStyles || {}),\n    });\n  }\n\n  function renderFullNarrative(n) {\n    if (n === null || n.id !== narrative.id) {\n      return null;\n    }\n\n    const arrows = [];\n\n    for (let idx = 0; idx < n.steps.length - 1; idx += 1) {\n      const step1 = n.steps[idx];\n      const step2 = n.steps[idx + 1];\n      arrows.push(renderBetweenSteps(step1, step2, getNarrativeStyle(n.id)));\n    }\n\n    return arrows;\n  }\n\n  function renderBetweenMarked(n) {\n    // this function should only be called if features.NARRATIVE_STEP_STYLES\n    // is true, and thus there is a 'stepStyles' attributes in events\n    if (n === null || n.id !== narrative.id) {\n      return null;\n    }\n\n    const arrows = [];\n\n    let lastMarked = null;\n\n    if (narrativesExist) {\n      for (let idx = 0; idx < n.steps.length; idx += 1) {\n        const step = n.steps[idx];\n        if (lastMarked) {\n          arrows.push(\n            renderBetweenSteps(\n              lastMarked,\n              step,\n              n.withLines ? { strokeWidth: \"1px\", stroke: step.colour } : {}\n            )\n          );\n        }\n        lastMarked = step;\n      }\n    } else {\n      for (let idx = 0; idx < n.steps.length; idx += 1) {\n        const step = n.steps[idx];\n        const _idx = step.narratives.indexOf(n.id);\n        const stepStyle = step.narrative___stepStyles[_idx];\n\n        if (stepStyle !== \"None\") {\n          if (lastMarked) {\n            arrows.push(\n              renderBetweenSteps(lastMarked, step, styles.stepStyles[stepStyle])\n            );\n          }\n          lastMarked = step;\n        }\n      }\n    }\n\n    return arrows;\n  }\n\n  function renderNarrative(n) {\n    const narrativeId = `narrative-${n.id.replace(/ /g, \"_\")}`;\n\n    const body = narrativesExist\n      ? renderBetweenMarked(n)\n      : features.NARRATIVE_STEP_STYLES\n      ? renderBetweenMarked(n)\n      : renderFullNarrative(n);\n\n    return (\n      <g id={narrativeId} className=\"narrative\">\n        {body}\n      </g>\n    );\n  }\n\n  // don't render in explore mode\n  if (narrative === null) {\n    return null;\n  }\n\n  return (\n    <Portal node={svg}>\n      <g className=\"narratives\">{narratives.map(renderNarrative)}</g>\n    </Portal>\n  );\n}\n\nexport default MapNarratives;\n"
  },
  {
    "path": "src/components/space/carto/atoms/Regions.jsx",
    "content": "import Portal from \"../../../Portal\";\n\nfunction MapRegions({ svg, regions, projectPoint, styles }) {\n  function renderRegion(region) {\n    const lineCoords = [];\n    const points = region.points.map(projectPoint);\n\n    points.forEach((p1, idx) => {\n      if (idx < region.points.length - 1) {\n        const p2 = points[idx + 1];\n        lineCoords.push({\n          x1: p1.x,\n          y1: p1.y,\n          x2: p2.x,\n          y2: p2.y,\n        });\n      }\n    });\n\n    return lineCoords.map((coords) => {\n      const regionstyles =\n        region.name in styles ? styles[region.name] : styles.default;\n\n      return (\n        <line\n          id={`${region.name}_style`}\n          markerStart=\"none\"\n          {...coords}\n          style={regionstyles}\n        />\n      );\n    });\n  }\n\n  if (!regions || !regions.length) return null;\n\n  return (\n    <Portal node={svg}>\n      <g id=\"regions-layer\" className=\"narrative\">\n        {regions.map(renderRegion)}\n      </g>\n    </Portal>\n  );\n}\n\nexport default MapRegions;\n"
  },
  {
    "path": "src/components/space/carto/atoms/SatelliteOverlayToggle.jsx",
    "content": "import copy from \"../../../../common/data/copy.json\";\nimport { language } from \"../../../../common/utilities\";\nimport mapImg from \"../../../../assets/satelliteoverlaytoggle/map.png\";\nimport satImg from \"../../../../assets/satelliteoverlaytoggle/sat.png\";\n\nconst SatelliteOverlayToggle = ({ isUsingSatellite, toggleSatellite }) => {\n  const toggleClass = isUsingSatellite\n    ? \"satellite-overlay-toggle-map\"\n    : \"satellite-overlay-toggle-sat\";\n  const toggleImg = isUsingSatellite ? mapImg : satImg;\n  const toggleLabel = isUsingSatellite\n    ? copy[language].tiles.default\n    : copy[language].tiles.satellite;\n  return (\n    <div id=\"satellite-overlay-toggle\" className=\"satellite-overlay-toggle\">\n      <button\n        className={`satellite-overlay-toggle-button ${toggleClass}`}\n        style={{ backgroundImage: `url(${toggleImg}` }}\n        name=\"satellite-toggle\"\n        onClick={toggleSatellite}\n      >\n        <div className=\"label\">{toggleLabel}</div>\n      </button>\n    </div>\n  );\n};\n\nexport default SatelliteOverlayToggle;\n"
  },
  {
    "path": "src/components/space/carto/atoms/SelectedEvents.jsx",
    "content": "import { Component } from \"react\";\nimport colors from \"../../../../common/global\";\nimport hash from \"object-hash\";\nimport Portal from \"../../../Portal\";\n\nclass MapSelectedEvents extends Component {\n  renderMarker(marker) {\n    const { x, y } = this.props.projectPoint([\n      marker.latitude,\n      marker.longitude,\n    ]);\n    const styles = this.props.styles;\n    const r = marker.radius ? marker.radius + 5 : 24;\n    return (\n      <g\n        key={hash(marker)}\n        className=\"location-marker\"\n        transform={`translate(${x - r}, ${y})`}\n      >\n        <path\n          className=\"leaflet-interactive\"\n          stroke={styles ? styles.stroke : colors.primaryHighlight}\n          strokeOpacity=\"1\"\n          strokeWidth={styles ? styles[\"stroke-width\"] : 2}\n          strokeLinecap=\"\"\n          strokeLinejoin=\"round\"\n          strokeDasharray={styles ? styles[\"stroke-dasharray\"] : \"2,2\"}\n          fill=\"none\"\n          d={`M0,0a${r},${r} 0 1,0 ${r * 2},0 a${r},${r} 0 1,0 -${r * 2},0 `}\n        />\n      </g>\n    );\n  }\n\n  render() {\n    return (\n      <Portal node={this.props.svg}>\n        {this.props.selected.map((s) => this.renderMarker(s))}\n      </Portal>\n    );\n  }\n}\nexport default MapSelectedEvents;\n"
  },
  {
    "path": "src/components/space/carto/atoms/Sites.jsx",
    "content": "function MapSites({ sites, projectPoint }) {\n  function renderSite(site) {\n    const { x, y } = projectPoint([site.latitude, site.longitude]);\n\n    return (\n      <div\n        className=\"leaflet-tooltip site-label leaflet-zoom-animated leaflet-tooltip-top\"\n        style={{\n          opacity: 1,\n          transform: `translate3d(calc(${x}px - 50%), ${y - 25}px, 0px)`,\n        }}\n      >\n        {site.site}\n      </div>\n    );\n  }\n\n  if (!sites || !sites.length) return null;\n\n  return <div className=\"sites-layer\">{sites.map(renderSite)}</div>;\n}\n\nexport default MapSites;\n"
  },
  {
    "path": "src/components/space/carto/atoms/__tests__/SatelliteOverlayToggle.spec.jsx",
    "content": "import { vi } from \"vitest\";\nimport { fireEvent, render, screen } from \"@testing-library/react\";\nimport SatelliteOverlayToggle from \"../SatelliteOverlayToggle\";\nimport \"@testing-library/jest-dom\";\n\ndescribe(\"<SatelliteOverlayToggle />\", () => {\n  it(\"shows the option to switch to satellite by default\", () => {\n    render(\n      <SatelliteOverlayToggle reset={vi.fn()} switchToSatellite={vi.fn()} />\n    );\n    expect(screen.getByRole(\"button\", { name: /sat/i })).toBeTruthy();\n  });\n\n  it(\"shows the option to switch to map when satellite selected\", () => {\n    render(\n      <SatelliteOverlayToggle\n        isUsingSatellite\n        reset={vi.fn()}\n        switchToSatellite={vi.fn()}\n      />\n    );\n    expect(screen.getByRole(\"button\", { name: /map/i })).toBeTruthy();\n  });\n\n  it(\"calls the reset function when switching to the default overlay\", () => {\n    const mockSat = vi.fn();\n    render(\n      <SatelliteOverlayToggle isUsingSatellite toggleSatellite={mockSat} />\n    );\n    const btn = screen.getByRole(\"button\", { name: /map/i });\n    fireEvent.click(btn);\n    expect(mockSat).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"calls the switchToSatellite function when switching to the satellite overlay\", () => {\n    const mockSat = vi.fn();\n    render(<SatelliteOverlayToggle toggleSatellite={mockSat} />);\n    const btn = screen.getByRole(\"button\", { name: /sat/i });\n    fireEvent.click(btn);\n    expect(mockSat).toHaveBeenCalledTimes(1);\n  });\n});\n"
  },
  {
    "path": "src/components/time/Axis.jsx",
    "content": "import { createRef, Component } from \"react\";\nimport { axisBottom, timeFormat, select } from \"d3\";\nimport { setD3Locale } from \"../../common/utilities\";\n\nconst TEXT_HEIGHT = 15;\nsetD3Locale();\nclass TimelineAxis extends Component {\n  constructor() {\n    super();\n    this.xAxis0Ref = createRef();\n    this.xAxis1Ref = createRef();\n    this.state = {\n      isInitialized: false,\n    };\n  }\n\n  componentDidUpdate() {\n    let fstFmt, sndFmt;\n\n    // 10yrs\n    if (this.props.extent > 5256000) {\n      fstFmt = \"%Y\";\n      sndFmt = \"\";\n      // 1yr\n    } else if (this.props.extent > 43200) {\n      sndFmt = \"%d %b\";\n      fstFmt = \"\";\n    } else {\n      sndFmt = \"%d %b\";\n      // fstFmt = \"%H:%M\";\n      fstFmt = \"\";\n    }\n\n    let { marginTop } = this.props.dims;\n    if (this.props.scaleX) {\n      this.x0 = axisBottom(this.props.scaleX)\n        .ticks(this.props.ticks)\n        .tickPadding(marginTop + 30)\n        .tickSize(100 - TEXT_HEIGHT - marginTop)\n        .tickFormat(timeFormat(fstFmt));\n\n      this.x1 = axisBottom(this.props.scaleX)\n        .ticks(this.props.ticks)\n        .tickPadding(marginTop)\n        .tickSize(0)\n        .tickFormat(timeFormat(sndFmt));\n\n      if (!this.state.isInitialized) this.setState({ isInitialized: true });\n    }\n\n    if (this.state.isInitialized) {\n      select(this.xAxis0Ref.current)\n        .transition()\n        .duration(this.props.transitionDuration)\n        .call(this.x0);\n\n      select(this.xAxis1Ref.current)\n        .transition()\n        .duration(this.props.transitionDuration)\n        .call(this.x1);\n    }\n  }\n\n  render() {\n    return (\n      <>\n        <g\n          ref={this.xAxis0Ref}\n          transform={`translate(0, 24)`}\n          clipPath=\"url(#clip)\"\n          className=\"axis xAxis\"\n        />\n        <g\n          ref={this.xAxis1Ref}\n          transform={`translate(0, ${this.props.dims.marginTop})`}\n          clipPath=\"url(#clip)\"\n          className=\"axis xAxis\"\n        />\n      </>\n    );\n  }\n}\n\nexport default TimelineAxis;\n"
  },
  {
    "path": "src/components/time/Categories.jsx",
    "content": "import { createRef, Component } from \"react\";\nimport { drag as d3Drag, select } from \"d3\";\n\nclass TimelineCategories extends Component {\n  constructor(props) {\n    super(props);\n    this.grabRef = createRef();\n    this.state = {\n      isInitialized: false,\n    };\n  }\n\n  componentDidUpdate() {\n    if (!this.state.isInitialized) {\n      const drag = d3Drag()\n        .on(\"start\", this.props.onDragStart)\n        .on(\"drag\", this.props.onDrag)\n        .on(\"end\", this.props.onDragEnd);\n\n      select(this.grabRef.current).call(drag);\n\n      this.setState({ isInitialized: true });\n    }\n  }\n\n  renderCategory(cat, idx) {\n    const { features, dims } = this.props;\n    const strokeWidth = 1; // dims.trackHeight / (this.props.categories.length + 1)\n    if (\n      features.GRAPH_NONLOCATED &&\n      features.GRAPH_NONLOCATED.categories &&\n      features.GRAPH_NONLOCATED.categories.includes(cat)\n    ) {\n      return null;\n    }\n\n    return (\n      <>\n        <g\n          className=\"tick\"\n          style={{ strokeWidth }}\n          opacity=\"0.5\"\n          transform={`translate(0, 66)`}\n        >\n          <line x1={dims.marginLeft} x2={dims.width - dims.marginLeft} />\n        </g>\n        <g className=\"tick\" opacity=\"1\" transform={`translate(0, 66)`}>\n          <text x={dims.marginLeft - 5} dy=\"0.32em\">\n            {cat}\n          </text>\n        </g>\n      </>\n    );\n  }\n\n  render() {\n    const { dims, categories, fallbackLabel } = this.props;\n    const categoriesExist = categories && categories.length > 0;\n    const renderedCategories = categoriesExist\n      ? categories.map((cat, idx) => this.renderCategory(cat, idx))\n      : this.renderCategory(fallbackLabel, 0);\n\n    return (\n      <g className=\"yAxis\">\n        {renderedCategories}\n        <rect\n          ref={this.grabRef}\n          className=\"drag-grabber\"\n          x={dims.marginLeft}\n          y={dims.marginTop}\n          width={Math.max(0, dims.width - dims.marginLeft * 2)}\n          height={dims.contentHeight}\n        />\n      </g>\n    );\n  }\n}\n\nexport default TimelineCategories;\n"
  },
  {
    "path": "src/components/time/Timeline.jsx",
    "content": "import { createRef, Component } from \"react\";\nimport { bindActionCreators } from \"redux\";\nimport { connect } from \"react-redux\";\nimport { scaleTime, timeMinute, timeSecond } from \"d3\";\nimport hash from \"object-hash\";\n\nimport { setLoading, setNotLoading, updateTicks } from \"../../actions\";\nimport * as selectors from \"../../selectors\";\nimport copy from \"../../common/data/copy.json\";\n\nimport Header from \"./atoms/Header\";\nimport Axis from \"./Axis\";\nimport Clip from \"./atoms/Clip\";\nimport Handles from \"./atoms/Handles\";\nimport ZoomControls from \"./atoms/ZoomControls\";\nimport Markers from \"./atoms/Markers\";\nimport Events from \"./atoms/Events\";\nimport Categories from \"./Categories\";\n\nclass Timeline extends Component {\n  constructor(props) {\n    super(props);\n    let searchParams = new URLSearchParams(window.location.href.split(\"?\")[1]);\n    this.styleDatetime = this.styleDatetime.bind(this);\n    this.getDatetimeX = this.getDatetimeX.bind(this);\n    this.getY = this.getY.bind(this);\n    this.onApplyZoom = this.onApplyZoom.bind(this);\n    this.onSelect = this.onSelect.bind(this);\n    this.onDragStart = this.onDragStart.bind(this);\n    this.onDrag = this.onDrag.bind(this);\n    this.onDragEnd = this.onDragEnd.bind(this);\n    this.svgRef = createRef();\n    this.state = {\n      isFolded:\n        searchParams.has(\"timeline\") &&\n        searchParams.get(\"timeline\") === \"false\",\n      dims: props.dimensions,\n      scaleX: null,\n      scaleY: null,\n      timerange: [null, null], // two Dates\n      dragPos0: null,\n      transitionDuration: 300,\n    };\n  }\n\n  componentDidMount() {\n    this.addEventListeners();\n  }\n\n  UNSAFE_componentWillReceiveProps(nextProps) {\n    if (hash(nextProps) !== hash(this.props)) {\n      this.setState({\n        timerange: nextProps.app.timeline.range,\n        scaleX: this.makeScaleX(),\n      });\n      if(this?.initialTimeRange == null) {\n        this.initialTimeRange = nextProps.app.timeline.range;\n      }\n    }\n\n    if (\n      hash(nextProps.activeCategories) !== hash(this.props.activeCategories) ||\n      hash(nextProps.dimensions) !== hash(this.props.dimensions)\n    ) {\n      const { trackHeight, marginTop } = nextProps.dimensions;\n      this.setState({\n        scaleY: this.makeScaleY(\n          nextProps.activeCategories,\n          trackHeight,\n          marginTop\n        ),\n      });\n    }\n\n    if (\n      nextProps.dimensions.trackHeight !== this.props.dimensions.trackHeight\n    ) {\n      this.computeDims();\n    }\n\n    // nextProps.domain.events.forEach(e => {\n    // });\n    // this.props.methods.onSelect()\n  }\n\n  addEventListeners() {\n    window.addEventListener(\"resize\", () => {\n      this.computeDims();\n    });\n    const element = document.querySelector(\".timeline-wrapper\");\n    if (element !== null) {\n      element.addEventListener(\"transitionend\", (event) => {\n        this.computeDims();\n      });\n    }\n  }\n\n  makeScaleX() {\n    return scaleTime()\n      .domain(this.state.timerange)\n      .range([\n        this.state.dims.marginLeft,\n        this.state.dims.width - this.state.dims.marginLeft,\n      ]);\n  }\n\n  makeScaleY(categories, trackHeight, marginTop) {\n    const { features } = this.props;\n    if (features.GRAPH_NONLOCATED && features.GRAPH_NONLOCATED.categories) {\n      categories = categories.filter(\n        (cat) => !features.GRAPH_NONLOCATED.categories.includes(cat.title)\n      );\n    }\n\n    const extraPadding = 0;\n    const catHeight =\n      categories.length > 2\n        ? trackHeight / categories.length\n        : trackHeight / (categories.length + 1);\n    const catsYpos = categories.map((g, i) => {\n      return (i + 1) * catHeight + marginTop + extraPadding / 2;\n    });\n\n    return (cat) => {\n      const idx = categories.indexOf(cat);\n      return catsYpos[idx];\n    };\n  }\n\n  componentDidUpdate(prevProps, prevState) {\n    if (prevState.timerange !== this.state.timerange) {\n      this.setState({ scaleX: this.makeScaleX() });\n    }\n  }\n\n  /**\n   * Returns the time scale (x) extent in minutes\n   */\n  getTimeScaleExtent() {\n    if (!this.state.scaleX) return 0;\n    const timeDomain = this.state.scaleX.domain();\n    return (timeDomain[1].getTime() - timeDomain[0].getTime()) / 60000;\n  }\n\n  onClickArrow() {\n    this.setState((prevState) => {\n      return { isFolded: !prevState.isFolded };\n    });\n  }\n\n  computeDims() {\n    const dom = this.props.ui.dom.timeline;\n    if (document.querySelector(`#${dom}`) !== null) {\n      const boundingClient = document\n        .querySelector(`#${dom}`)\n        .getBoundingClientRect();\n\n      this.setState(\n        {\n          dims: {\n            ...this.props.dimensions,\n            width: boundingClient.width,\n          },\n        },\n        () => {\n          this.setState({ scaleX: this.makeScaleX() });\n        }\n      );\n    }\n  }\n\n  /**\n   * Shift time range by moving forward or backwards\n   * @param {String} direction: 'forward' / 'backwards'\n   */\n  onMoveTime(direction) {\n    const extent = this.getTimeScaleExtent();\n    const newCentralTime = timeMinute.offset(\n      this.state.scaleX.domain()[0],\n      extent\n    );\n\n    // if forward\n    let domain0 = newCentralTime;\n    let domainF = timeMinute.offset(newCentralTime, extent);\n\n    // if backwards\n    if (direction === \"backwards\") {\n      domain0 = timeMinute.offset(newCentralTime, -(2 * extent));\n      domainF = timeMinute.offset(newCentralTime, -extent);\n    }\n\n    this.props.methods.onUpdateTimerange([domain0, domainF]);\n    this.props.methods.onSelect([]);\n  }\n\n  onCenterTime(newCentralTime) {\n    const extent = this.getTimeScaleExtent();\n\n    const domain0 = timeMinute.offset(newCentralTime, -extent / 2);\n    const domainF = timeMinute.offset(newCentralTime, +extent / 2);\n\n    this.setState({ timerange: [domain0, domainF] }, () => {\n      this.props.methods.onUpdateTimerange(this.state.timerange);\n    });\n  }\n\n  /**\n   * Change display of time range\n   * WITHOUT updating the store, or data shown.\n   * Used for updates in the middle of a transition, for performance purposes\n   */\n  onSoftTimeRangeUpdate(timerange) {\n    this.setState({ timerange });\n  }\n\n  /**\n   * Apply zoom level to timeline\n   * @param {object} zoom: zoom level from zoomLevels\n   */\n  onApplyZoom(zoom) {\n    const now = new Date();\n    const { rangeLimits } = this.props.app.timeline;\n\n    // Calculate the start and end of the zoom window, ending at 'now' and going back zoom.duration minutes\n    let newDomainF = now;\n    let newDomain0 = timeMinute.offset(now, -zoom.duration);\n\n    if (rangeLimits) {\n      const minDate = rangeLimits[0];\n      const maxDate = rangeLimits[1];\n\n      if (newDomain0 < minDate) {\n        newDomain0 = minDate;\n        newDomainF = timeMinute.offset(newDomain0, zoom.duration);\n      }\n      if (newDomainF > maxDate) {\n        newDomainF = maxDate;\n        newDomain0 = timeMinute.offset(newDomainF, -zoom.duration);\n      }\n    }\n\n    this.setState(\n      {\n        timerange: [newDomain0, newDomainF],\n      },\n      () => {\n        this.props.actions.updateTicks(15);\n        this.props.methods.onUpdateTimerange(this.state.timerange);\n      }\n    );\n  }\n\n  toggleTransition(isTransition) {\n    this.setState({ transitionDuration: isTransition ? 300 : 0 });\n  }\n\n  /*\n   * Setup drag behavior\n   */\n  onDragStart(event) {\n    event.sourceEvent.stopPropagation();\n    this.setState(\n      {\n        dragPos0: event.x,\n      },\n      () => {\n        this.toggleTransition(false);\n      }\n    );\n  }\n\n  /*\n   * Drag and update\n   */\n  onDrag(event) {\n    const drag0 = this.state.scaleX.invert(this.state.dragPos0).getTime();\n    const dragNow = this.state.scaleX.invert(event.x).getTime();\n    const timeShift = (drag0 - dragNow) / 1000;\n\n    const { range, rangeLimits } = this.props.app.timeline;\n    let newDomain0 = timeSecond.offset(range[0], timeShift);\n    let newDomainF = timeSecond.offset(range[1], timeShift);\n\n    if (rangeLimits) {\n      // If the store contains absolute time limits,\n      // make sure the zoom doesn't go over them\n      const minDate = rangeLimits[0];\n      const maxDate = rangeLimits[1];\n\n      newDomain0 = newDomain0 < minDate ? minDate : newDomain0;\n      newDomainF = newDomainF > maxDate ? maxDate : newDomainF;\n    }\n\n    // Updates components without updating timerange\n    this.onSoftTimeRangeUpdate([newDomain0, newDomainF]);\n  }\n\n  /**\n   * Stop dragging and update data\n   */\n  onDragEnd() {\n    this.toggleTransition(true);\n    this.props.methods.onUpdateTimerange(this.state.timerange);\n  }\n\n  getDatetimeX(datetime) {\n    return this.state.scaleX(datetime);\n  }\n\n  getY(event) {\n    const { features, domain, activeCategories } = this.props;\n    const { USE_CATEGORIES, GRAPH_NONLOCATED } = features;\n\n    const categoriesExist =\n      USE_CATEGORIES && activeCategories && activeCategories.length > 0;\n\n    if (!categoriesExist) {\n      return this.state.dims.trackHeight / 1.5;\n    }\n\n    const { category } = event;\n\n    if (GRAPH_NONLOCATED && GRAPH_NONLOCATED.categories.includes(category)) {\n      const { project } = event;\n      return (\n        this.state.dims.marginTop +\n        domain.projects[project].offset +\n        this.props.ui.eventRadius\n      );\n    }\n    if (!this.state.scaleY) return 0;\n\n    return this.state.scaleY(category);\n  }\n\n  /**\n   * Determines additional styles on the <circle> for each location.\n   * A location consists of an array of events (see selectors). The function\n   * also has full access to the domain and redux state to derive values if\n   * necessary. The function should return an array, where the value at the\n   * first index is a styles object for the SVG at the location, and the value\n   * at the second index is an optional additional component that renders in\n   * the <g/> div.\n   */\n  styleDatetime(timestamp, category) {\n    return [null, null];\n  }\n\n  onSelect(event) {\n    if (this.props.features.ZOOM_TO_TIMEFRAME_ON_TIMELINE_CLICK) {\n      const timeframe = Math.floor(\n        this.props.features.ZOOM_TO_TIMEFRAME_ON_TIMELINE_CLICK / 2\n      );\n      const start = timeMinute.offset(event.datetime, -timeframe);\n      const end = timeMinute.offset(event.datetime, timeframe);\n      this.props.actions.updateTicks(1);\n      this.props.methods.onUpdateTimerange([start, end]);\n    }\n    this.props.methods.onSelect(event);\n  }\n\n  render() {\n    const { isNarrative, app, domain } = this.props;\n    const { timeline } = app;\n\n    let classes = `timeline-wrapper ${this.state.isFolded ? \" folded\" : \"\"}`;\n    classes += app.narrative !== null ? \" narrative-mode\" : \"\";\n    const { dims } = this.state;\n    const contentHeight = { height: dims.contentHeight };\n    const { activeCategories: categories } = this.props;\n\n    const title = copy[this.props.app.language].timeline.info.replace(\n      \"%n\",\n      domain.eventCountInTimeRange\n    );\n\n    const resetTest = copy[this.props.app.language].timeline.reset;\n\n    return (\n      <div className={classes} onKeyDown={this.props.onKeyDown} tabIndex=\"1\">\n        <Header\n          title={title}\n          from={this.state.timerange[0]}\n          to={this.state.timerange[1]}\n          onClick={() => {\n            this.onClickArrow();\n          }}\n          hideInfo={isNarrative}\n          resetTest={resetTest}\n          resetClick={() => {\n            this.setState({\n              timerange: this.initialTimeRange\n            }, () => {\n              this.props.methods.onUpdateTimerange(this.state.timerange);\n            });\n            this.computeDims();\n          }}\n        />\n        <div className=\"timeline-content\">\n          <div id={this.props.ui.dom.timeline} className=\"timeline\">\n            <div className=\"timeline-container\">\n              <svg ref={this.svgRef} width={dims.width} style={contentHeight}>\n                <Clip dims={dims} />\n                <Axis\n                  ticks={timeline.dimensions.ticks}\n                  dims={dims}\n                  extent={this.getTimeScaleExtent()}\n                  transitionDuration={this.state.transitionDuration}\n                  scaleX={this.state.scaleX}\n                />\n                <Categories\n                  dims={dims}\n                  getCategoryY={(category) =>\n                    this.getY({ category, project: null })\n                  }\n                  onDragStart={this.onDragStart}\n                  onDrag={this.onDrag}\n                  onDragEnd={this.onDragEnd}\n                  categories={categories}\n                  features={this.props.features}\n                  fallbackLabel={\n                    copy[this.props.app.language].timeline\n                      .default_categories_label\n                  }\n                />\n                <Markers\n                  dims={dims}\n                  selected={this.props.app.selected}\n                  getEventX={(ev) => this.getDatetimeX(ev.datetime)}\n                  getEventY={this.getY}\n                  categories={categories}\n                  transitionDuration={this.state.transitionDuration}\n                  styles={this.props.ui.styles}\n                  features={this.props.features}\n                  eventRadius={this.props.ui.eventRadius}\n                />\n                <Events\n                  events={this.props.domain.events}\n                  projects={this.props.domain.projects}\n                  categories={categories}\n                  styleDatetime={this.styleDatetime}\n                  narrative={this.props.app.narrative}\n                  getDatetimeX={this.getDatetimeX}\n                  getY={this.getY}\n                  getHighlights={(group) => {\n                    if (group === \"None\") {\n                      return [];\n                    }\n                    return categories.map((c) => c.group === group);\n                  }}\n                  getCategoryColor={this.props.methods.getCategoryColor}\n                  transitionDuration={this.state.transitionDuration}\n                  onSelect={this.onSelect}\n                  dims={dims}\n                  features={this.props.features}\n                  setLoading={this.props.actions.setLoading}\n                  setNotLoading={this.props.actions.setNotLoading}\n                  eventRadius={this.props.ui.eventRadius}\n                  filterColors={this.props.ui.filterColors}\n                  coloringSet={this.props.app.coloringSet}\n                  highlighted={this.props.app.highlighted}\n                />\n              </svg>\n            </div>\n\n            <div className=\"timeline-bottom\">\n              <Handles\n                dims={dims}\n                onMoveTime={(dir) => {\n                  this.onMoveTime(dir);\n                }}\n                backward={true}\n              />\n              <ZoomControls\n                extent={this.getTimeScaleExtent()}\n                zoomLevels={timeline.zoomLevels}\n                dims={dims}\n                onApplyZoom={this.onApplyZoom}\n              />\n              <Handles\n                dims={dims}\n                onMoveTime={(dir) => {\n                  this.onMoveTime(dir);\n                }}\n                backward={false}\n              />\n            </div>\n          </div>\n        </div>\n      </div>\n    );\n  }\n}\n\nfunction mapStateToProps(state) {\n  return {\n    dimensions: selectors.selectDimensions(state),\n    isNarrative: !!state.app.associations.narrative,\n    activeCategories: selectors.getActiveCategories(state),\n    domain: {\n      events: selectors.selectStackedEvents(state),\n      eventCountInTimeRange: selectors.selectEventCountInTimeRange(state),\n      projects: selectors.selectProjects(state),\n      narratives: state.domain.narratives,\n    },\n    app: {\n      selected: state.app.selected,\n      highlighted: state.app.highlighted,\n      language: state.app.language,\n      narrative: state.app.associations.narrative,\n      coloringSet: state.app.associations.coloringSet,\n      timeline: {\n        zoomLevels: state.app.timeline.zoomLevels,\n        dimensions: selectors.selectDimensions(state),\n        ticks: state.app.timeline.ticks,\n        range: selectors.selectTimeRange(state),\n        rangeLimits: selectors.selectTimeRangeLimits(state),\n      },\n    },\n    ui: {\n      dom: state.ui.dom,\n      styles: state.ui.style.selectedEvents,\n      eventRadius: state.ui.eventRadius,\n      filterColors: state.ui.coloring.colors,\n    },\n    features: selectors.getFeatures(state),\n  };\n}\n\nfunction mapDispatchToProps(dispatch) {\n  return {\n    actions: bindActionCreators(\n      { setLoading, setNotLoading, updateTicks },\n      dispatch\n    ),\n  };\n}\n\nexport default connect(mapStateToProps, mapDispatchToProps)(Timeline);\n"
  },
  {
    "path": "src/components/time/atoms/Clip.jsx",
    "content": "const TimelineClip = ({ dims }) => (\n  <clipPath id=\"clip\">\n    <rect\n      x={dims.marginLeft}\n      y=\"0\"\n      width={Math.max(0, dims.width - dims.marginLeft * 2)}\n      height={dims.contentHeight}\n    />\n  </clipPath>\n);\n\nexport default TimelineClip;\n"
  },
  {
    "path": "src/components/time/atoms/DatetimeBar.jsx",
    "content": "const DatetimeBar = ({\n  highlights,\n  events,\n  x,\n  y,\n  width,\n  height,\n  onSelect,\n  styleProps,\n  extraRender,\n}) => {\n  if (highlights.length === 0) {\n    return (\n      <rect\n        onClick={onSelect}\n        className=\"event\"\n        x={x}\n        y={y}\n        style={styleProps}\n        width={width}\n        height={height}\n      />\n    );\n  }\n  const sectionHeight = height / highlights.length;\n  return (\n    <>\n      {highlights.map((h, idx) => (\n        <rect\n          onClick={onSelect}\n          className=\"event\"\n          x={x}\n          y={y - sectionHeight + idx * sectionHeight + sectionHeight / 2}\n          style={{ ...styleProps, opacity: h ? 0.3 : 0.1 }}\n          width={width}\n          height={sectionHeight}\n        />\n      ))}\n    </>\n  );\n};\n\nexport default DatetimeBar;\n"
  },
  {
    "path": "src/components/time/atoms/DatetimeDot.jsx",
    "content": "export default ({\n  category,\n  events,\n  x,\n  y,\n  r,\n  onSelect,\n  styleProps,\n  extraRender,\n}) => {\n  if (!y) return null;\n  return (\n    <circle\n      onClick={onSelect}\n      className=\"event\"\n      cx={x}\n      cy={y}\n      style={styleProps}\n      r={r}\n    />\n  );\n};\n"
  },
  {
    "path": "src/components/time/atoms/DatetimePentagon.jsx",
    "content": "const DatetimePentagon = ({ x, y, r, transform, onSelect, styleProps }) => {\n  const s = (r * 2) / 3;\n  return (\n    <polygon\n      onClick={onSelect}\n      className=\"event\"\n      x={x}\n      y={y}\n      style={styleProps}\n      points={`${x},${y + s} ${x + s},${y} ${x + s},${y - s} ${x - s},${\n        y - s\n      } ${x - s},${y}`}\n      transform={`rotate(180, ${x}, ${y})`}\n    />\n  );\n};\n\nexport default DatetimePentagon;\n"
  },
  {
    "path": "src/components/time/atoms/DatetimeSquare.jsx",
    "content": "const DatetimeSquare = ({\n  x,\n  y,\n  r,\n  transform,\n  onSelect,\n  styleProps,\n  extraRender,\n}) => {\n  return (\n    <rect\n      onClick={onSelect}\n      className=\"event\"\n      x={x}\n      y={y}\n      style={styleProps}\n      width={r}\n      height={r}\n      transform={transform}\n    />\n  );\n};\n\nexport default DatetimeSquare;\n"
  },
  {
    "path": "src/components/time/atoms/DatetimeStar.jsx",
    "content": "const DatetimeStar = ({\n  x,\n  y,\n  r,\n  transform,\n  onSelect,\n  styleProps,\n  extraRender,\n}) => {\n  const s = (r * 2) / 3;\n  return (\n    <polygon\n      onClick={onSelect}\n      className=\"event\"\n      x={x}\n      y={y}\n      style={styleProps}\n      points={`${x + s},${y - s} ${x - r},${y} ${x + r},${y} ${x - s},${\n        y - s\n      } ${x},${y + s}`}\n      transform={transform}\n    />\n  );\n};\n\nexport default DatetimeStar;\n"
  },
  {
    "path": "src/components/time/atoms/DatetimeTriangle.jsx",
    "content": "const DatetimeTriangle = ({ x, y, r, transform, onSelect, styleProps }) => {\n  const s = (r * 2) / 3;\n  return (\n    <polygon\n      onClick={onSelect}\n      className=\"event\"\n      x={x}\n      y={y}\n      style={styleProps}\n      points={`${x},${y + s} ${x + s},${y - s} ${x - s},${y - s}`}\n      transform={`rotate(180, ${x}, ${y})`}\n    />\n  );\n};\n\nexport default DatetimeTriangle;\n"
  },
  {
    "path": "src/components/time/atoms/Events.jsx",
    "content": "import DatetimeBar from \"./DatetimeBar\";\nimport DatetimeSquare from \"./DatetimeSquare\";\nimport DatetimeStar from \"./DatetimeStar\";\nimport DatetimeTriangle from \"./DatetimeTriangle\";\nimport DatetimePentagon from \"./DatetimePentagon\";\nimport Project from \"./Project\";\nimport ColoredMarkers from \"../../atoms/ColoredMarkers\";\nimport {\n  calcOpacity,\n  getEventCategories,\n  zipColorsToPercentages,\n  calculateColorPercentages,\n  isLatitude,\n  isLongitude,\n} from \"../../../common/utilities\";\nimport { AVAILABLE_SHAPES } from \"../../../common/constants\";\n\nconst HIGHLIGHT_COLOR = \"#E31A1B\";\n\nfunction renderDot(event, styles, props) {\n  const colorPercentages = calculateColorPercentages(\n    [event],\n    props.coloringSet\n  );\n  return (\n    <g\n      key={event.id}\n      className=\"timeline-event\"\n      onClick={props.onSelect}\n      transform={`translate(${props.x}, ${props.y + 40})`}\n    >\n      <ColoredMarkers\n        radius={props.eventRadius}\n        colorPercentMap={zipColorsToPercentages(\n          props.filterColors,\n          colorPercentages\n        )}\n        styles={{\n          ...styles,\n        }}\n        className=\"event\"\n      />\n    </g>\n  );\n}\n\nfunction renderBar(event, styles, props) {\n  const fillOpacity = props.features.GRAPH_NONLOCATED\n    ? event.projectOffset >= 0\n      ? styles.opacity\n      : 0.5\n    : calcOpacity(1);\n\n  return (\n    <DatetimeBar\n      onSelect={props.onSelect}\n      category={event.category}\n      events={[event]}\n      x={props.x}\n      y={props.dims.marginTop}\n      width={props.eventRadius / 4}\n      height={props.dims.trackHeight}\n      styleProps={{ ...styles, fillOpacity }}\n      highlights={props.highlights}\n    />\n  );\n}\n\nfunction renderDiamond(event, styles, props) {\n  return (\n    <DatetimeSquare\n      onSelect={props.onSelect}\n      x={props.x}\n      y={props.y - 1.8 * props.eventRadius}\n      r={1.8 * props.eventRadius}\n      styleProps={styles}\n      transform={`rotate(45, ${props.x}, ${props.y})`}\n    />\n  );\n}\n\nfunction renderSquare(event, styles, props) {\n  return (\n    <DatetimeSquare\n      onSelect={props.onSelect}\n      x={props.x}\n      y={props.y - (1.8 * props.eventRadius) / 2}\n      r={1.8 * props.eventRadius}\n      styleProps={styles}\n    />\n  );\n}\n\nfunction renderTriangle(event, styles, props) {\n  return (\n    <DatetimeTriangle\n      onSelect={props.onSelect}\n      x={props.x}\n      y={props.y}\n      r={1.5 * props.eventRadius}\n      styleProps={styles}\n    />\n  );\n}\n\nfunction renderPentagon(event, styles, props) {\n  return (\n    <DatetimePentagon\n      onSelect={props.onSelect}\n      x={props.x}\n      y={props.y}\n      r={1.5 * props.eventRadius}\n      styleProps={styles}\n    />\n  );\n}\n\nfunction renderStar(event, styles, props) {\n  return (\n    <DatetimeStar\n      onSelect={props.onSelect}\n      x={props.x}\n      y={props.y}\n      r={1.8 * props.eventRadius}\n      styleProps={{ ...styles, fillRule: \"nonzero\" }}\n      transform={`rotate(180, ${props.x}, ${props.y})`}\n    />\n  );\n}\n\nconst TimelineEvents = ({\n  events,\n  projects,\n  categories,\n  narrative,\n  getDatetimeX,\n  getY,\n  getCategoryColor,\n  getHighlights,\n  onSelect,\n  transitionDuration,\n  dims,\n  features,\n  setLoading,\n  setNotLoading,\n  eventRadius,\n  filterColors,\n  coloringSet,\n  highlighted,\n}) => {\n  const narIds = narrative ? narrative.steps.map((s) => s.id) : [];\n\n  function renderEvent(acc, event) {\n    if (narrative) {\n      if (!narIds.includes(event.id)) {\n        return null;\n      }\n    }\n    const isDot =\n      (isLatitude(event.latitude) && isLongitude(event.longitude)) ||\n      (features.GRAPH_NONLOCATED && event.projectOffset !== -1);\n\n    const { shape: eventShape } = event;\n\n    let renderShape = isDot ? renderDot : renderBar;\n    if (eventShape && eventShape.shape) {\n      if (eventShape.shape === AVAILABLE_SHAPES.BAR) {\n        renderShape = renderBar;\n      } else if (eventShape.shape === AVAILABLE_SHAPES.DIAMOND) {\n        renderShape = renderDiamond;\n      } else if (eventShape.shape === AVAILABLE_SHAPES.STAR) {\n        renderShape = renderStar;\n      } else if (eventShape.shape === AVAILABLE_SHAPES.TRIANGLE) {\n        renderShape = renderTriangle;\n      } else if (eventShape.shape === AVAILABLE_SHAPES.PENTAGON) {\n        renderShape = renderPentagon;\n      } else if (eventShape.shape === AVAILABLE_SHAPES.SQUARE) {\n        renderShape = renderSquare;\n      } else {\n        renderShape = renderDot;\n      }\n    }\n\n    // Check if this event is highlighted\n    const isHighlighted =\n      highlighted &&\n      highlighted.length > 0 &&\n      highlighted.includes(event.civId);\n\n    // if an event has multiple categories, it should be rendered on each of\n    // those timelines: so we create as many event 'shadows' as there are\n    // categories\n    const evShadows = getEventCategories(event, categories).map((cat) => {\n      const y = getY({ ...event, category: cat });\n\n      const colour = isHighlighted\n        ? HIGHLIGHT_COLOR\n        : event.colour\n          ? event.colour\n          : getCategoryColor(cat.title);\n\n      const styles = {\n        fill: colour,\n        fillOpacity: y > 0 ? calcOpacity(1) : 0,\n        transition: `transform ${transitionDuration / 1000}s ease`,\n      };\n\n      return { y, styles };\n    });\n\n    function getRender(y, styles) {\n      return renderShape(event, styles, {\n        x: getDatetimeX(event.datetime),\n        y,\n        eventRadius,\n        onSelect: () => onSelect(event),\n        dims,\n        highlights: features.HIGHLIGHT_GROUPS\n          ? getHighlights(\n              event.filters[\n                features.HIGHLIGHT_GROUPS.filterIndexIndicatingGroup\n              ]\n            )\n          : [],\n        features,\n        filterColors,\n        coloringSet,\n      });\n    }\n\n    if (evShadows.length === 0) {\n      acc.push(getRender(getY(event), { fill: getCategoryColor(null) }));\n    } else {\n      evShadows.forEach((evShadow) => {\n        acc.push(getRender(evShadow.y, evShadow.styles));\n      });\n    }\n    return acc;\n  }\n\n  let renderProjects = () => null;\n  if (features.GRAPH_NONLOCATED) {\n    renderProjects = function () {\n      return (\n        <>\n          {Object.values(projects).map((project) => (\n            <Project\n              key={project.id}\n              {...project}\n              eventRadius={eventRadius}\n              onClick={() => console.log(project)}\n              getX={getDatetimeX}\n              dims={dims}\n              colour={getCategoryColor(project.category)}\n            />\n          ))}\n        </>\n      );\n    };\n  }\n\n  return (\n    <g clipPath=\"url(#clip)\">\n      {renderProjects()}\n      {events.reduce(renderEvent, [])}\n    </g>\n  );\n};\n\nexport default TimelineEvents;\n"
  },
  {
    "path": "src/components/time/atoms/Handles.jsx",
    "content": "const TimelineHandles = ({ dims, onMoveTime, backward }) => {\n  if (backward === true) {\n    return (\n      <div className=\"timeline-handle\" onClick={() => onMoveTime(\"backwards\")}>\n        <span className=\"timeline-handle__triangle\"></span>\n      </div>\n    );\n  }\n\n  return (\n    <div\n      className=\"timeline-handle right\"\n      onClick={() => onMoveTime(\"forward\")}\n    >\n      <span className=\"timeline-handle__triangle\"></span>\n    </div>\n  );\n};\n\nexport default TimelineHandles;\n"
  },
  {
    "path": "src/components/time/atoms/Header.jsx",
    "content": "import { makeNiceDate } from \"../../../common/utilities\";\n\nconst TimelineHeader = ({ title, from, to, onClick, hideInfo, resetTest, resetClick }) => {\n  const d0 = from && makeNiceDate(from);\n  const d1 = to && makeNiceDate(to);\n  return (\n    <div className=\"timeline-header\">\n      <div className=\"timeline-toggle\" onClick={() => onClick()}>\n        <p>\n          <i className=\"arrow-down\" />\n        </p>\n      </div>\n      <div className={`timeline-info ${hideInfo ? \"hidden\" : \"\"}`}>\n        <p dangerouslySetInnerHTML={{ __html: title }} />\n        <p>\n          {d0} - {d1}\n          <small className=\"reset-button\" onClick={() => resetClick()}><a className=\"cell\">{resetTest}</a></small>\n        </p>\n        <div style={{ fontWeight: \"400\", textAlign: \"center\", marginTop: \"5px\" }}>\n          <i className=\"material-icons\" style={{ color: \"#FFA726\", fontSize: \"18px\", verticalAlign: \"middle\" }}>warning</i> No verified incidents are being added after Aug 2025.\n        </div>\n      </div>\n    </div>\n  );\n};\n\nexport default TimelineHeader;\n"
  },
  {
    "path": "src/components/time/atoms/Labels.jsx",
    "content": "const TimelineLabels = ({ dims, timelabels }) => {\n  return (\n    <g>\n      <line\n        className=\"axisBoundaries\"\n        x1={dims.marginLeft}\n        x2={dims.marginLeft}\n        y1=\"10\"\n        y2=\"20\"\n      />\n      <line\n        className=\"axisBoundaries\"\n        x1={dims.width - dims.width_controls}\n        x2={dims.width - dims.width_controls}\n        y1=\"10\"\n        y2=\"20\"\n      />\n      <text className=\"timeLabel0 timeLabel\" x=\"5\" y=\"15\">\n        {timelabels[0]}\n      </text>\n      <text\n        className=\"timelabelF timeLabel\"\n        x={dims.width - dims.width_controls - 5}\n        y=\"135\"\n        style={{ textAnchor: \"end\" }}\n      >\n        {timelabels[1]}\n      </text>\n    </g>\n  );\n};\n\nexport default TimelineLabels;\n"
  },
  {
    "path": "src/components/time/atoms/Markers.jsx",
    "content": "import colors from \"../../../common/global\";\nimport hash from \"object-hash\";\nimport {\n  getEventCategories,\n  isLatitude,\n  isLongitude,\n} from \"../../../common/utilities\";\nimport { AVAILABLE_SHAPES } from \"../../../common/constants\";\n\nconst TimelineMarkers = ({\n  styles,\n  eventRadius,\n  getEventX,\n  getEventY,\n  categories,\n  transitionDuration,\n  selected,\n  dims,\n  features,\n}) => {\n  function renderMarker(acc, event) {\n    function renderCircle(y) {\n      return (\n        <circle\n          key={hash(event)}\n          className=\"timeline-marker\"\n          cx={0}\n          cy={0}\n          stroke={styles ? styles.stroke : colors.primaryHighlight}\n          strokeOpacity=\"1\"\n          strokeWidth={styles ? styles[\"stroke-width\"] : 1}\n          strokeLinejoin=\"round\"\n          strokeDasharray={styles ? styles[\"stroke-dasharray\"] : \"2,2\"}\n          style={{\n            transform: `translate(${getEventX(event)}px, ${y + 40}px)`,\n            WebkitTransition: `transform ${transitionDuration / 1000}s ease`,\n            MozTransition: \"none\",\n            opacity: 1,\n          }}\n          r={eventRadius * 2}\n        />\n      );\n    }\n    function renderBar() {\n      return (\n        <rect\n          className=\"timeline-marker\"\n          x={0}\n          y={dims.marginTop}\n          width={eventRadius / 1.5}\n          height={dims.contentHeight - 55}\n          stroke={styles ? styles.stroke : colors.primaryHighlight}\n          strokeOpacity=\"1\"\n          strokeWidth={styles ? styles[\"stroke-width\"] : 1}\n          strokeDasharray={styles ? styles[\"stroke-dasharray\"] : \"2,2\"}\n          style={{\n            transform: `translate(${getEventX(event)}px)`,\n            opacity: 0.7,\n          }}\n        />\n      );\n    }\n\n    const isDot =\n      (isLatitude(event.latitude) && isLongitude(event.longitude)) ||\n      (features.GRAPH_NONLOCATED && event.projectOffset !== -1);\n\n    const evShadows = getEventCategories(event, categories).map((cat) =>\n      getEventY({ ...event, category: cat })\n    );\n\n    function renderMarkerForEvent(y) {\n      switch (event.shape) {\n        case \"circle\":\n        case AVAILABLE_SHAPES.DIAMOND:\n        case AVAILABLE_SHAPES.STAR:\n          acc.push(renderCircle(y));\n          break;\n        case AVAILABLE_SHAPES.BAR:\n          acc.push(renderBar(y));\n          break;\n        default:\n          return isDot ? acc.push(renderCircle(y)) : acc.push(renderBar(y));\n      }\n    }\n\n    if (evShadows.length > 0) {\n      evShadows.forEach(renderMarkerForEvent);\n    } else {\n      renderMarkerForEvent(getEventY(event));\n    }\n    return acc;\n  }\n\n  return <g clipPath=\"url(#clip)\">{selected.reduce(renderMarker, [])}</g>;\n};\n\nexport default TimelineMarkers;\n"
  },
  {
    "path": "src/components/time/atoms/Project.jsx",
    "content": "const Project = ({\n  offset,\n  id,\n  start,\n  end,\n  getX,\n  y,\n  dims,\n  colour,\n  eventRadius,\n  onClick,\n}) => {\n  const length = getX(end) - getX(start);\n  if (offset === undefined) return null;\n  return (\n    <rect\n      onClick={onClick}\n      className=\"project\"\n      x={getX(start)}\n      y={dims.marginTop + offset}\n      width={length}\n      style={{ fill: colour, fillOpacity: 0.2 }}\n      height={2 * eventRadius}\n    />\n  );\n};\n\nexport default Project;\n"
  },
  {
    "path": "src/components/time/atoms/ZoomControls.jsx",
    "content": "const DEFAULT_ZOOM_LEVELS = [\n  { label: \"20 years\", duration: 10512000 },\n  { label: \"2 years\", duration: 1051200 },\n  { label: \"3 months\", duration: 129600 },\n  { label: \"3 days\", duration: 4320 },\n  { label: \"12 hours\", duration: 720 },\n  { label: \"1 hour\", duration: 60 },\n];\n\nfunction zoomIsActive(duration, extent, max) {\n  if (duration >= max && extent >= max) {\n    return true;\n  }\n  return duration === extent;\n}\n\nconst TimelineZoomControls = ({ extent, zoomLevels, dims, onApplyZoom }) => {\n  function renderZoom(zoom, idx) {\n    const max = zoomLevels.reduce((acc, vl) =>\n      acc.duration < vl.duration ? vl : acc\n    );\n    const isActive = zoomIsActive(zoom.duration, extent, max.duration);\n    return (\n      <div\n        className={`zoom-level-button ${isActive ? \"active\" : \"\"}`}\n        x=\"60\"\n        y={idx * 15 + 20}\n        onClick={() => onApplyZoom(zoom)}\n        key={idx}\n      >\n        {zoom.label}\n      </div>\n    );\n  }\n\n  if (zoomLevels.length === 0) {\n    zoomLevels = DEFAULT_ZOOM_LEVELS;\n  }\n  return (\n    <div className=\"zoom-controls\">\n      {zoomLevels.map((z, idx) => renderZoom(z, idx))}\n    </div>\n  );\n};\n\nexport default TimelineZoomControls;\n"
  },
  {
    "path": "src/index.jsx",
    "content": "import ReactDOM from \"react-dom/client\";\nimport { Provider } from \"react-redux\";\nimport store from \"./store\";\nimport App from \"./components/App\";\n\nconst root = ReactDOM.createRoot(document.getElementById(\"explore-app\"));\nroot.render(\n  <Provider store={store}>\n    <App />\n  </Provider>\n);\n\n// Expressions from https://exceptionshub.com/how-to-detect-safari-chrome-ie-firefox-and-opera-browser.html\n\n/* eslint-disable */\n// Opera 8.0+\nconst isOpera =\n  (!!window.opr && !!opr.addons) ||\n  !!window.opera ||\n  navigator.userAgent.indexOf(\" OPR/\") >= 0;\n// Firefox 1.0+\nconst isFirefox = typeof InstallTrigger !== \"undefined\";\n// Safari 3.0+ \"[object HTMLElementConstructor]\"\nconst isSafari =\n  /constructor/i.test(window.HTMLElement) ||\n  (function (p) {\n    return p.toString() === \"[object SafariRemoteNotification]\";\n  })(\n    !window[\"safari\"] ||\n      (typeof safari !== \"undefined\" && safari.pushNotification)\n  );\n// Internet Explorer 6-11\nconst isIE = /* @cc_on!@ */ false || !!document.documentMode;\n// Edge 20+\nconst isEdge = !isIE && !!window.StyleMedia;\n// Chrome 1+\nconst isChrome = !!window.chrome && !!window.chrome.webstore;\n// Blink engine detection\nconst isBlink = (isChrome || isOpera) && !!window.CSS;\n\nif (isEdge || isIE) {\n  alert(\n    \"Please view this website in Opera for best viewing. It is untested in your browser.\"\n  );\n}\n/* eslint-enable */\n"
  },
  {
    "path": "src/reducers/__tests__/index.spec.js",
    "content": "import { updateTimeRange } from \"../../actions\";\nimport initial from \"../../store/initial\";\nimport reduce from \"../app\";\n\ndescribe(\"app reducer\", () => {\n  it(\"can update the selected time range\", () => {\n    const result = reduce(\n      initial.app,\n      updateTimeRange([\"2022-01-01T00:00:00.000Z\", \"2022-03-01T00:30:00.000Z\"])\n    );\n    expect(result.timeline.range.current).toEqual([\n      \"2022-01-01T00:00:00.000Z\",\n      \"2022-03-01T00:30:00.000Z\",\n    ]);\n  });\n});\n"
  },
  {
    "path": "src/reducers/__tests__/ui.spec.js",
    "content": "import { toggleTileOverlay } from \"../../actions\";\nimport initial from \"../../store/initial\";\nimport ui from \"../ui\";\nimport config from \"../../../config\";\n\ndescribe(\"UI reducer\", () => {\n  it(\"can change the tiling\", () => {\n    const result = ui(initial.ui, toggleTileOverlay());\n    expect(result.tiles.current).toEqual(initial.ui.tiles.satellite);\n    expect(result.tiles.default).toEqual(initial.ui.tiles.default);\n  });\n\n  it(\"can revert to the default tiling\", () => {\n    const result = ui(\n      {\n        ...initial.ui,\n        tiles: { default: \"some default\", current: \"something else\" },\n      },\n      toggleTileOverlay()\n    );\n    expect(result.tiles.current).toBeUndefined();\n    expect(result.tiles.default).toEqual(\"some default\");\n  });\n});\n"
  },
  {
    "path": "src/reducers/app.js",
    "content": "import initial from \"../store/initial\";\nimport { ASSOCIATION_MODES } from \"../common/constants\";\nimport { toggleFlagAC } from \"../common/utilities\";\nimport * as selectors from \"../selectors\";\n\nimport {\n  UPDATE_HIGHLIGHTED,\n  UPDATE_SELECTED,\n  UPDATE_COLORING_SET,\n  UPDATE_TICKS,\n  CLEAR_FILTER,\n  TOGGLE_ASSOCIATIONS,\n  TOGGLE_SHAPES,\n  UPDATE_TIMERANGE,\n  UPDATE_DIMENSIONS,\n  UPDATE_NARRATIVE,\n  UPDATE_NARRATIVE_STEP_IDX,\n  UPDATE_SOURCE,\n  TOGGLE_LANGUAGE,\n  TOGGLE_SITES,\n  TOGGLE_FETCHING_DOMAIN,\n  TOGGLE_FETCHING_SOURCES,\n  TOGGLE_INFOPOPUP,\n  TOGGLE_INTROPOPUP,\n  TOGGLE_NOTIFICATIONS,\n  TOGGLE_COVER,\n  FETCH_ERROR,\n  FETCH_SOURCE_ERROR,\n  SET_LOADING,\n  SET_NOT_LOADING,\n  SET_INITIAL_CATEGORIES,\n  SET_INITIAL_SHAPES,\n  UPDATE_SEARCH_QUERY,\n  UPDATE_MAP_VIEW,\n} from \"../actions\";\n\nfunction updateHighlighted(appState, action) {\n  return Object.assign({}, appState, {\n    highlighted: action.highlighted,\n  });\n}\n\nfunction updateTicks(appState, action) {\n  return {\n    ...appState,\n    timeline: {\n      ...appState.timeline,\n      dimensions: {\n        ...appState.timeline.dimensions,\n        ticks: action.ticks,\n      },\n    },\n  };\n}\n\nfunction updateSelected(appState, action) {\n  return Object.assign({}, appState, {\n    selected: action.selected,\n  });\n}\n\nfunction updateColoringSet(appState, action) {\n  return {\n    ...appState,\n    associations: {\n      ...appState.associations,\n      coloringSet: action.coloringSet,\n    },\n  };\n}\n\nfunction updateNarrative(appState, action) {\n  let [minTime, maxTime] = selectors.selectTimeRange(appState);\n\n  const cornerBound0 = [180, 180];\n  const cornerBound1 = [-180, -180];\n\n  // Compute narrative time range and map bounds\n  if (action.narrative) {\n    [minTime, maxTime] = selectors.selectTimeRangeLimits(appState);\n\n    // Find max and mins coordinates of narrative events\n    action.narrative.steps.forEach((step) => {\n      const stepTime = step.datetime;\n      if (stepTime < minTime) minTime = stepTime;\n      if (stepTime > maxTime) maxTime = stepTime;\n\n      if (!!step.longitude && !!step.latitude) {\n        if (+step.longitude < cornerBound0[1])\n          cornerBound0[1] = +step.longitude;\n        if (+step.longitude > cornerBound1[1])\n          cornerBound1[1] = +step.longitude;\n        if (+step.latitude < cornerBound0[0]) cornerBound0[0] = +step.latitude;\n        if (+step.latitude > cornerBound1[0]) cornerBound1[0] = +step.latitude;\n      }\n    });\n    // Adjust bounds to center around first event, while keeping visible all others\n    // Takes first event, finds max ditance with first attempt bounds, and use this max distance\n    // on the other side, both in latitude and longitude\n    const first = action.narrative.steps[0];\n    if (!!first.longitude && !!first.latitude) {\n      const firstToLong0 = Math.abs(+first.longitude - cornerBound0[1]);\n      const firstToLong1 = Math.abs(+first.longitude - cornerBound1[1]);\n      const firstToLat0 = Math.abs(+first.latitude - cornerBound0[0]);\n      const firstToLat1 = Math.abs(+first.latitude - cornerBound1[0]);\n\n      if (firstToLong0 > firstToLong1)\n        cornerBound1[1] = +first.longitude + firstToLong0;\n      if (firstToLong0 < firstToLong1)\n        cornerBound0[1] = +first.longitude - firstToLong1;\n      if (firstToLat0 > firstToLat1)\n        cornerBound1[0] = +first.latitude + firstToLat0;\n      if (firstToLat0 < firstToLat1)\n        cornerBound0[0] = +first.latitude - firstToLat1;\n    }\n\n    // Add some buffer on both sides of the time extent\n    minTime = minTime - Math.abs((maxTime - minTime) / 10);\n    maxTime = maxTime + Math.abs((maxTime - minTime) / 10);\n  }\n\n  return {\n    ...appState,\n    associations: {\n      ...appState.associations,\n      narrative: action.narrative,\n    },\n    map: {\n      ...appState.map,\n      bounds: action.narrative ? [cornerBound0, cornerBound1] : null,\n    },\n    timeline: {\n      ...appState.timeline,\n      range: {\n        ...appState.timeline.range,\n        current: [minTime, maxTime],\n      },\n    },\n  };\n}\n\nfunction updateNarrativeStepIdx(appState, action) {\n  return {\n    ...appState,\n    narrativeState: {\n      current: action.idx,\n    },\n  };\n}\n\nfunction toggleAssociations(appState, action) {\n  if (!(action.value instanceof Array)) {\n    action.value = [action.value];\n  }\n  const { association: associationType } = action;\n\n  let newAssociations = appState.associations[associationType].slice(0);\n  action.value.forEach((vl) => {\n    if (newAssociations.includes(vl)) {\n      newAssociations = newAssociations.filter((s) => s !== vl);\n    } else {\n      newAssociations.push(vl);\n    }\n  });\n\n  return {\n    ...appState,\n    associations: {\n      ...appState.associations,\n      [associationType]: newAssociations,\n    },\n  };\n}\n\nfunction toggleShapes(appState, action) {\n  let newShapes = [...appState.shapes];\n  if (newShapes.includes(action.shape)) {\n    const idx = newShapes.indexOf(action.shape);\n    newShapes.splice(idx, 1);\n  } else {\n    newShapes.push(action.shape);\n  }\n\n  return {\n    ...appState,\n    shapes: newShapes,\n  };\n}\n\nfunction clearFilter(appState, action) {\n  return {\n    ...appState,\n    filters: {\n      ...appState.filters,\n      [action.filter]: [],\n    },\n  };\n}\n\nfunction updateTimeRange(appState, action) {\n  // XXX\n  return {\n    ...appState,\n    timeline: {\n      ...appState.timeline,\n      range: {\n        ...appState.timeline.range,\n        current: [\n          new Date(action.timerange[0]).toISOString(),\n          new Date(action.timerange[1]).toISOString(),\n        ],\n      },\n    },\n  };\n}\n\nfunction updateDimensions(appState, action) {\n  return {\n    ...appState,\n    timeline: {\n      ...appState.timeline,\n      dimensions: {\n        ...appState.timeline.dimensions,\n        ...action.dims,\n      },\n    },\n  };\n}\n\nfunction toggleLanguage(appState, action) {\n  const otherLanguage = appState.language === \"es-MX\" ? \"en-US\" : \"es-MX\";\n  return Object.assign({}, appState, {\n    language: action.language || otherLanguage,\n  });\n}\n\nfunction updateSource(appState, action) {\n  return {\n    ...appState,\n    source: action.source,\n  };\n}\n\nfunction fetchError(state, action) {\n  return {\n    ...state,\n    error: action.message,\n    notifications: [{ type: \"error\", message: action.message }],\n  };\n}\n\nconst toggleSites = toggleFlagAC(\"isShowingSites\");\nconst toggleFetchingDomain = toggleFlagAC(\"isFetchingDomain\");\nconst toggleFetchingSources = toggleFlagAC(\"isFetchingSources\");\nconst toggleInfoPopup = toggleFlagAC(\"isInfopopup\");\nconst toggleIntroPopup = toggleFlagAC(\"isIntropopup\");\nconst toggleNotifications = toggleFlagAC(\"isNotification\");\nconst toggleCover = toggleFlagAC(\"isCover\");\n\nfunction fetchSourceError(appState, action) {\n  return {\n    ...appState,\n    errors: {\n      ...appState.errors,\n      source: action.msg,\n    },\n  };\n}\n\nfunction setLoading(appState) {\n  return {\n    ...appState,\n    loading: true,\n  };\n}\n\nfunction setNotLoading(appState) {\n  return {\n    ...appState,\n    loading: false,\n  };\n}\n\nfunction setInitialCategories(appState, action) {\n  const categories = action.values.reduce((acc, val) => {\n    if (val.mode === ASSOCIATION_MODES.CATEGORY) acc.push(val.title);\n    return acc;\n  }, []);\n\n  return {\n    ...appState,\n    associations: {\n      ...appState.associations,\n      categories: categories,\n    },\n  };\n}\n\nfunction setInitialShapes(appState, action) {\n  const shapeIds = action.values.map((sh) => sh.id);\n  return {\n    ...appState,\n    shapes: shapeIds,\n  };\n}\n\nfunction updateSearchQuery(appState, action) {\n  return {\n    ...appState,\n    searchQuery: action.searchQuery,\n  };\n}\n\nfunction updateMapView(appState, action) {\n  return {\n    ...appState,\n    map: {\n      ...appState.map,\n      anchor: [action.lat, action.lng],\n      startZoom: action.zoom,\n    },\n  };\n}\n\nfunction app(appState = initial.app, action) {\n  switch (action.type) {\n    case UPDATE_HIGHLIGHTED:\n      return updateHighlighted(appState, action);\n    case UPDATE_SELECTED:\n      return updateSelected(appState, action);\n    case UPDATE_COLORING_SET:\n      return updateColoringSet(appState, action);\n    case UPDATE_TICKS:\n      return updateTicks(appState, action);\n    case CLEAR_FILTER:\n      return clearFilter(appState, action);\n    case TOGGLE_ASSOCIATIONS:\n      return toggleAssociations(appState, action);\n    case TOGGLE_SHAPES:\n      return toggleShapes(appState, action);\n    case UPDATE_TIMERANGE:\n      return updateTimeRange(appState, action);\n    case UPDATE_DIMENSIONS:\n      return updateDimensions(appState, action);\n    case UPDATE_NARRATIVE:\n      return updateNarrative(appState, action);\n    case UPDATE_NARRATIVE_STEP_IDX:\n      return updateNarrativeStepIdx(appState, action);\n    case UPDATE_SOURCE:\n      return updateSource(appState, action);\n    /* toggles */\n    case TOGGLE_LANGUAGE:\n      return toggleLanguage(appState, action);\n    case TOGGLE_SITES:\n      return toggleSites(appState);\n    case TOGGLE_FETCHING_DOMAIN:\n      return toggleFetchingDomain(appState);\n    case TOGGLE_FETCHING_SOURCES:\n      return toggleFetchingSources(appState);\n    case TOGGLE_INFOPOPUP:\n      return toggleInfoPopup(appState);\n    case TOGGLE_INTROPOPUP:\n      return toggleIntroPopup(appState);\n    case TOGGLE_NOTIFICATIONS:\n      return toggleNotifications(appState);\n    case TOGGLE_COVER:\n      return toggleCover(appState);\n    /* errors */\n    case FETCH_ERROR:\n      return fetchError(appState, action);\n    case FETCH_SOURCE_ERROR:\n      return fetchSourceError(appState, action);\n    case SET_LOADING:\n      return setLoading(appState);\n    case SET_NOT_LOADING:\n      return setNotLoading(appState);\n    case SET_INITIAL_CATEGORIES:\n      return setInitialCategories(appState, action);\n    case SET_INITIAL_SHAPES:\n      return setInitialShapes(appState, action);\n    case UPDATE_SEARCH_QUERY:\n      return updateSearchQuery(appState, action);\n    case UPDATE_MAP_VIEW:\n      return updateMapView(appState, action);\n    default:\n      return appState;\n  }\n}\n\nexport default app;\n"
  },
  {
    "path": "src/reducers/domain.js",
    "content": "import initial from \"../store/initial\";\n\nimport { UPDATE_DOMAIN, MARK_NOTIFICATIONS_READ } from \"../actions\";\nimport { validateDomain } from \"./validate/validators\";\n\nfunction updateDomain(domainState, action) {\n  return {\n    ...domainState,\n    ...validateDomain(action.payload.domain, action.payload.features),\n  };\n}\n\nfunction markNotificationsRead(domainState, action) {\n  return {\n    ...domainState,\n    notifications: domainState.notifications.map((n) => ({\n      ...n,\n      isRead: true,\n    })),\n  };\n}\n\nfunction domain(domainState = initial.domain, action) {\n  switch (action.type) {\n    case UPDATE_DOMAIN:\n      return updateDomain(domainState, action);\n    case MARK_NOTIFICATIONS_READ:\n      return markNotificationsRead(domainState, action);\n    default:\n      return domainState;\n  }\n}\n\nexport default domain;\n"
  },
  {
    "path": "src/reducers/features.js",
    "content": "import initial from \"../store/initial\";\n\nfunction features(featureState = initial.features, action) {\n  return featureState;\n}\n\nexport default features;\n"
  },
  {
    "path": "src/reducers/index.js",
    "content": "import { combineReducers } from \"redux\";\nimport rootReducer from \"./root\";\nimport domain from \"./domain\";\nimport app from \"./app\";\nimport ui from \"./ui\";\nimport features from \"./features\";\n\nfunction decorateRootReducer(rootReducer, reducer) {\n  return (state, action) =>\n    reducer(\n      {\n        ...rootReducer(state, action),\n      },\n      action\n    );\n}\n\nexport default decorateRootReducer(\n  rootReducer,\n  combineReducers({\n    app,\n    domain,\n    ui,\n    features,\n  })\n);\n"
  },
  {
    "path": "src/reducers/root.js",
    "content": "import { REHYDRATE_STATE } from \"../actions\";\nimport { applyUrlState } from \"../store/plugins/urlState\";\n\nexport default function rootReducer(state = {}, action) {\n  switch (action.type) {\n    case REHYDRATE_STATE:\n      return applyUrlState(state);\n    default:\n      return state;\n  }\n}\n"
  },
  {
    "path": "src/reducers/ui.js",
    "content": "import initial from \"../store/initial\";\n\nimport { TOGGLE_TILE_OVERLAY } from \"../actions\";\n\nfunction ui(uiState = initial.ui, action) {\n  switch (action.type) {\n    case TOGGLE_TILE_OVERLAY:\n      return {\n        ...uiState,\n        tiles: {\n          ...uiState.tiles,\n          current:\n            uiState.tiles.current === uiState.tiles.satellite\n              ? uiState.tiles.default\n              : uiState.tiles.satellite,\n        },\n      };\n    default:\n      return uiState;\n  }\n}\n\nexport default ui;\n"
  },
  {
    "path": "src/reducers/validate/associationsSchema.js",
    "content": "import Joi from \"joi\";\n\nconst associationsSchema = Joi.object().keys({\n  id: Joi.string().allow(\"\").required(),\n  title: Joi.string().allow(\"\").required(),\n  desc: Joi.string().allow(\"\"),\n  mode: Joi.string().allow(\"\").required(),\n  filter_paths: Joi.array(),\n});\n\nexport default associationsSchema;\n"
  },
  {
    "path": "src/reducers/validate/eventSchema.js",
    "content": "import Joi from \"joi\";\n\nfunction joiFromCustom(custom) {\n  const output = {};\n  custom.forEach((field) => {\n    if (field.kind === \"text\" || field.kind === \"link\") {\n      output[field.key] = Joi.string().allow(\"\");\n    }\n    if (field.kind === \"list\") {\n      output[field.key] = Joi.array().allow(\"\");\n    }\n  });\n  return output;\n}\n\nfunction createEventSchema(custom) {\n  return Joi.object()\n    .keys({\n      id: Joi.string().allow(\"\"),\n      civId: Joi.string().allow(\"\"),\n      description: Joi.string().allow(\"\").required(),\n      graphic: Joi.string().allow(\"\"),\n      date: Joi.string().allow(\"\"),\n      time: Joi.string().allow(\"\"),\n      time_precision: Joi.string().allow(\"\"),\n\n      /* map */\n      location: Joi.string().allow(\"\"),\n      latitude: Joi.string().allow(\"\"),\n      longitude: Joi.string().allow(\"\"),\n      /* space */\n      x: Joi.string().allow(\"\"),\n      y: Joi.string().allow(\"\"),\n      z: Joi.string().allow(\"\"),\n\n      type: Joi.string().allow(\"\"),\n      category: Joi.string().allow(\"\"),\n      category_full: Joi.string().allow(\"\"),\n      associations: Joi.array().default([]),\n      sources: Joi.array(),\n      comments: Joi.string().allow(\"\"),\n      time_display: Joi.string().allow(\"\"),\n      // nested\n      narrative___stepStyles: Joi.array(),\n      shape: Joi.string().allow(\"\"),\n      colour: Joi.string().allow(\"\"),\n      ...joiFromCustom(custom),\n    })\n    .and(\"latitude\", \"longitude\")\n    .or(\"date\", \"latitude\");\n}\n\nexport default createEventSchema;\n"
  },
  {
    "path": "src/reducers/validate/regionSchema.js",
    "content": "import Joi from \"joi\";\n\nconst regionSchema = Joi.object().keys({\n  name: Joi.string().required(),\n  items: Joi.array().required(),\n});\n\nexport default regionSchema;\n"
  },
  {
    "path": "src/reducers/validate/shapeSchema.js",
    "content": "import Joi from \"joi\";\n\nconst shapeSchema = Joi.object().keys({\n  id: Joi.string().allow(\"\"),\n  title: Joi.string().allow(\"\"),\n  shape: Joi.string().allow(\"\"),\n  colour: Joi.string().allow(\"\"),\n});\n\nexport default shapeSchema;\n"
  },
  {
    "path": "src/reducers/validate/siteSchema.js",
    "content": "import Joi from \"joi\";\n\nconst siteSchema = Joi.object().keys({\n  id: Joi.string().required(),\n  description: Joi.string().allow(\"\").required(),\n  site: Joi.string().required(),\n  latitude: Joi.string().required(),\n  longitude: Joi.string().required(),\n  enabled: Joi.string().allow(\"\"),\n});\n\nexport default siteSchema;\n"
  },
  {
    "path": "src/reducers/validate/sourceSchema.js",
    "content": "import Joi from \"joi\";\n\nconst sourceSchema = Joi.object().keys({\n  id: Joi.string().required(),\n  title: Joi.string().allow(\"\"),\n  thumbnail: Joi.string().allow(\"\"),\n  paths: Joi.array().required(),\n  type: Joi.string().allow(\"\"),\n  affil_s: Joi.array().allow(\"\"),\n  url: Joi.string().allow(\"\"),\n  description: Joi.string().allow(\"\"),\n  parent: Joi.string().allow(\"\"),\n  author: Joi.string().allow(\"\"),\n  date: Joi.string().allow(\"\"),\n  notes: Joi.string().allow(\"\"),\n});\n\nexport default sourceSchema;\n"
  },
  {
    "path": "src/reducers/validate/validators.js",
    "content": "import createEventSchema from \"./eventSchema\";\nimport siteSchema from \"./siteSchema\";\nimport associationsSchema from \"./associationsSchema\";\nimport sourceSchema from \"./sourceSchema\";\nimport regionSchema from \"./regionSchema\";\nimport shapeSchema from \"./shapeSchema\";\n\nimport { calcDatetime, capitalize } from \"../../common/utilities\";\n\n/*\n * Create an error notification object\n * Types: ['error', 'warning', 'good', 'neural']\n */\nfunction makeError(type, id, message) {\n  return {\n    type: \"error\",\n    id,\n    message: `${type} ${id}: ${message}`,\n  };\n}\n\nfunction isValidDate(d) {\n  return d instanceof Date && !isNaN(d);\n}\n\nfunction findDuplicateAssociations(associations) {\n  const seenSet = new Set([]);\n  const duplicates = [];\n  associations.forEach((item) => {\n    if (seenSet.has(item.id)) {\n      duplicates.push({\n        id: item.id,\n        error: makeError(\n          \"Association\",\n          item.id,\n          \"association was found more than once. Ignoring duplicate.\"\n        ),\n      });\n    } else {\n      seenSet.add(item.id);\n    }\n  });\n  return duplicates;\n}\n\n/*\n * Validate domain schema\n */\nexport function validateDomain(domain, features) {\n  const sanitizedDomain = {\n    events: [],\n    sites: [],\n    associations: [],\n    sources: {},\n    regions: [],\n    shapes: [],\n    notifications: domain ? domain.notifications : null,\n  };\n\n  if (domain === undefined) {\n    return sanitizedDomain;\n  }\n\n  const discardedDomain = {\n    events: [],\n    sites: [],\n    associations: [],\n    sources: [],\n    regions: [],\n    shapes: [],\n  };\n\n  function validateArrayItem(item, domainKey, schema) {\n    const result = schema.validate(item);\n    if (result.error != null) {\n      const id = item.id || \"-\";\n      const domainStr = capitalize(domainKey);\n      const error = makeError(domainStr, id, result.error.message);\n\n      discardedDomain[domainKey].push(Object.assign(item, { error }));\n    } else {\n      sanitizedDomain[domainKey].push(item);\n    }\n  }\n\n  function validateArray(items, domainKey, schema) {\n    items.forEach((item) => {\n      if (domainKey === \"events\" && item.date === \"\" && item.time === \"\")\n        return;\n      validateArrayItem(item, domainKey, schema);\n    });\n  }\n\n  function validateObject(obj, domainKey, itemSchema) {\n    Object.keys(obj).forEach((key) => {\n      if (key === \"\") return;\n      const vl = obj[key];\n      const result = itemSchema.validate(vl);\n      if (result.error != null) {\n        const id = vl.id || \"-\";\n        const domainStr = capitalize(domainKey);\n        discardedDomain[domainKey].push({\n          ...vl,\n          error: makeError(domainStr, id, result.error.message),\n        });\n      } else {\n        sanitizedDomain[domainKey][key] = vl;\n      }\n    });\n  }\n\n  if (!Array.isArray(features.CUSTOM_EVENT_FIELDS)) {\n    features.CUSTOM_EVENT_FIELDS = [];\n  }\n\n  const eventSchema = createEventSchema(features.CUSTOM_EVENT_FIELDS);\n  validateArray(domain.events, \"events\", eventSchema);\n  validateArray(domain.sites, \"sites\", siteSchema);\n  validateArray(domain.associations, \"associations\", associationsSchema);\n  validateObject(domain.sources, \"sources\", sourceSchema);\n  validateArray(domain.regions, \"regions\", regionSchema);\n  validateArray(domain.shapes, \"shapes\", shapeSchema);\n\n  // NB: [lat, lon] array is best format for projecting into map\n  sanitizedDomain.regions = sanitizedDomain.regions.map((region) => ({\n    name: region.name,\n    points: region.items.map((coords) => coords.replace(/\\s/g, \"\").split(\",\")),\n  }));\n\n  sanitizedDomain.shapes = sanitizedDomain.shapes.reduce((acc, val) => {\n    if (!val.shape) {\n      discardedDomain.shapes.push({\n        ...val,\n        error: makeError(\n          \"events\",\n          val.id,\n          \"Invalid event shape. Please specify a shape for this type of event.\"\n        ),\n      });\n    } else {\n      acc.push(val);\n    }\n    return acc;\n  }, []);\n\n  const duplicateAssociations = findDuplicateAssociations(domain.associations);\n  // Duplicated associations\n  if (duplicateAssociations.length > 0) {\n    sanitizedDomain.notifications.push({\n      message:\n        \"Associations are required to be unique. Ignoring duplicates for now.\",\n      items: duplicateAssociations,\n      type: \"error\",\n    });\n  }\n  sanitizedDomain.associations = domain.associations;\n\n  // append events with datetime and sort\n  sanitizedDomain.events = sanitizedDomain.events.filter((event, idx) => {\n    let errorMsg = \"\";\n    event.civId = event.id;\n    event.id = idx;\n    // event.associations comes in as a [association.ids...]; convert to actual association objects\n    event.associations = event.associations.reduce((acc, id) => {\n      const foundAssociation = sanitizedDomain.associations.find(\n        (elem) => elem.id === id\n      );\n      if (foundAssociation) acc.push(foundAssociation);\n      return acc;\n    }, []);\n\n    if (event.shape) {\n      const relatedShapeObj = sanitizedDomain.shapes.find(\n        (elem) => elem.id === event.shape\n      );\n      if (!relatedShapeObj)\n        errorMsg =\n          \"Failed to find related shape. Please verify shape type for event.\";\n      else {\n        event.shape = relatedShapeObj;\n      }\n    }\n    // if lat, long come in with commas, replace with decimal format\n    event.latitude = event.latitude.replace(\",\", \".\");\n    event.longitude = event.longitude.replace(\",\", \".\");\n\n    event.datetime = calcDatetime(event.date, event.time);\n    if (!isValidDate(event.datetime))\n      errorMsg =\n        \"Invalid date. It's been dropped, as otherwise timemap won't work as expected.\";\n\n    if (errorMsg) {\n      discardedDomain.events.push({\n        ...event,\n        error: makeError(\"events\", event.id, errorMsg),\n      });\n      return false;\n    }\n    return true;\n  });\n\n  sanitizedDomain.events.sort((a, b) => a.datetime - b.datetime);\n\n  // Message the number of failed items in domain\n  Object.keys(discardedDomain).forEach((disc) => {\n    const len = discardedDomain[disc].length;\n    if (len) {\n      sanitizedDomain.notifications.push({\n        message: `${len} invalid ${disc} not displayed.`,\n        items: discardedDomain[disc],\n        type: \"error\",\n      });\n    }\n  });\n  return sanitizedDomain;\n}\n"
  },
  {
    "path": "src/scss/_burger.scss",
    "content": "// Burger transition\n.side-menu-burg {\n  overflow: hidden;\n  margin: 0;\n  appearance: none;\n  box-shadow: none;\n  border-radius: 0;\n  border: 0;\n  cursor: pointer;\n  background: none;\n  position: relative;\n  width: 18px;\n  height: 18px;\n  padding: 3px;\n  box-sizing: content-box;\n\n  &:before,\n  &:after {\n    content: \" \";\n    position: absolute;\n    right: 50%;\n    right: calc(50% - 1px);\n    top: 3px;\n    width: 2px;\n    height: 18px;\n    background-color: $midwhite;\n  }\n  &:before {\n    transform: rotate(45deg);\n  }\n  &:after {\n    transform: rotate(-45deg);\n  }\n  &:hover:after,\n  &:hover:before {\n    background-color: #fff;\n  }\n\n  &.hidden {\n    display: none;\n  }\n\n  &.is-active {\n  }\n}\n\n.side-menu-burg:focus {\n  outline: none;\n}\n"
  },
  {
    "path": "src/scss/_icons.scss",
    "content": ".icon {\n  display: inline-block;\n  width: 32px;\n  height: 1em;\n  stroke-width: 0;\n  stroke: $offwhite;\n  fill: $offwhite;\n  transform: scale(1.4);\n  cursor: pointer;\n\n  &:hover {\n    transition: 0.2s ease;\n    stroke: $yellow;\n    fill: $yellow;\n  }\n}\n"
  },
  {
    "path": "src/scss/_variables.scss",
    "content": "@font-face {\n  font-family: \"GT-Zirkon\";\n  src: url(../assets/fonts/timemapfont.woff); // a Lato woff by default\n}\n\n$event_default: red;\n\n$offwhite: #efefef;\n$offwhite-transparent: rgba(239, 239, 239, 0.9);\n$lightwhite: #dfdfdf;\n$midwhite: #a0a0a0;\n$darkwhite: darken($midwhite, 15%);\n$yellow: #eb443e; // #ffd800;\n$red: rgb(233, 0, 19);\n$green: rgb(61, 241, 79);\n$midgrey: rgb(44, 44, 44);\n$darkgrey: #232323;\n$black: #000000;\n$active: #7e56c2;\n$black-transparent: rgba(0, 0, 0, 0.7);\n\n// Category colors\n$default: red;\n$alpha: #00ff00;\n$beta: #ff00ff;\n$other: yellow;\n\n.default {\n  background: $default;\n}\n.other {\n  background: $other;\n}\n.alpha {\n  background: $alpha;\n}\n.beta {\n  background: $beta;\n}\n\n$mainfont: \"Roboto\", Helvetica, sans-serif;\n\n// Font sizes\n$xsmall: 10px; //0.7em;\n$small: 11px; //0.9em;\n$normal: 12px; //1em;\n$large: 14px; //1.1em;\n$xlarge: 16px; //1.2em;\n$xxlarge: 20px; //1.4em;\n$xxxlarge: 32px;\n\n// z-index levels\n$final-level: 10000;\n$loading-overlay: 500;\n$overheader: 100;\n$header: 20;\n$map-overlay: 2;\n$map: 1;\n$scene: 1;\n$hidden: -1;\n$timeline: 13;\n\n// platform-specific sizes\n$infopopup-width: 400px;\n$infopopup-left: 122px;\n$infopopup-bottom: 180px;\n$card-width: 500px;\n$card-right: 2px;\n$narrative-info-height: 205px;\n$narrative-info-desc-height: 153px;\n$timeline-height: 130px;\n$toolbar-width: 0px;\n\n$panel-width: 1000px;\n$panel-height: 1000px;\n$vimeo-width: $panel-width - 100;\n$vimeo-height: $panel-height * 0.5;\n$banner-height: 100px;\n$padding: 20px;\n$header-inset: 10px;\n\n// CSS variables (for React access)\n:root {\n  --toolbar-width: 110px;\n  --error-red: #eb443e;\n}\n"
  },
  {
    "path": "src/scss/button.scss",
    "content": ".button {\n  font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n  font-weight: normal;\n  border: 0;\n  border-radius: 0em;\n  cursor: pointer;\n  display: inline-block;\n  line-height: 1;\n  outline: none;\n  text-align: left;\n}\n.button--primary {\n  color: $offwhite;\n  background-color: $default;\n}\n.button--secondary {\n  color: #333;\n  background-color: transparent;\n  box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 2px inset;\n}\n.button--small {\n  font-size: 12px;\n  padding: 10px 16px;\n  margin: 0.6em 0.3em 0 0;\n}\n.button--medium {\n  font-size: 14px;\n  padding: 11px 20px;\n}\n.button--large {\n  font-size: 16px;\n  padding: 12px 24px;\n}\n\n.no-hover {\n  cursor: auto !important;\n}\n"
  },
  {
    "path": "src/scss/card.scss",
    "content": ".event-card {\n  box-sizing: border-box;\n  margin: 0;\n  padding: 15px;\n  transition: 0.2 ease;\n  border: 0;\n  opacity: 1;\n  color: $black;\n  list-style-type: none;\n  transition: background-color 0.4s;\n  text-align: left;\n  overflow-y: auto;\n  height: 100%;\n  max-width: $card-width;\n\n  & + .event-card {\n    border-top: 1px solid #dedede;\n  }\n\n  &:hover {\n    background: $lightwhite;\n    transition: background-color 0.4s;\n    // cursor: pointer;\n  }\n\n  h4 {\n    margin-bottom: 0;\n    margin-right: 5px;\n    text-transform: uppercase;\n    font-size: 0.875rem;\n    font-weight: 400;\n    color: $midwhite;\n    margin-bottom: 3px;\n\n    &:first-child {\n      margin-top: 0;\n    }\n  }\n\n  p {\n    margin: 0;\n  }\n\n  .card-row,\n  .card-col,\n  .card-cell {\n    margin: 5px 3px 5px 0px;\n    &.m0 {\n      margin: 0;\n    }\n  }\n\n  .card-row,\n  .card-col {\n    display: flex;\n    flex-direction: row;\n    justify-content: space-between;\n\n    & > span,\n    .card-cell {\n      flex: 1;\n    }\n\n    @media screen and (max-width: 600px) {\n      flex-wrap: wrap;\n      & > span {\n        display: block;\n        min-width: 50%;\n      }\n    }\n  }\n\n  .card-col {\n    flex-direction: column;\n  }\n\n  .card-source {\n    margin: 0;\n    padding: 2px 0;\n    border-radius: 3px;\n\n    .source-row {\n      display: flex;\n      flex-direction: row;\n      align-items: flex-start;\n      padding: 5px 10px;\n      border-left: 3px solid $darkgrey;\n      background: linear-gradient(to right, $darkgrey 50%, transparent 50%);\n      background-size: 200% 100%;\n      background-position: right bottom;\n\n      &:hover {\n        background-color: $darkgrey;\n        color: white;\n        cursor: pointer;\n\n        background-position: left bottom;\n        transition: all 1s ease-in;\n      }\n    }\n\n    .source-icon {\n      display: flex;\n      align-items: center;\n      font-size: 24px;\n      margin-right: 15px;\n    }\n\n    .source-type {\n      display: inline-block;\n      margin-right: 5px;\n      text-transform: uppercase;\n      font-weight: bold;\n    }\n  }\n\n  .card-cell {\n    font-size: 16px;\n    a {\n      transition: color 0.2s;\n    }\n    a:hover {\n      color: $darkwhite;\n      cursor: pointer;\n      transition: color 0.2s;\n    }\n    a.disabled {\n      color: $midgrey;\n      font-weight: normal;\n      cursor: default;\n    }\n  }\n\n  .card-bottomhalf {\n    transition: 0.4s ease;\n    height: auto;\n\n    &.folded {\n      transition: 0.4s ease;\n      height: 0;\n      overflow: hidden;\n    }\n  }\n\n  .card-toggle p {\n    text-align: center;\n    cursor: pointer;\n\n    .arrow-down {\n      display: inline-block;\n      transition: 0.2s ease;\n      border: solid $darkwhite;\n      border-width: 0 2px 2px 0;\n      padding: 3px;\n      transform: rotate(-135deg);\n      -webkit-transform: rotate(-135deg);\n\n      &.folded {\n        transition: 0.2s ease;\n        transform: rotate(45deg);\n        -webkit-transform: rotate(45deg);\n      }\n    }\n\n    &:hover .arrow-down {\n      transition: 0.2s ease;\n      border: solid $darkgrey;\n      border-width: 0 2px 2px 0;\n    }\n  }\n\n  .filters {\n    width: 100%;\n    margin: 5px 0;\n    text-align: left;\n  }\n\n  .warning {\n    background: $red;\n    color: white;\n    text-transform: uppercase;\n    width: 100%;\n    text-align: center;\n  }\n\n  .timestamp {\n    margin-top: 0;\n\n    .estimated-timestamp {\n      color: $midwhite;\n      margin-left: 5px;\n    }\n  }\n\n  .media {\n    display: flex;\n    flex-direction: column;\n    cursor: pointer;\n\n    .img-wrapper {\n      width: 100%;\n      display: flex;\n      img {\n        max-width: 100%;\n        height: auto;\n        object-fit: cover;\n      }\n    }\n\n    video {\n      width: 100%;\n      padding-bottom: 10px;\n      user-select: none;\n      &:focus {\n        outline: 0 !important;\n      }\n    }\n\n    video::-webkit-media-controls-panel {\n      // remove Chrome's gradient\n      background-image: none !important;\n      filter: brightness(0.9);\n      display: flex;\n      align-self: flex-end;\n      // flex-basis: 35px;\n      background-color: rgba($red, 0.6);\n    }\n\n    /* Could Use thise as well for Individual Controls */\n    video::-webkit-media-controls-play-button {\n      align-self: center;\n    }\n\n    video::-webkit-media-controls-timeline {\n      display: none;\n    }\n  }\n\n  .category {\n    margin-bottom: 5px;\n\n    .color-category {\n      width: 12px;\n      height: 12px;\n      border-radius: 20px;\n      display: inline-block;\n      margin: 0 0 0 5px;\n    }\n\n    p {\n      text-align: right;\n      flex: 1;\n    }\n  }\n\n  .summary {\n    overflow: auto;\n    margin-top: 0;\n    border-bottom: none;\n    white-space: pre-line;\n  }\n\n  .filter {\n    display: inline-block;\n    margin: 0;\n    margin-right: 5px;\n  }\n\n  &.selected {\n    background: $offwhite;\n  }\n\n  .card-row {\n    border-color: darkgray;\n\n    @media screen and (max-width: 600px) {\n      & > span {\n        flex: 1;\n      }\n    }\n  }\n\n  .embedded {\n    // width: calc(#{$card-width} - 50px) !important;\n    width: 100%;\n    max-width: 90vw;\n\n    .twitter-tweet {\n      max-width: 450px !important;\n    }\n  }\n\n  .source-hidden,\n  .source-graphic {\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    text-align: center;\n    // width: calc(#{$card-width} - 35px);\n    border: 2px solid $midgrey;\n    min-height: 260px;\n    background-color: $darkgrey;\n    border-radius: 5px;\n    cursor: auto;\n    h4 {\n      color: white;\n    }\n  }\n\n  details {\n    margin-top: 18px;\n  }\n  /* Styling the Disclosure Widgets */\n  details > summary {\n    cursor: pointer;\n    padding: 0;\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    &:hover {\n      .summary-text {\n        background: rgba($active, 0.3);\n      }\n    }\n    .summary-hide {\n      display: none;\n    }\n    .summary-line {\n      height: 1px;\n      flex: 1;\n      background: #000;\n    }\n    .summary-text {\n      padding: 5px 9px;\n      border-radius: 6px;\n      margin: 0 6px;\n      transition: background 0.3s ease;\n    }\n  }\n\n  details[open] {\n    .summary-hide {\n      display: inline;\n    }\n    .summary-show {\n      display: none;\n    }\n  }\n\n  details > summary > * {\n    display: inline;\n  }\n}\n.media.source-graphic {\n  background-color: darken($red, 26%);\n  h4 {\n    color: $midwhite;\n    transition: font-size 0.3s ease;\n  }\n  h4:hover {\n    font-size: 103%;\n    color: $offwhite;\n    cursor: pointer;\n  }\n}\n"
  },
  {
    "path": "src/scss/cardstack.scss",
    "content": "// @import 'burger';\n@import \"card\";\n\n.card-stack {\n  display: flex;\n  flex-direction: column;\n  position: absolute;\n  top: #{$card-right};\n  right: $card-right;\n  max-height: calc(100% - #{$timeline-height} - 35px);\n  height: auto;\n  width: $card-width;\n  overflow-y: scroll;\n  box-shadow: 0 19px 38px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.22);\n  z-index: $header;\n  color: white;\n  overflow: hidden;\n  max-width: 100vw;\n  background: $offwhite;\n  border-radius: 6px;\n\n  @media screen and (max-width: 600px) {\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    max-height: 100vh;\n  }\n\n  &.narrative-mode {\n    right: $card-right;\n    left: auto;\n    top: $narrative-info-height + 32px;\n    height: calc(100% - #{$narrative-info-height} - #{$timeline-height} - 32px);\n  }\n\n  &.full-height {\n    max-height: calc(100% - 20px);\n  }\n\n  .card-stack-header {\n    position: initial;\n    top: $card-right;\n    width: 100%;\n    max-width: $card-width;\n    box-sizing: border-box;\n    padding: 0 5px;\n    background: $black;\n    border-radius: 2px;\n    border: 1px solid $black;\n    font-size: $large;\n    transition: 0.2s ease;\n    text-align: left;\n    z-index: 20;\n\n    &:hover {\n      transition: 0.2s ease;\n    }\n\n    .header-copy {\n      margin: 0;\n      padding: 0 10px;\n      line-height: 20px;\n      text-align: right;\n\n      &.top {\n        padding-top: 10px;\n      }\n\n      &:last-child {\n        padding-bottom: 10px;\n      }\n    }\n\n    .side-menu-burg {\n      position: absolute;\n      left: 8px;\n      top: 9px;\n\n      span {\n        width: 20px;\n      }\n    }\n  }\n\n  .card-stack-content {\n    flex: 1;\n    max-width: $card-width;\n    overflow: auto;\n    padding-right: 10px;\n    display: block;\n    width: 100%;\n    box-sizing: border-box;\n\n    ul {\n      padding: 0;\n      margin-top: 1px;\n      margin-bottom: 0;\n    }\n\n    .card-list {\n      height: auto;\n    }\n  }\n\n  &.folded {\n    .card-stack-header {\n      border: 0;\n      height: 0;\n      overflow: hidden;\n    }\n    .card-stack-content {\n      height: 0;\n      overflow: hidden;\n    }\n  }\n}\n\nli {\n  list-style-type: none;\n}\n"
  },
  {
    "path": "src/scss/common.scss",
    "content": "@import \"variables\";\n\nhtml {\n  font-family: $mainfont;\n  font-size: 14px;\n  -webkit-font-smoothing: antialiased;\n  @media screen and (max-width: 600px) {\n    font-size: 16px;\n  }\n}\n\nbody {\n  margin: 0;\n  overflow: hidden;\n  background: $black;\n\n  a {\n    text-decoration: none;\n\n    &:hover {\n      color: $yellow;\n    }\n  }\n}\n\nh1 {\n}\n\nh2 {\n  font-size: 1.3rem;\n  font-weight: bold;\n  text-transform: uppercase;\n}\n\np {\n  font-size: 1rem;\n  line-height: 1.5em;\n}\n\n.login-wrapper {\n  margin-left: 20px;\n  color: white;\n\n  .login-title {\n    p.message {\n      color: $yellow;\n    }\n  }\n\n  form span {\n    width: 120px;\n    display: inline-block;\n  }\n\n  form input {\n    margin: 10px 0;\n    height: 30px;\n    box-sizing: border-box;\n    padding: 0 5px;\n    outline: none;\n    font-family: $mainfont;\n\n    &:focus {\n      border: 3px solid $yellow;\n    }\n  }\n\n  form button {\n    background: $black;\n    color: white;\n    width: 120px;\n    height: 30px;\n    border: 1px solid $offwhite;\n    text-transform: uppercase;\n    cursor: pointer;\n    outline: none;\n    margin-top: 10px;\n    margin-left: 320px;\n\n    &:hover,\n    &:focus {\n      transition: 0.2s ease;\n      border: 1px solid $yellow;\n      color: $yellow;\n    }\n  }\n}\n\n.page {\n  font-family: $mainfont;\n  box-sizing: border-box;\n  height: 100%;\n  width: 100%;\n\n  ::-moz-selection {\n    color: $black;\n    background: $yellow;\n  }\n  ::selection {\n    color: $black;\n    background: $yellow;\n  }\n}\n\n.chart {\n  background: #000010;\n}\n\n.primary-action {\n  button {\n    font-size: 1.2em;\n    height: 40px;\n    line-height: 40px;\n    width: 200px;\n    padding: 0;\n    border: 1px solid $offwhite;\n    background: none;\n    color: $offwhite;\n    cursor: pointer;\n    outline: none;\n\n    &:hover {\n      transition: 0.2s ease;\n      color: $yellow;\n      border: 1px solid $yellow;\n      background: rgba(white, 0.1);\n    }\n  }\n}\n\n/*\nScrollbar\n*/\n\n::-webkit-scrollbar {\n  width: 6px;\n}\n\n::-webkit-scrollbar-track {\n  background: none;\n}\n\n::-webkit-scrollbar-thumb {\n  border-radius: 3px;\n  background: $offwhite;\n}\n\n.scrollbar-black {\n  *::-webkit-scrollbar-thumb,\n  &::-webkit-scrollbar-thumb {\n    background: $black;\n  }\n}\n\n.hidden {\n  visibility: hidden;\n}\n"
  },
  {
    "path": "src/scss/cover.scss",
    "content": ".cover-container {\n  position: absolute;\n  top: -100%;\n  left: 0;\n  height: 100vh;\n  background-color: black;\n  width: 100%;\n  opacity: 1;\n  transition: top 0.4s ease;\n  z-index: 2;\n  overflow-y: auto;\n  overflow-x: hidden;\n  color: $offwhite;\n\n  &.showing {\n    top: 0;\n    left: 0;\n    z-index: $loading-overlay + 1;\n  }\n}\n\n.cover-header {\n  position: fixed;\n  bottom: 20px;\n  left: 0;\n  width: 64px;\n  right: 7px;\n  left: auto;\n  top: 7px;\n  bottom: auto;\n  border-radius: 6px;\n  overflow: hidden;\n  max-width: initial;\n  justify-content: center;\n  align-items: center;\n  flex-direction: column;\n  transition: all 0.6s ease;\n  transition-delay: 0.3s;\n\n  &.minimized {\n    top: 78px;\n    right: 7px;\n    transition: all 0.6s ease;\n    transition-delay: 0s;\n    z-index: 10;\n\n    @media screen and (max-width: 600px) {\n      top: 145px;\n    }\n  }\n\n  .cover-logo-container {\n    display: block;\n    padding: 0;\n    img {\n      display: block;\n      width: 100%;\n      height: auto;\n    }\n  }\n}\n\n.fullscreen-bg {\n  &.hidden {\n    top: -100%;\n  }\n\n  position: absolute;\n  top: 0;\n  right: 0;\n  bottom: 0;\n  left: 0;\n  // overflow: hidden;\n  z-index: -100;\n  background: #000000;\n}\n\n.fullscreen-bg__video {\n  position: relative;\n  top: 0;\n  left: -25vw;\n  width: 150vw;\n  height: 100vh;\n  -webkit-filter: contrast(70%) brightness(70%) grayscale(100%);\n  filter: contrast(70%) brightness(70%) grayscale(100%);\n\n  @media only screen and (max-width: 992px) {\n    display: none;\n  }\n}\n\n.default-cover-container {\n  display: flex;\n  justify-content: center;\n  flex-direction: column;\n  align-items: center;\n}\n\n.cover-container {\n  font-size: 12pt;\n  display: flex;\n  flex-direction: column;\n  max-height: 100%;\n\n  hr,\n  br {\n    width: 100%;\n  }\n\n  .sidebar {\n    display: flex;\n    flex-direction: column;\n    justify-content: space-around;\n    align-items: space-around;\n    position: fixed;\n    left: 0;\n    background-color: $offwhite;\n    margin-top: 60px;\n    min-height: calc(100% - 280px);\n    max-height: calc(100% - 280px);\n    min-width: 19%;\n    max-width: 19%;\n    color: black;\n\n    .il-video-pill {\n      display: flex;\n      justify-content: center;\n      align-items: center;\n      text-align: center;\n      flex: 1;\n      background-color: transparent;\n      border-bottom: 5px solid black;\n      transition: all 0.4s ease;\n\n      &.explore {\n        background-color: $yellow;\n      }\n\n      &.videos {\n        background-color: blue;\n      }\n\n      &:hover {\n        cursor: pointer;\n        background-color: $darkwhite;\n        color: white;\n      }\n    }\n  }\n\n  .hero {\n    min-width: 100%;\n    margin: 20px 0 40px;\n    display: flex;\n    flex-direction: column;\n\n    .row {\n      display: flex;\n      flex: 1;\n      flex-direction: row;\n\n      justify-content: space-around;\n\n      &.vertical {\n        flex-direction: column;\n      }\n\n      .cell {\n        border: 1px solid white;\n        display: flex;\n        justify-content: center;\n        align-items: center;\n        text-align: center;\n        flex: 1;\n        background-color: $darkgrey;\n        padding: 10px 0;\n        transition: all 0.4s ease;\n        letter-spacing: 2px;\n        min-height: 40px;\n\n        &.small {\n          letter-spacing: inherit;\n          font-size: 10pt;\n        }\n\n        &.plain {\n          min-height: 10px;\n          background-color: black;\n          letter-spacing: 1px;\n        }\n\n        &.yellow {\n          color: $offwhite;\n          background-color: $yellow;\n        }\n\n        &:hover {\n          cursor: pointer;\n          background-color: $offwhite;\n          color: $yellow;\n          border-color: $yellow;\n        }\n      }\n    }\n  }\n\n  .cover-content {\n    flex-direction: column;\n    max-width: 600px;\n    margin: 0 auto;\n    overflow-y: auto;\n    overflow-x: hidden;\n    padding-bottom: 2em;\n\n    h1,\n    h2,\n    h3,\n    h4,\n    h5 {\n      text-align: center;\n    }\n    h2 {\n      margin: 75px 0 15px;\n    }\n\n    h1 {\n      margin-bottom: -15px;\n      margin-top: 30px;\n    }\n\n    h5 {\n      margin-top: -15px;\n    }\n\n    .md-container {\n      width: 100%;\n      overflow-wrap: break-word;\n      // white-space: pre-line;\n\n      ul {\n        list-style: none;\n      }\n\n      li::before {\n        content: \"* \";\n      }\n\n      p {\n        text-align: justify;\n        font-size: 1.2rem;\n        line-height: 1.65em;\n      }\n    }\n\n    // mobile styles, remove overlay buttons\n    @media only screen and (max-width: 1200px) {\n      font-size: 22pt !important;\n      max-width: 100vw;\n      padding: 0 40px 80px 40px;\n      margin-bottom: 0;\n    }\n\n    .verify-tabs {\n      background-color: $yellow;\n      color: black;\n      display: flex;\n      flex-direction: column;\n\n      .v-tab {\n        display: flex;\n        margin: auto;\n        justify-content: center;\n        align-content: center;\n        flex: 1;\n      }\n    }\n\n    .il-cover-verification-container {\n      display: flex;\n      flex-direction: column;\n\n      .il-cover-verification {\n        .il-video {\n          border-radius: 1em;\n          background-color: rgba(240, 240, 240, 0.5);\n        }\n      }\n    }\n  }\n\n  _::-webkit-full-page-media,\n  _:future,\n  :root .cover-content {\n    max-width: auto;\n  }\n}\n\n.cover-footer {\n  &.disabled {\n    display: none;\n  }\n\n  position: fixed;\n  bottom: 0;\n  min-height: 150px;\n  min-width: 100%;\n  padding: 10px;\n  background-color: black;\n  display: flex;\n  justify-content: center;\n\n  .il-cover-button {\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    min-width: 300px;\n    max-height: 80px;\n    margin-top: 30px;\n    background-color: $offwhite;\n    color: black;\n    transition: all 0.3s ease;\n\n    &:hover {\n      cursor: pointer;\n      background-color: darken($offwhite, 30%);\n      color: black;\n    }\n  }\n}\n"
  },
  {
    "path": "src/scss/header.scss",
    "content": ".header {\n  background: #000000;\n  position: fixed;\n  padding: 10px;\n  z-index: 10;\n  top: 10px;\n  right: 10px;\n  height: 40px;\n  width: 240px;\n  box-sizing: border-box;\n  text-overflow: ellipsis;\n  box-shadow: 0 19px 38px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.22);\n  cursor: pointer;\n\n  .header-title {\n    a {\n      color: darken($offwhite, 5%);\n      font-size: $xlarge;\n      letter-spacing: 0.1em;\n      float: left;\n      text-transform: uppercase;\n    }\n\n    p {\n      margin: 0;\n    }\n  }\n\n  .side-menu-burg {\n    right: 10px;\n    span,\n    span::before,\n    span::after {\n      background: $midwhite;\n    }\n  }\n\n  &:hover {\n    .side-menu-burg {\n      span {\n        transition: 0.2s ease;\n        background: $offwhite;\n      }\n      span::before {\n        transition: 0.2s ease;\n        top: -6px;\n        background: $offwhite;\n      }\n\n      span::after {\n        transition: 0.2s ease;\n        bottom: -6px;\n        background: $offwhite;\n      }\n    }\n    .header-title a {\n      transition: 0.2s ease;\n      color: $offwhite;\n    }\n  }\n}\n"
  },
  {
    "path": "src/scss/infopopup.scss",
    "content": "@import \"burger\";\n\n.infopopup {\n  display: block;\n  position: absolute;\n  width: 600px;\n  max-width: calc(min(60vw, 100%));\n  color: $darkgrey;\n  background: $offwhite-transparent;\n  top: 50%;\n  left: 50%;\n  transform: translate(-50%, -50%);\n  border: 3px solid $offwhite;\n  border-radius: 1px;\n  padding: 20px 15px 15px;\n  box-sizing: border-box;\n  font-size: $large;\n  transition: opacity 0.5s ease 0.1s, z-index 0.1s ease 0s;\n  opacity: 1;\n  z-index: $overheader;\n  border: 2px solid $midwhite;\n  border-radius: 6px;\n  box-shadow: 10px -10px 38px rgba(0, 0, 0, 0.3),\n    10px 15px 12px rgba(0, 0, 0, 0.22);\n\n  &__bg {\n    background-color: rgba(0, 0, 0, 0.4);\n    position: fixed;\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    z-index: 100;\n    cursor: pointer;\n    &.hidden {\n      display: none;\n    }\n  }\n\n  @media screen and (max-width: 600px) {\n    font-size: 18px;\n    width: 98vw;\n    max-width: none;\n    max-height: 80vh;\n    background: rgba(0, 0, 0, 0.95);\n    overflow: auto;\n    figcaption {\n      overflow-wrap: break-word;\n      font-size: 0.75rem;\n    }\n  }\n  p:nth-last-child(1) {\n    margin-bottom: 0;\n  }\n\n  &.hidden {\n    transition: 0.5s ease;\n    opacity: 0;\n  }\n\n  .two-columns {\n    display: flex;\n    flex-direction: row;\n    max-width: 100%;\n    overflow: hidden;\n    margin-top: 20px;\n    gap: 20px;\n    justify-content: space-between;\n    align-items: flex-start;\n    &_column {\n      flex: 1;\n\n      figure {\n        margin: 0;\n      }\n      figcaption {\n        margin-top: 6px;\n        text-align: left;\n        color: $midwhite;\n      }\n      img {\n        border-radius: 9px;\n      }\n    }\n  }\n\n  .side-menu-burg {\n    position: absolute;\n    right: 6px;\n    top: 6px;\n    &.light {\n      &.is-active span:after,\n      &.is-active span:before {\n        background: black;\n      }\n    }\n  }\n\n  &.dark {\n    // background: $black-transparent;\n    background: rgba(0, 0, 0, 0.8);\n    color: white;\n    @media screen and (max-width: 600px) {\n      background: rgba(0, 0, 0, 0.9);\n    }\n  }\n\n  iframe {\n    flex: 1;\n    width: 100%;\n    min-height: 400px;\n  }\n\n  @media (max-height: 1000px) {\n    iframe {\n      min-height: 230px;\n    }\n  }\n\n  &.mobile {\n    border: none;\n    padding: 5vmin;\n    .side-menu-burg {\n      display: none;\n    }\n  }\n\n  .legend {\n    display: flex;\n    flex-direction: column;\n  }\n\n  .legend-header {\n    h2 {\n      width: 100%;\n      margin: 0;\n      text-align: center;\n      margin-top:15px;\n    }\n  }\n\n  .legend-container {\n    height: 100%;\n    display: flex;\n    flex-direction: row;\n\n    .legend-item {\n      display: flex;\n      justify-content: center;\n      align-items: center;\n      &.one {\n        flex: 1;\n      }\n      &.three {\n        flex: 5;\n      }\n    }\n  }\n\n  .legend-section {\n    height: 25px;\n    display: flex;\n    align-items: center;\n\n    svg {\n      width: 60px;\n      float: left;\n      display: inline-block;\n    }\n\n    .legend-labels {\n      display: flex;\n    }\n  }\n}\n"
  },
  {
    "path": "src/scss/loading.scss",
    "content": ".loading-overlay {\n  font-weight: 300;\n  width: 100%;\n  height: 100%;\n  position: absolute;\n  background: rgba(0, 0, 0, 0.9);\n  transition: 0.4s ease;\n  z-index: $loading-overlay;\n  opacity: 1;\n\n  .loading-wrapper {\n    position: fixed;\n    left: 50%;\n    top: 40%;\n    text-align: center;\n    width: 100%;\n    margin: 0 0 0 -50%;\n    height: 100%;\n    opacity: 1;\n\n    span {\n      color: $offwhite;\n      letter-spacing: 0.1em;\n      text-transform: uppercase;\n    }\n  }\n\n  &.hidden {\n    transition: opacity 0.4s ease, z-index 0.1s 0.4s;\n    opacity: 0;\n    z-index: $hidden;\n  }\n}\n\n/*\nhttps://github.com/tobiasahlin/SpinKit/blob/master/LICENSE\n*/\n.spinner {\n  width: 40px;\n  height: 40px;\n\n  position: relative;\n  margin: 10px auto;\n\n  &.small {\n    width: 15px;\n    height: 15px;\n    margin: 5px 20px 5px 10px;\n  }\n}\n\n.double-bounce,\n.double-bounce-overlay {\n  width: 100%;\n  height: 100%;\n  border-radius: 50%;\n  background-color: $offwhite;\n  opacity: 0.6;\n  position: absolute;\n  top: 0;\n  left: 0;\n\n  -webkit-animation: sk-bounce 3s infinite ease-in-out;\n  animation: sk-bounce 3s infinite ease-in-out;\n}\n\n.double-bounce-overlay {\n  -webkit-animation-delay: -1s;\n  animation-delay: -1s;\n  background-color: black;\n}\n\n@-webkit-keyframes sk-bounce {\n  0%,\n  100% {\n    -webkit-transform: scale(0.3);\n  }\n  50% {\n    -webkit-transform: scale(1);\n  }\n}\n\n@keyframes sk-bounce {\n  0%,\n  100% {\n    transform: scale(0.3);\n    -webkit-transform: scale(0.3);\n  }\n  50% {\n    transform: scale(1);\n    -webkit-transform: scale(1);\n  }\n}\n\n.fixedTooSmallMessage {\n  position: absolute;\n  top: 0;\n  color: white;\n  padding: 10px;\n}\n"
  },
  {
    "path": "src/scss/main.scss",
    "content": "@import \"variables\";\n@import \"common\";\n@import \"loading\";\n@import \"header\";\n@import \"cardstack\";\n@import \"narrativecard\";\n@import \"overlay\";\n@import \"map\";\n@import \"timeline\";\n@import \"toolbar\";\n@import \"infopopup\";\n@import \"notification\";\n@import \"mediaplayer\";\n@import \"cover\";\n@import \"search\";\n@import \"satelliteoverlaytoggle\";\n"
  },
  {
    "path": "src/scss/map.scss",
    "content": "@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    opacity: 0.1;\n  }\n}\n\n.map-wrapper {\n  position: fixed;\n  top: 0px;\n  bottom: 0px;\n  left: 0;\n  right: 0;\n\n  &.mobile {\n    left: 0px;\n  }\n\n  .leaflet-container {\n    height: 100%;\n  }\n\n  &.hidden {\n    z-index: $hidden;\n  }\n\n  &.show {\n    z-index: $map;\n  }\n\n  &.narrative-mode {\n    left: 0;\n  }\n\n  .event {\n    fill: $event_default;\n    cursor: pointer;\n    opacity: 0.45;\n  }\n\n  .link {\n    stroke: $midgrey;\n    fill: none;\n    stroke-width: 2;\n    stroke-dasharray: 2px 5px;\n  }\n\n  .site-label {\n    background: rgba($black, 0.6);\n    color: #fff;\n    padding: 5px;\n    font-weight: 500;\n    font-size: 11px;\n    border: rgba($black, 0.6);\n    letter-spacing: 0.05em;\n\n    &::before {\n      border-top-color: rgba($black, 0.6);\n    }\n  }\n\n  .sites-layer,\n  .shapes-layer {\n    position: fixed;\n    top: 0px;\n    left: 110px;\n  }\n\n  &.narrative-mode {\n    .sites-layer,\n    .shapes-layer {\n      position: fixed;\n      top: 0px;\n      left: 0px;\n    }\n  }\n}\n\n/*\n* Leaflet mapping controls\n*/\n.leaflet-touch .leaflet-bar {\n  .leaflet-control-zoom {\n    border: 0;\n    margin-left: 20px;\n    margin-top: 20px;\n  }\n\n  a.leaflet-control-zoom-in,\n  a.leaflet-control-zoom-out {\n    border: 0;\n    border-radius: 2px;\n    color: $yellow;\n  }\n\n  a.leaflet-control-zoom-in {\n    border-bottom: 1px solid $yellow;\n  }\n}\n\n/*\n* Leaflet marker and popups\n*/\n\n.leaflet-svg {\n  display: block;\n\n  &.hide {\n    display: none;\n  }\n\n  &:focus {\n    outline: none;\n  }\n}\n\n.leaflet-popup {\n  display: none;\n\n  &.do-display {\n    display: block;\n  }\n}\n\n.leaflet-popup-content-wrapper {\n  border-radius: 3px;\n  background: $black;\n\n  .leaflet-popup-content {\n    color: white;\n    margin: 0;\n    padding: 3px 5px;\n\n    .event-card {\n      margin: 0;\n    }\n  }\n}\n\n.leaflet-popup-close-button {\n  display: none;\n\n  & + .leaflet-popup-content-wrapper .leaflet-popup-content {\n    padding-top: 3px;\n  }\n}\n\n.leaflet-popup-tip-container {\n  display: none;\n}\n\n.leaflet-pane > svg path.bus-route,\n.leaflet-pane > svg path.district {\n  pointer-events: auto;\n}\n\n.eventLocationMarker {\n  fill: none;\n  stroke: $yellow;\n  stroke-width: 2;\n}\n\n.leaflet-tile {\n  // filter: brightness(110%) invert(100%) grayscale(800%) contrast(80%);\n}\n\n/*\n*\n* Elements\n*/\n\n.event-hover {\n  opacity: 0;\n}\n\n.event-hover:hover {\n  opacity: 1;\n}\n\n.narrative-mode {\n}\n\n.location-event {\n  cursor: pointer;\n}\n\n.cluster-event {\n  cursor: pointer;\n}\n\n.location-event-marker {\n  pointer-events: all !important;\n  fill: $event_default;\n  stroke-width: 0;\n\n  &.blue {\n    fill: blue;\n  }\n}\n\n.cluster-event-marker {\n  pointer-events: all !important;\n\n  &.red {\n    fill: red;\n  }\n}\n\n.narrative-step-arrow {\n  pointer-events: all !important;\n}\n\n.path-polyline {\n  stroke: $darkgrey;\n  stroke-width: 2px;\n}\n\n.no-hover {\n  cursor: grab;\n}\n\n// no hover styles for events when in narrative mode\n.narrative-mode {\n  .event-hover:hover {\n    opacity: 0;\n  }\n\n  .no-hover {\n    cursor: inherit;\n  }\n}\n"
  },
  {
    "path": "src/scss/mediaplayer.scss",
    "content": "@import \"video-react/dist/video-react.css\";\n"
  },
  {
    "path": "src/scss/narrativecard.scss",
    "content": "/*\nNARRATIVE INFO\n*/\n.narrative-info {\n  position: fixed;\n  top: 30px;\n  left: auto;\n  right: $card-right; // looks a bit better due to the 1px border on other elements.\n  width: $card-width;\n  box-sizing: border-box;\n  max-height: calc(100% - 250px);\n  box-shadow: 0 19px 38px rgba($black, 0.3), 0 15px 12px rgba($black, 0.22);\n  background: $black;\n  color: $offwhite;\n\n  .narrative-info-header {\n    display: flex;\n    justify-content: space-between;\n    align-items: stretch;\n    border-bottom: 1px solid $darkwhite;\n    padding: 0 15px;\n\n    .count-container {\n      display: flex;\n      justify-content: center;\n      align-items: center;\n      border-right: 1px solid $darkwhite;\n    }\n    .count {\n      position: relative;\n      padding-right: 15px;\n    }\n  }\n\n  .narrative-info-desc {\n    max-height: $narrative-info-desc-height;\n    overflow-y: auto;\n    white-space: pre-line;\n    padding-bottom: 5px;\n  }\n\n  p {\n    padding: 0 15px 15px 15px;\n  }\n\n  h3,\n  h6 {\n    text-align: center;\n  }\n\n  h3 {\n    font-size: $large;\n    letter-spacing: 0.1em;\n    text-transform: uppercase;\n    font-weight: 100;\n  }\n\n  h6 {\n    margin: 10px 0;\n    i {\n      font-size: $normal;\n    }\n  }\n\n  p {\n    font-size: $normal;\n    line-height: 1.4em;\n  }\n\n  .actions {\n    width: 100%;\n    .action {\n      width: calc(50% - 5px);\n      height: 40px;\n      box-sizing: border-box;\n      line-height: 40px;\n      text-align: center;\n      display: inline-block;\n\n      &:not(.disabled) {\n        &:hover {\n          cursor: pointer;\n          transition: 0.2s ease;\n          color: $yellow;\n        }\n      }\n\n      &.disabled {\n        color: $midgrey;\n        cursor: normal;\n      }\n\n      &:first-child {\n        margin-right: 10px;\n      }\n    }\n  }\n}\n\n.narrative-adjust {\n  position: fixed;\n  bottom: $timeline-height;\n  right: auto;\n  background-color: rgba(0, 0, 0, 0.8);\n  z-index: $header;\n\n  &.left {\n    right: $card-right + $card-width - 40pt;\n  }\n\n  &.right {\n    right: $card-right;\n  }\n\n  .material-icons {\n    font-size: 40pt;\n    color: $offwhite;\n    transition: color 0.2s ease;\n\n    &.disabled {\n      display: none;\n    }\n\n    &:hover {\n      cursor: pointer;\n      color: $midwhite;\n    }\n  }\n}\n\n.narrative-close {\n  display: flex;\n  justify-content: flex-start;\n  position: fixed;\n  padding: 2px 5px 0 5px;\n  right: $card-right;\n  top: 5px;\n  width: $card-width - 12px; // subtracting the extra width added by padding\n  // width: 15px;\n  background-color: black;\n  height: 20px;\n  transition: background-color 0.2s ease;\n  border: 1px solid black;\n\n  button {\n    height: 15px;\n    width: 15px;\n  }\n\n  .close-text {\n    display: none;\n    color: $midgrey;\n    flex: 1;\n    width: 100%;\n    justify-content: center;\n    font-size: 10pt;\n  }\n\n  // disable whitening of crosshair on hover\n  button {\n    span,\n    span:before,\n    span:after {\n      background: $midwhite !important;\n    }\n  }\n\n  &:hover {\n    cursor: pointer;\n    background-color: $offwhite;\n    color: black;\n    .close-text {\n      display: flex;\n    }\n  }\n}\n"
  },
  {
    "path": "src/scss/notification.scss",
    "content": "@import \"burger\";\n\n.notification-wrapper {\n  top: 60px;\n  right: 60px;\n  width: 400px;\n  height: auto;\n  position: absolute;\n  display: flex;\n  flex-direction: column;\n}\n\n.notification {\n  width: 100%;\n  min-height: 40px;\n  box-shadow: 10px -10px 38px rgba(0, 0, 0, 0.3),\n    10px 15px 12px rgba(0, 0, 0, 0.22);\n  color: $darkgrey;\n  background: $offwhite;\n  border-radius: 5px;\n  border: 3px solid $offwhite;\n  padding: 20px;\n  margin-bottom: 10px;\n  box-sizing: border-box;\n  font-size: $large;\n  transition: opacity 0.5s ease 0.1s, z-index 0.1s ease 0s;\n  opacity: 1;\n  z-index: $overheader;\n  cursor: pointer;\n\n  &:hover {\n    background: lighten($offwhite, 5%);\n    transition: background-color 0.4s;\n  }\n\n  &.hidden {\n    transition: 0.5s ease;\n    opacity: 0;\n  }\n\n  .side-menu-burg {\n    position: absolute;\n    right: 8px;\n    top: 10px;\n  }\n\n  .message {\n    display: inline-block;\n\n    &.error {\n      color: red;\n    }\n    &.warning {\n      color: orange;\n    }\n    &.good {\n      color: green;\n    }\n    &.neutral {\n      color: $darkgrey;\n    }\n  }\n\n  .details {\n    overflow: hidden;\n    display: flex;\n    flex-direction: column;\n    border-radius: 3px;\n    margin-top: 10px;\n    padding: 10px;\n    background: $darkgrey;\n    color: $offwhite;\n    font-family: monospace;\n\n    &.true {\n      height: auto;\n      transition: height 0.4s, margin 0.4s;\n    }\n\n    &.false {\n      height: 0;\n      padding: 0;\n      margin: 0;\n      transition: height 0.4s, margin 0.4s;\n    }\n  }\n}\n"
  },
  {
    "path": "src/scss/overlay.scss",
    "content": "a {\n  color: $yellow !important;\n}\n\n.mo-overlay {\n  display: flex;\n  flex-direction: column;\n  // justify-content: center;\n  align-items: center;\n  position: fixed;\n  top: 0;\n  left: 0;\n  width: 100vw;\n  height: 100vh;\n  background-color: rgba(0, 0, 0, 0.85);\n  &.opaque {\n    background-color: black;\n  }\n  z-index: 20;\n}\n\n.mo-container {\n  margin-top: $banner-height;\n  background-color: transparent;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  width: 60vw;\n  max-width: 1500px;\n  box-shadow: 0 19px 19px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.22);\n  overflow: auto;\n  z-index: $overheader;\n}\n\n$overlay-bg: rgba(239, 239, 239, 0.9);\n\n.mo-banner {\n  position: fixed;\n  min-height: 100px;\n  color: $offwhite;\n  background-color: transparent;\n  top: 0;\n  width: 100%;\n  display: flex;\n  justify-content: center;\n  align-items: stretch;\n  flex-direction: row;\n\n  .mo-banner-close {\n    position: fixed;\n    top: 20px;\n    left: 20px;\n    min-width: $banner-height;\n    width: $banner-height;\n    .material-icons {\n      font-size: 40pt;\n      background-color: transparent;\n      display: flex;\n      justify-content: center;\n      align-items: center;\n      transition: 0.3s all ease;\n      color: $overlay-bg;\n\n      &:hover {\n        text-decoration: none;\n        cursor: pointer;\n        color: white;\n      }\n    }\n  }\n\n  .mo-banner-content {\n    text-align: center;\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    &.h3 {\n      border-radius: 2px;\n      padding: 10px 15px;\n      background-color: transparent;\n      color: $overlay-bg;\n    }\n  }\n}\n\n.banner-trans {\n  display: flex;\n  flex-direction: row;\n  justify-content: flex-start;\n  align-items: center;\n  min-width: 2 * $banner-height;\n  width: 2 * $banner-height;\n\n  .trans-button {\n    padding: 15px;\n    margin: 10px;\n    border: 1px solid $darkwhite;\n    transition: 0.1s all ease;\n    &:hover {\n      background-color: $darkwhite;\n      cursor: pointer;\n    }\n  }\n\n  &.right-overlay {\n    position: relative;\n    width: 25%;\n    float: right;\n    justify-content: flex-end;\n    z-index: $map;\n    .trans-button {\n      background-color: $black;\n      &:hover {\n        background-color: $darkwhite;\n      }\n    }\n  }\n}\n\n.media-gallery-controls {\n  height: 100%;\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-top: -50%;\n\n  .back,\n  .next {\n    position: fixed;\n    bottom: 0;\n    height: 170px;\n    background: transparent;\n    color: $offwhite;\n    cursor: pointer;\n    box-shadow: 0 19px 19px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.22);\n    svg path {\n      fill: $offwhite;\n    }\n    z-index: 1;\n  }\n\n  .centerer {\n    width: 100%;\n    height: 100%;\n    display: flex;\n    justify-content: center;\n    align-items: center;\n  }\n\n  .material-icons {\n    font-size: 40pt;\n  }\n\n  .back {\n    left: 10px;\n    svg path {\n      transform: translate(17px, 15px) rotate(-90deg);\n    }\n  }\n  .next {\n    margin-left: calc(100% - 60px);\n    right: 10px;\n    svg path {\n      transform: translate(17px, 15px) rotate(90deg);\n    }\n  }\n}\n\n.mo-media-container {\n  flex: 1;\n  flex-direction: row;\n  justify-content: center;\n  display: inline-block;\n  overflow: hidden;\n  box-sizing: border-box;\n  width: 100%;\n  max-height: calc(#{$panel-height} - 100px);\n\n  .media-content {\n    display: flex;\n    flex-direction: column;\n  }\n\n  // NB: topcushion seems to be necessary with certain overflows..\n  &.topcushion {\n    padding-top: 150px;\n  }\n}\n\n.mo-footer {\n  position: fixed;\n  // height: 250px;\n  background-color: transparent;\n  width: 100%;\n  opacity: 0.9;\n  bottom: 20px;\n  display: flex;\n  justify-content: center;\n}\n\n.mo-meta-container {\n  color: $overlay-bg;\n  background-color: rgba(0, 0, 0, 0.5);\n  border-radius: 2em;\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n  box-sizing: border-box;\n  min-height: 100px;\n\n  .mo-box-desc {\n    display: flex;\n    flex-direction: row;\n    justify-content: space-between;\n    padding: 0 20px;\n    max-width: $panel-width;\n  }\n\n  .mo-box {\n    display: flex;\n    flex-direction: row;\n    justify-content: space-around;\n    min-width: 800px;\n    max-width: $panel-width;\n    padding: $padding 0;\n    border-top: 1px solid rgb(189, 189, 189);\n    font-size: $normal;\n\n    h4 {\n      margin: 0 0 5px 0;\n      text-transform: uppercase;\n      font-size: $xsmall;\n      color: $darkwhite;\n      font-weight: 100;\n    }\n\n    p {\n      margin-top: 0;\n      font-size: $large;\n    }\n\n    .material-icons {\n      font-size: $normal;\n      color: $darkwhite;\n      margin-right: 5px;\n    }\n\n    a {\n      font-size: $large;\n      color: $yellow;\n      border-bottom: 1px solid $yellow;\n    }\n  }\n\n  .indent {\n    margin-left: 2 * $header-inset;\n  }\n}\n\n/* source overlay specific styles */\n.no-source-container {\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n  border: 1px solid black;\n  padding: 2em;\n  min-height: 200px;\n}\n\n.no-source-row {\n  p {\n    text-align: center;\n    color: $midgrey;\n\n    .no-source-icon {\n      font-size: $xxxlarge;\n      color: $darkwhite;\n    }\n  }\n}\n\n.source-media-gallery {\n  display: flex;\n  flex-direction: row;\n  height: 100%;\n  width: 100%;\n  margin: 0;\n  transition: transform 0.2s ease;\n}\n\n.source-text-container {\n  display: flex;\n  justify-content: center;\n  box-sizing: border-box;\n  padding: 0 calc(50% - 400px);\n  overflow-y: scroll;\n  line-height: 1.5em;\n  min-width: 100%;\n  margin-bottom: 120px;\n\n  color: $overlay-bg;\n\n  a {\n    color: $yellow;\n    border-bottom: 1px solid $yellow;\n  }\n\n  .md-container {\n    width: 100%;\n    overflow-wrap: break-word;\n    white-space: pre-line;\n  }\n}\n\n.source-image-container,\n.media-player {\n  display: flex;\n  justify-content: center;\n  padding: 20px;\n  min-width: calc(100% - 40px);\n  z-index: $final-level;\n  max-height: 60vh;\n}\n\n.media-player {\n  background-color: transparent;\n  box-sizing: border-box;\n  width: 100%;\n  min-width: 100%;\n  height: 100%;\n  min-height: 100%;\n  display: block;\n}\n\n.source-image,\n.source-video {\n  padding: 1px;\n  max-height: 100%;\n  margin: auto;\n  width: auto;\n  height: auto;\n  max-width: 100%;\n  object-fit: contain;\n  &:hover {\n    cursor: pointer;\n    background-color: $yellow;\n  }\n}\n\n.source-image-loader {\n  width: 400px;\n  height: 400px;\n}\n\n.source-document {\n  width: 100%;\n  min-height: 80vh;\n}\n\n.video-react .video-react-progress-control {\n  align-self: center;\n}\n\n.video-react .video-react-control {\n  min-height: 100%;\n}\n\n// full-screen overloads\n.mo-overlay.full-screen {\n  background-color: black;\n  .mo-container {\n    background-color: transparent;\n  }\n  .mo-media-container {\n    border-top: 1px solid $offwhite;\n    border-bottom: 1px solid $offwhite;\n  }\n  .mo-box {\n    border-color: transparent;\n  }\n}\n"
  },
  {
    "path": "src/scss/popup.scss",
    "content": ".popup {\n  box-sizing: border-box;\n  margin: 0;\n  padding: 15px;\n  border: 0;\n  opacity: 0;\n  border-radius: 2px;\n  transition: 0.2 ease;\n  background: rgba(0, 0, 0, 0.9);\n  transition: 0.4s ease;\n  box-shadow: 0 19px 38px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.22);\n\n  &:hover {\n    transition: 0.4s ease;\n    box-shadow: 0 29px 38px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.22);\n  }\n\n  .card-tophalf {\n    height: 100px;\n\n    .left {\n      float: left;\n      width: 120px;\n      padding-right: 5px;\n      box-sizing: border-box;\n      border-right: 1px dotted $midwhite;\n    }\n    .right {\n      float: left;\n      width: 225px;\n      padding-left: 5px;\n      height: 90px;\n      overflow: hidden;\n    }\n  }\n\n  .filter,\n  p.see-more {\n    cursor: pointer;\n\n    &:hover {\n      color: $yellow;\n    }\n  }\n\n  p {\n    margin: 5px 0 0 0;\n  }\n\n  .timestamp {\n    text-transform: uppercase;\n    font-size: $xlarge;\n    margin-top: 0;\n  }\n\n  .location {\n    font-size: $normal;\n    color: $offwhite;\n  }\n\n  .estimated-timestamp {\n    margin-top: 3px;\n    margin-left: 3px;\n    font-size: $xsmall;\n    color: $midwhite;\n    text-transform: lowercase;\n  }\n\n  .summary {\n    max-height: 200px;\n    text-overflow: ellipsis;\n    overflow: scroll;\n    font-weight: 500;\n  }\n\n  .source {\n    text-align: right;\n  }\n}\n"
  },
  {
    "path": "src/scss/satelliteoverlaytoggle.scss",
    "content": "@import \"variables\";\n\n.satellite-overlay-toggle {\n  position: fixed;\n  top: 0.5em;\n  right: 0.5em;\n  z-index: $map-overlay;\n  border-radius: 6px;\n  overflow: hidden;\n\n  @media screen and (max-width: 600px) {\n    top: 75px;\n  }\n\n  .satellite-overlay-toggle-button {\n    cursor: pointer;\n    width: 64px;\n    height: 64px;\n    opacity: 0.85;\n    box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);\n    border: none;\n    color: white;\n    user-select: none;\n    display: flex;\n    justify-content: center;\n    align-items: flex-end;\n    padding-bottom: 0.5em;\n    text-transform: uppercase;\n\n    &.satellite-overlay-toggle-map {\n      color: black;\n    }\n\n    &:hover {\n      opacity: 1;\n    }\n\n    .label {\n      font-size: $normal;\n      font-family: $mainfont;\n    }\n  }\n}\n"
  },
  {
    "path": "src/scss/search.scss",
    "content": "#search-bar-icon-container {\n  position: absolute;\n  background-color: black;\n  color: #a0a0a0;\n  border: #a0a0a0 solid 0.1px;\n  top: 10px;\n  margin-left: 10px;\n  height: 24px;\n  padding: 10px;\n  &:hover {\n    cursor: pointer;\n    color: white;\n  }\n}\n\n.search-bar-overlay {\n  background-color: black;\n  height: 100vh;\n  width: 400px;\n  position: absolute;\n  transition: 0.2s ease;\n}\n\n.search-bar-input {\n  width: 300px;\n  margin: 20px;\n  line-height: 40px;\n  font-size: 15px;\n  color: gray;\n  padding-left: 15px;\n  background: black;\n  border: 1px solid #a0a0a0;\n  &:focus {\n    outline: none;\n  }\n}\n\n#close-search-overlay {\n  color: #a0a0a0;\n  vertical-align: middle;\n  font-size: 30px;\n  transition: 0.2s ease;\n  &:hover {\n    color: white;\n    cursor: pointer;\n  }\n}\n\n.search-outer-container {\n  position: absolute;\n  left: 110px;\n  &.narrative-mode {\n    left: 0;\n  }\n}\n\n.search-row {\n  color: black;\n  padding-left: 15px;\n  padding-right: 15px;\n  padding-top: 10px;\n  padding-bottom: 10px;\n  background-color: #dfdfdf;\n  transition: background-color 0.4s;\n  border-bottom: 1px black solid;\n  border-top: 1px black solid;\n  font-size: 14px;\n  opacity: 0.9;\n  &:hover {\n    transition: background-color 0.4s;\n    background-color: white;\n    cursor: pointer;\n  }\n}\n\n.search-row > p {\n  margin: 0;\n}\n\n.search-results {\n  height: calc(100% - 332px);\n  overflow: auto;\n}\n\ndiv.location-date-container {\n  margin-top: 10px;\n  margin-bottom: 10px;\n}\n\ndiv.location-date-container > div {\n  width: 50%;\n  display: inline-block;\n  vertical-align: top;\n}\n\ndiv.location-date-container > div > p {\n  display: inline;\n  line-height: 17px;\n  vertical-align: top;\n}\n\ndiv.location-date-container > div > i {\n  font-size: 12px;\n  margin-right: 5px;\n}\n"
  },
  {
    "path": "src/scss/tabs.scss",
    "content": ".react-tabs {\n  padding-top: 0;\n  box-sizing: border-box;\n\n  [role=\"tablist\"] {\n    padding: 0;\n  }\n\n  [role=\"tab\"] {\n    font-size: $xlarge;\n    width: 33%;\n    background: none;\n    color: $midwhite;\n    outline: none;\n    float: left;\n    cursor: pointer;\n    text-align: center;\n    height: 40px;\n    line-height: 40px;\n    border-bottom: 1px solid rgba(255, 255, 255, 0.4);\n    list-style-type: none;\n    box-sizing: border-box;\n    &:hover {\n      color: $offwhite;\n    }\n  }\n\n  [role=\"tab\"][aria-selected=\"true\"] {\n    font-weight: 700;\n    border-radius: 0;\n    border: 0;\n    color: $offwhite;\n    border: 1px solid;\n    box-sizing: border-box;\n    text-align: center;\n    border: 1px solid rgba(255, 255, 255, 0.4);\n    border-bottom: 0;\n  }\n\n  .react-innertabpanel {\n    box-sizing: border-box;\n    padding-top: 0;\n\n    hr {\n      border-top: 0;\n      border-bottom: 1px solid $midwhite;\n      margin-block-start: 0.5em;\n      margin-block-end: 1.5em;\n    }\n  }\n}\n"
  },
  {
    "path": "src/scss/timeline.scss",
    "content": ".timeline-wrapper {\n  position: fixed;\n  box-sizing: border-box;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  height: auto;\n  background: rgba($black, 0.8);\n  box-shadow: 0 -10px 38px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.22);\n  color: white;\n  transition: transform 0.3s ease;\n  z-index: $timeline;\n  border-top: 1px solid black;\n\n  &.folded {\n    transform: translateY(100%);\n\n    .timeline-header .timeline-toggle p .arrow-down {\n      transform: translate(0, 0px) rotate(-135deg);\n      -webkit-transform: translate(0, 5px) rotate(-135deg);\n    }\n  }\n\n  &.narrative-mode {\n    left: 0;\n    transition: left 0.2s ease;\n  }\n\n  .timeline-header {\n    height: 0px;\n    width: 100%;\n    font-size: $large;\n    font-weight: 700;\n\n    .timeline-toggle {\n      position: absolute;\n      margin: 0 auto;\n      width: 100%;\n      text-align: center;\n\n      p {\n        width: 60px;\n        height: 25px;\n        margin: 0 auto;\n        background: rgba($black, 0.8);\n        margin-top: -25px;\n        border-radius: 6px 6px 0 0;\n        cursor: pointer;\n\n        &:hover {\n          .arrow-down {\n            transition: 0.2s ease;\n            border-right: 2px solid $offwhite;\n            border-bottom: 2px solid $offwhite;\n          }\n        }\n      }\n\n      .arrow-down {\n        display: inline-block;\n        padding: 3px;\n        transition: 0.2s ease;\n        transform: rotate(45deg);\n        -webkit-transform: rotate(45deg);\n        border-right: 2px solid $midwhite;\n        border-bottom: 2px solid $midwhite;\n      }\n    }\n    @media screen and (max-width: 1040px) {\n      .timeline-toggle p {\n        margin: -25px 10px 0 auto;\n      }\n    }\n\n    .timeline-info {\n      width: calc(#{$card-width} - 20px);\n      position: absolute;\n      bottom: 100%;\n      margin-bottom: 6px;\n      margin-left: 10px;\n      background: rgba($black, 0.8);\n      padding: 9px 15px 11px;\n      box-sizing: border-box;\n      min-height: 20px;\n      border-radius: 6px;\n      &.hidden {\n        display: none;\n      }\n      p {\n        margin: 0;\n        text-transform: uppercase;\n        font-size: 1.15rem;\n\n        span {\n          color: $offwhite;\n        }\n        &:first-child {\n          text-transform: none;\n          font-size: 1rem;\n          color: $midwhite;\n          font-weight: 400;\n        }\n        @media screen and (max-width: 600px) {\n          font-size: 1rem;\n          &:nth-child(1) {\n            font-size: 0.875rem;\n          }\n        }\n      }\n\n      small.reset-button {\n        float:right;\n        cursor: pointer;\n        font-size: 0.75rem;\n      }\n\n      // mobile styles\n      @media screen and (max-width: 600px) {\n        bottom: 115%;\n        bottom: calc(100% + 25px);\n        width: 96vw;\n        margin: 0 2vw 10px;\n      }\n    }\n  }\n\n  .timeline-content {\n    .timeline-labels {\n      padding-top: 2px;\n      padding-left: 20px;\n      margin-right: 0px;\n      border-right: 1px solid $midgrey;\n      width: 175px;\n      height: 180px;\n      float: left;\n      text-align: left;\n      box-sizing: border-box;\n\n      .timeline-label-title {\n        font-size: $normal;\n        font-weight: 700;\n        fill: $offwhite;\n        letter-spacing: 0.1em;\n        height: 20px;\n        text-transform: uppercase;\n      }\n\n      .timeline-label {\n        font-size: $small;\n        line-height: 16px;\n        color: $offwhite;\n        text-align: right;\n        padding-right: 10px;\n        letter-spacing: 0.05em;\n      }\n    }\n\n    .timeLabel {\n      font-size: $normal;\n      fill: $midwhite;\n      letter-spacing: 0.05em;\n    }\n\n    .timeline {\n      width: 100%;\n      box-sizing: border-box;\n\n      svg {\n        display: block;\n      }\n\n      .domain {\n        opacity: 0;\n      }\n\n      .tick {\n        cursor: -webkit-grab;\n        cursor: -moz-grab;\n        line {\n          stroke: rgb(199, 199, 199);\n          shape-rendering: crispEdges;\n          opacity: 0.6;\n        }\n\n        text {\n          fill: $midwhite;\n          text-transform: capitalize;\n\n          @media screen and (max-width: 960px) {\n            writing-mode:vertical-lr;\n            /*\n             * Applies slightly different in Gecko and WebKit/Blink.\n             * Optimized for WebKit as its more common on mobile viewports.\n             */\n            transform: translateX(6px) translateY(6px);\n          }\n        }\n      }\n\n      .xAxis {\n        line {\n          stroke-dasharray: 1px 4px;\n        }\n      }\n\n      .yAxis {\n        .tick line {\n          stroke: white; // $midwhite;\n          cursor: -webkit-grab;\n          cursor: -moz-grab;\n        }\n\n        .tick text {\n          font-size: 10px;\n          text-anchor: end;\n        }\n      }\n\n      .drag-grabber {\n        cursor: -webkit-grab;\n        cursor: -moz-grab;\n        fill: $offwhite;\n        opacity: 0.05;\n      }\n\n      .axisBoundaries {\n        stroke: $offwhite;\n        stroke-width: 1;\n        stroke-dasharray: 1px 2px;\n      }\n\n      .event {\n        cursor: pointer;\n        opacity: 0.7;\n\n        &.mouseover {\n          opacity: 1;\n        }\n      }\n\n      .timeline-marker {\n        fill: none;\n      }\n\n      .coevent {\n        opacity: 0.7;\n        cursor: pointer;\n      }\n\n      .time-controls path,\n      .time-controls rect {\n        cursor: pointer;\n        transition: 0.2s ease;\n        fill: $midwhite;\n\n        &:hover path,\n        &:hover path {\n          transition: 0.2s ease;\n          fill: $offwhite;\n        }\n      }\n\n      .time-controls-inline path {\n        cursor: pointer;\n        fill: $offwhite;\n      }\n\n      .time-controls circle,\n      .time-controls-inline circle {\n        fill: $midwhite;\n        fill-opacity: 0.01;\n        cursor: pointer;\n        stroke: $midwhite;\n        stroke-width: 1;\n      }\n\n      .time-controls-inline circle {\n        stroke: none;\n      }\n\n      .time-controls g,\n      .time-controls-inline {\n        &:hover {\n          cursor: pointer;\n          circle {\n            transition: 0.2s ease;\n            fill-opacity: 0.2;\n            fill: $offwhite;\n          }\n          path,\n          rect {\n            transition: 0.2s ease;\n            fill: $offwhite;\n          }\n        }\n      }\n    }\n  }\n}\n\n.zoom-controls {\n  display: flex;\n  padding: 6px 20px;\n  align-items: center;\n  justify-content: center;\n  grid-gap: 9px;\n  @media screen and (max-width: 600px) {\n    padding: 6px 3px;\n  }\n\n  .zoom-level-button {\n    padding: 6px 9px;\n    font-size: 0.875rem;\n    cursor: pointer;\n    text-anchor: middle;\n    letter-spacing: 0.05em;\n    transition: 0.2s ease;\n    color: $midwhite;\n    border-radius: 3px;\n    border: 0;\n    background-color: transparent;\n    font-weight: 600;\n    text-align: center;\n\n    @media screen and (max-width: 600px) {\n      font-size: 0.65rem;\n    }\n\n    &:hover {\n      color: $offwhite;\n      background-color: rgba($active, 0.3);\n    }\n    &.active {\n      color: $offwhite;\n      background-color: $active;\n    }\n  }\n}\n\n/*\n* Slider\n* https://bl.ocks.org/mbostock/6452972\n*/\n.track,\n.track-overlay {\n  stroke-linecap: round;\n}\n\n.track {\n  stroke: $offwhite;\n  stroke-opacity: 1;\n  stroke-width: 1px;\n}\n\n.track-overlay {\n  pointer-events: stroke;\n  stroke-width: 15px;\n  stroke: transparent;\n  cursor: pointer;\n}\n\n/*\n* Handles\n*/\n.timeline-bottom {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n}\n.timeline-handle {\n  width: 30px;\n  height: 30px;\n  text-align: right;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  margin-left: 5px;\n  border-radius: 4px;\n  cursor: pointer;\n  &:hover {\n    background-color: rgba($active, 0.4);\n    .timeline-handle__triangle {\n      border-color: transparent $offwhite transparent transparent;\n    }\n  }\n  &__triangle {\n    display: block;\n    width: 0;\n    height: 0;\n    border-style: solid;\n    border-width: 7px 10px 7px 0;\n    border-color: transparent $midwhite transparent transparent;\n  }\n  &.right {\n    margin-right: 5px;\n    &:hover {\n      background-color: rgba($active, 0.4);\n      .timeline-handle__triangle {\n        border-color: transparent transparent transparent $offwhite;\n      }\n    }\n    .timeline-handle__triangle {\n      border-width: 7px 0 7px 10px;\n      border-color: transparent transparent transparent $midwhite;\n    }\n  }\n}\n"
  },
  {
    "path": "src/scss/toolbar.scss",
    "content": "@import \"burger\";\n@import \"tabs\";\n\n.toolbar-wrapper {\n  position: fixed;\n  top: 0px;\n  left: 0px;\n  z-index: $header;\n  background: transparent;\n\n  &.narrative-mode {\n    left: -0;\n  }\n\n  .toolbar {\n    position: relative;\n    display: flex;\n    width: auto;\n    height: 70px;\n    padding: 0;\n    margin: 0;\n    box-sizing: border-box;\n    color: $offwhite;\n    text-align: center;\n    font-size: $normal;\n    font-weight: 400;\n    z-index: $header;\n\n    button {\n      background: #222222;\n    }\n\n    .react-tabs__tab-list {\n      margin: 0;\n      display: flex;\n      align-items: center;\n      justify-content: flex-start;\n      grid-gap: 15px;\n      margin-left: 10px;\n      height: 100%;\n    }\n\n    .toolbar-header {\n      padding: 5px 15px 5px 10px;\n      border-radius: 0 0 6px 0;\n      background-color: $midgrey;\n      transition: 0.2s ease;\n      // border-bottom: 2px solid $midwhite;\n      text-transform: uppercase;\n      cursor: pointer;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n\n      p {\n        white-space: pre-wrap;\n        font-size: 1.15rem;\n        line-height: 1.3em;\n        font-weight: 400;\n        text-transform: uppercase;\n        margin: 0;\n      }\n    }\n\n    @media screen and (max-width: 600px) {\n      height: 60px;\n      .toolbar-header p {\n        font-size: 1rem;\n      }\n    }\n\n    .toolbar-tabs {\n      padding: 0;\n    }\n\n    .bottom-actions {\n      display: none;\n      width: $toolbar-width;\n      bottom: 10px;\n      box-sizing: border-box;\n\n      .bottom-action-block {\n        display: block;\n\n        &:last-child {\n          padding-left: 8px;\n        }\n      }\n\n      .action-button {\n        width: 60px;\n        height: 25px;\n        border-radius: 30px;\n        background: none;\n        margin: 0 auto;\n        margin-top: 10px;\n        display: block;\n        outline: none;\n        font-size: $xsmall;\n        cursor: pointer;\n        transition: 0.2s ease;\n        border: 1px solid $midwhite;\n        color: $midwhite;\n\n        &.tiny {\n          height: 30px;\n          width: 30px;\n          display: inline-block;\n          float: left;\n          margin-right: 2px;\n          &:last-child {\n            margin-right: 0;\n          }\n        }\n\n        &:hover {\n          cursor: pointer;\n        }\n\n        &:hover:not(.disabled) {\n          transition: 0.2s ease;\n          border: 1px solid $offwhite;\n          color: $offwhite;\n\n          svg path {\n            stroke: $offwhite;\n          }\n          svg polyline {\n            stroke: $offwhite;\n          }\n          svg polygon {\n            fill: $offwhite;\n          }\n        }\n\n        svg {\n          &.reset {\n            margin-left: -4px;\n            margin-top: -1px;\n            -webkit-transform: scale(0.9);\n            -moz-transform: translate(-2px, 1px) scale(0.9);\n            transform: scale(0.9);\n          }\n\n          path,\n          polyline {\n            fill: none;\n            stroke: $midwhite;\n            stroke-width: 2px;\n          }\n\n          polygon {\n            fill: $midwhite;\n          }\n\n          &.coevents {\n            margin: 0;\n            -webkit-transform: scale(0.9);\n            transform: scale(1.2);\n\n            path {\n              stroke-width: 2px;\n            }\n            rect {\n              fill: $midwhite;\n              &.no-fill {\n                fill: $darkgrey;\n              }\n            }\n            line {\n              stroke-width: 1px;\n              stroke: $midwhite;\n            }\n          }\n        }\n\n        &.info {\n          font-size: $xxlarge;\n          bottom: 120px;\n        }\n\n        &.disabled {\n          cursor: default;\n        }\n\n        &.enabled {\n          border: 1px solid $offwhite;\n          color: $offwhite;\n\n          svg path {\n            stroke: $offwhite;\n          }\n          svg polyline {\n            stroke: $offwhite;\n          }\n          svg polygon {\n            fill: $offwhite;\n          }\n        }\n      }\n    }\n  }\n\n  .download-row + .download-row {\n    margin-top: 14px;\n  }\n  .download-button {\n    aspect-ratio: 1 / 1;\n    width: 50px;\n    height: auto;\n    flex-direction: column;\n    text-align: center;\n    display: inline-flex;\n    vertical-align: middle;\n    align-items: center;\n    justify-content: center;\n    color: $black;\n    border: 1px solid $offwhite;\n    background: $offwhite;\n    border-radius: 6px;\n    font-weight: 600;\n    cursor: pointer;\n    transition: background 0.3s ease;\n    &:hover {\n      background: rgba(#fff, 0.6);\n    }\n  }\n  .download-description {\n    display: inline-block;\n    width: calc(100% - 52px);\n    padding-left: 12px;\n    box-sizing: border-box;\n    vertical-align: middle;\n  }\n\n  .toolbar-tab {\n    position: relative;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    flex-direction: column;\n    font-weight: 400;\n    padding: 0;\n    height: auto;\n    width: 45px;\n    aspect-ratio: 1 / 1;\n    background: rgba(0, 0, 0, 0.8);\n    border-radius: 6px;\n    cursor: pointer;\n    transition: 0.2s ease;\n    color: $midwhite;\n\n    svg {\n      // transform: scale(0.7);\n      path,\n      circle,\n      polygon,\n      polyline,\n      line {\n        stroke-width: 2px;\n        transition: 0.2s ease;\n        stroke: $midwhite;\n        fill: none;\n        stroke-linecap: round;\n      }\n\n      &.scenes {\n        path {\n          transition: 0.2s ease;\n          fill: $midwhite;\n          stroke: none;\n        }\n      }\n    }\n\n    &:hover {\n      .tab-caption {\n        transform: scale(1);\n      }\n    }\n    .tab-caption {\n      display: block;\n      text-align: center;\n      font-size: 1rem;\n      position: absolute;\n      top: 100%;\n      top: calc(100% + 5px);\n      background: #000;\n      padding: 3px 6px;\n      font-size: 1rem;\n      color: #fff;\n      border-radius: 3px;\n      transform: scale(0);\n      transition: transform 0.15s ease;\n      user-select: none;\n      &:after {\n        content: \"\";\n        display: block;\n        position: absolute;\n        width: 6px;\n        height: 6px;\n        transform: rotate(45deg);\n        background-color: #000;\n        left: 50%;\n        left: calc(50% - 3px);\n        top: -3px;\n      }\n    }\n\n    &.active {\n      background: $active;\n    }\n\n    &:hover,\n    &.active {\n      transition: 0.2s ease;\n      color: $offwhite;\n\n      svg {\n        path,\n        circle,\n        polygon,\n        polyline,\n        line {\n          transition: 0.2s ease;\n          stroke: $offwhite;\n        }\n\n        &.scenes {\n          path {\n            transition: 0.2s ease;\n            fill: $offwhite;\n            stroke: none;\n          }\n        }\n      }\n    }\n  }\n}\n\n.toolbar-panels {\n  display: flex;\n  flex-direction: column;\n  align-items: stretch;\n  justify-content: flex-start;\n  width: 440px;\n  top: 75px;\n  left: 15px;\n  padding: 15px 15px 30px;\n  box-sizing: border-box;\n  background: $black;\n  color: $offwhite;\n  position: fixed;\n  transition: 0.2s ease;\n  max-height: calc(100vh - #{$timeline-height} - 50px);\n  box-shadow: 10px -10px 38px rgba(0, 0, 0, 0.3),\n    10px 15px 12px rgba(0, 0, 0, 0.22);\n  z-index: 20;\n\n  @media screen and (max-width: 600px) {\n    left: 3px;\n    right: 3px;\n    width: auto;\n    top: 65px;\n    max-height: calc(100vh - 65px - 5px);\n  }\n\n  .sticky-header {\n    position: sticky;\n    top: 0;\n    background: #000;\n    z-index: 100;\n    padding: 0 0 10px;\n    h2 {\n      margin: 0;\n    }\n  }\n  .panel-description {\n    margin-bottom: 1.5rem;\n    .hint {\n      color: $midwhite;\n    }\n    p:nth-last-child(1) {\n      margin-bottom: 0;\n    }\n    p:nth-child(1) {\n      margin-top: 0;\n    }\n  }\n\n  h2 {\n    text-transform: none;\n    letter-spacing: normal;\n    &:nth-child(1) {\n      margin-top: 0;\n    }\n  }\n\n  .panel-header {\n    position: absolute;\n    display: inline-block;\n    width: 36px;\n    height: 36px;\n    box-sizing: border-box;\n    top: 0;\n    left: 100%;\n    border-radius: 0 3px 3px 0;\n    background: $black;\n    padding: 8px 6px;\n    cursor: pointer;\n\n    .caret {\n      position: relative;\n      transform: translate(8px, 5px) rotate(45deg);\n      width: 8px;\n      height: 8px;\n      transition: 0.2s ease;\n      border-left: 2px solid $midwhite;\n      border-bottom: 2px solid $midwhite;\n    }\n\n    &:hover {\n      .caret {\n        transition: 0.2s ease;\n        border-left: 2px solid $offwhite;\n        border-bottom: 2px solid $offwhite;\n      }\n    }\n  }\n\n  .react-tabs__tab-list {\n    height: 40px;\n    overflow: hidden;\n  }\n\n  .react-tabs__tab-panel {\n    margin-top: 0px;\n  }\n\n  .react-tabs__tab-panel--selected {\n    overflow-y: auto;\n    margin-top: 0;\n\n    .react-tabs__tab-panel--selected {\n      padding-top: 20px;\n      box-sizing: border-box;\n    }\n  }\n  .react-tabs .react-innertabpanel {\n    padding-top: 0;\n  }\n\n  ul {\n    margin: 0;\n    padding-left: 0;\n    height: auto;\n    transition: 0.2s ease;\n    height: calc(100% - 310px);\n  }\n\n  transition: opacity 0.3s ease, margin 0.3s ease, left 0s linear 0s;\n  &.folded {\n    transition: opacity 0.3s ease, margin 0.3s ease, left 0s linear 1s;\n    opacity: 0;\n    margin-top: 20px;\n    left: -110%;\n    right: auto;\n    max-width: 100vw;\n\n    ul {\n      height: 0;\n      margin: 0;\n    }\n\n    .panel-header {\n      visibility: hidden;\n\n      .caret {\n        transform: translate(8px, 5px) rotate(225deg);\n      }\n    }\n  }\n\n  input {\n    width: 100%;\n    border: 1px solid;\n    height: 60px;\n    color: $offwhite;\n    background: none;\n    outline: none;\n    box-sizing: border-box;\n    margin: 20px 0;\n    padding: 5px 10px;\n    font-size: 18px;\n    letter-spacing: 0.1em;\n    transition: 0.2s ease;\n    border-color: $midwhite;\n    text-align: center;\n\n    &:focus {\n      transition: 0.2s ease;\n      border-color: $offwhite;\n    }\n  }\n\n  .item {\n    width: 100%;\n    background: none;\n    font-size: 1rem;\n    padding: 3px 0;\n    margin: 0 0 3px;\n\n    &:hover {\n      opacity: 0.8;\n      cursor: pointer;\n    }\n\n    button,\n    label {\n      display: inline-block;\n      vertical-align: middle;\n    }\n\n    button {\n      aspect-ratio: 1 / 1;\n      border: 1px transparent;\n      background: none;\n      color: $offwhite;\n      outline: none;\n      transition: 0.2s ease;\n      text-align: left;\n      padding: 4px;\n      margin-right: 3px;\n\n      .border {\n        width: 16px;\n        height: 16px;\n        background: none;\n        box-sizing: border-box;\n        position: relative;\n\n        .checkbox {\n          display: inline-block;\n          width: 12px;\n          height: 12px;\n          border: 1px solid $offwhite;\n          box-sizing: border-box;\n          background: none;\n          position: absolute;\n          top: 2px;\n          left: 2px;\n          background: none;\n        }\n      }\n    }\n\n    span {\n      width: calc(100% - 40px);\n      display: inline-block;\n      height: 36px;\n      line-height: 36px;\n      color: $offwhite;\n      font-size: $normal;\n      overflow: hidden;\n    }\n\n    &.active {\n      .checkbox {\n        background: $offwhite;\n      }\n    }\n  }\n\n  .arrow {\n    display: inline-block;\n    width: 10px;\n    height: 10px;\n    line-height: 10px;\n    padding: 10px;\n    float: left;\n    cursor: pointer;\n    color: $offwhite;\n    transition: 0.4s ease;\n    transform: rotate(0deg);\n\n    &:after {\n      content: \"▾\";\n    }\n\n    &.folded {\n      transition: 0.4s ease;\n      transform: rotate(-90deg);\n    }\n  }\n\n  .panel-action {\n    button {\n      font-size: 1.2em;\n      height: 140px;\n      line-height: 140px;\n      width: 100%;\n      padding: 10px;\n      border: 1px solid $offwhite;\n      background-size: 100%;\n      color: $offwhite;\n      cursor: pointer;\n      outline: none;\n      text-transform: uppercase;\n      margin-bottom: 10px;\n      transition: 0.2s ease;\n      letter-spacing: 0.1em;\n      background-color: #000;\n\n      &:hover {\n        transition: 0.2s ease;\n        letter-spacing: 0.15em;\n        background-color: $yellow;\n        color: $black;\n      }\n\n      p {\n        text-transform: none;\n      }\n    }\n  }\n}\n\n.search-content {\n  .item {\n    overflow: auto;\n    min-height: 32px;\n    height: auto;\n    border-bottom: 1px solid rgba(white, 0.25);\n\n    span {\n      height: auto;\n    }\n  }\n}\n\n.path-list {\n  margin-bottom: 10px;\n\n  .item {\n    border-bottom: 1px solid rgba(255, 255, 255, 0.25);\n  }\n}\n\n/*\n* Made with block\n*/\n#made-with {\n  position: fixed;\n  top: 75px;\n  background: rgba(0, 0, 0, 0.8);\n  left: 5px;\n  padding: 5px 12px;\n  margin-bottom: 6px;\n  border-radius: 4px;\n  width: 115px;\n  text-align: left;\n  font-size: 0.75rem;\n  opacity: 0.65;\n  color: $midwhite;\n  &:hover {\n    opacity: 1;\n  }\n  @media screen and (max-width: 600px) {\n    top: 65px;\n    opacity: 1;\n  }\n}\n\n// @media (max-height: 678px) {\n//   .toolbar-wrapper {\n//     .toolbar-tab {\n//       height: 60px;\n//       padding: 0;\n\n//       &:hover {\n//         .tab-caption {\n//           transition: 0.2s ease;\n//           opacity: 1;\n//         }\n//       }\n//     }\n//     .toolbar .bottom-actions {\n//       .action-button {\n//         margin-top: 5px;\n//       }\n//     }\n//   }\n// }\n"
  },
  {
    "path": "src/scss/video.scss",
    "content": ".video-wrapper {\n  z-index: 1;\n  position: relative;\n  width: 740px;\n  height: 420px;\n  transition: opacity 500ms;\n  background-color: black;\n  overflow: hidden;\n}\n\n.video-js .vjs-big-play-button {\n  font-size: 3em;\n  line-height: 40px;\n  height: 40px;\n  width: 40px;\n  display: block;\n  position: absolute;\n  background: none;\n  top: 10px;\n  left: 10px;\n  padding: 0;\n  cursor: pointer;\n  opacity: 1;\n  border-radius: 20px;\n  transition: 0.2s ease;\n  border: 1px solid $midwhite;\n\n  &:hover {\n    transition: 0.2s ease;\n    border: 1px solid $offwhite;\n  }\n}\n\n.fullscreen-bg {\n  position: fixed;\n  top: 0;\n  right: 0;\n  bottom: 0;\n  left: 0;\n  overflow: hidden;\n  z-index: -100;\n}\n\n.fullscreen-bg__video {\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  -webkit-filter: contrast(70%) brightness(70%) grayscale(30%); /* Webkit */\n  filter: gray; /* IE6-9 */\n  filter: contrast(70%) brightness(70%) grayscale(30%); /* W3C */\n}\n\n@media (min-aspect-ratio: 16/9) {\n  .fullscreen-bg__video {\n    height: 300%;\n    top: -100%;\n  }\n}\n\n@media (max-aspect-ratio: 16/9) {\n  .fullscreen-bg__video {\n    width: 300%;\n    left: -100%;\n  }\n}\n\n@media (max-width: 767px) {\n  .fullscreen-bg {\n    background: url(\"/static/archive/img/city.jpg\") center center / cover\n      no-repeat;\n  }\n\n  .fullscreen-bg__video {\n    display: none;\n  }\n}\n"
  },
  {
    "path": "src/selectors/__tests__/timeline.spec.js",
    "content": "import initial from \"../../store/initial\";\nimport { advanceTo, clear } from \"jest-date-mock\";\nimport * as selectors from \"../\";\n\ndescribe(\"timeline selectors\", () => {\n  beforeAll(() => {\n    advanceTo(new Date(\"2022-02-01T00:00:00.000Z\"));\n  });\n\n  afterAll(() => {\n    clear();\n  });\n\n  const state = (range) => ({\n    ...initial,\n    app: {\n      ...initial.app,\n      timeline: {\n        ...initial.app.timeline,\n        range,\n      },\n    },\n  });\n\n  describe(\"selectTimeRange\", () => {\n    it(\"returns the currently selected time range\", () => {\n      expect(\n        selectors.selectTimeRange(\n          state({\n            initial: [\"2020-03-03T00:00:00.000Z\", \"2024-01-04T00:00:00.000Z\"],\n            current: [\"2021-03-03T00:00:00.000Z\", \"2023-01-04T00:00:00.000Z\"],\n            initialDaysShown: 31,\n            limits: {\n              lower: \"2022-02-01T00:00:00.000Z\",\n              upper: undefined,\n            },\n          })\n        )\n      ).toEqual([\n        new Date(\"2021-03-03T00:00:00.000Z\"),\n        new Date(\"2023-01-04T00:00:00.000Z\"),\n      ]);\n    });\n\n    it(\"falls back to a fixed default time range when no current range is set\", () => {\n      expect(\n        selectors.selectTimeRange(\n          state({\n            current: undefined,\n            initial: [\"2020-03-03T00:00:00.000Z\", \"2024-01-04T00:00:00.000Z\"],\n            initialDaysShown: 31,\n            limits: {\n              lower: \"2022-02-01T00:00:00.000Z\",\n              upper: undefined,\n            },\n          })\n        )\n      ).toEqual([\n        new Date(\"2020-03-03T00:00:00.000Z\"),\n        new Date(\"2024-01-04T00:00:00.000Z\"),\n      ]);\n    });\n\n    it(\"falls back to a dynamic default time range when no fixed default range or current range is set\", () => {\n      expect(\n        selectors.selectTimeRange(\n          state({\n            current: undefined,\n            initial: undefined,\n            initialDaysShown: 31,\n            limits: {\n              lower: \"2022-02-01T00:00:00.000Z\",\n              upper: undefined,\n            },\n          })\n        )\n      ).toEqual([\n        new Date(\"2022-01-01T00:00:00.000Z\"),\n        new Date(\"2022-02-01T00:00:00.000Z\"),\n      ]);\n    });\n\n    it(\"falls back to a dynamic default if an invalid default range is passed in\", () => {\n      expect(\n        selectors.selectTimeRange(\n          state({\n            current: undefined,\n            initial: \"some garbage data\",\n            initialDaysShown: 31,\n            limits: {\n              lower: \"2022-02-01T00:00:00.000Z\",\n              upper: undefined,\n            },\n          })\n        )\n      ).toEqual([\n        new Date(\"2022-01-01T00:00:00.000Z\"),\n        new Date(\"2022-02-01T00:00:00.000Z\"),\n      ]);\n    });\n  });\n\n  describe(\"selectTimeRangeLimits\", () => {\n    it(\"returns fixed time range limits\", () => {\n      expect(\n        selectors.selectTimeRangeLimits(\n          state({\n            current: undefined,\n            initial: undefined,\n            initialDaysShown: 31,\n            limits: {\n              lower: \"2021-02-01T00:00:00.000Z\",\n              upper: \"2023-03-03T00:00:00.000Z\",\n            },\n          })\n        )\n      ).toEqual([\n        new Date(\"2021-02-01T00:00:00.000Z\"),\n        new Date(\"2023-03-03T00:00:00.000Z\"),\n      ]);\n    });\n\n    it(\"returns limits from a given lower bound to the current date, when no upper bound is passed in\", () => {\n      expect(\n        selectors.selectTimeRangeLimits(\n          state({\n            current: undefined,\n            initial: undefined,\n            initialDaysShown: 31,\n            limits: {\n              lower: \"2021-02-01T00:00:00.000Z\",\n              upper: undefined,\n            },\n          })\n        )\n      ).toEqual([\n        new Date(\"2021-02-01T00:00:00.000Z\"),\n        new Date(\"2022-02-01T00:00:00.000Z\"),\n      ]);\n    });\n  });\n});\n"
  },
  {
    "path": "src/selectors/helpers.js",
    "content": "/**\n * Some handy helpers\n */\n\n/**\n * Given an event and a time range,\n * returns true/false if the event falls within timeRange\n */\nexport function isTimeRangedIn(event, timeRange) {\n  const eventTime = event.datetime;\n  return timeRange[0] < eventTime && eventTime < timeRange[1];\n}\n\n/**\n * Shuffles array in place. ES6 version\n * @param {Array} a items An array containing the items.\n * https://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array\n */\nexport function shuffle(a) {\n  for (let i = a.length - 1; i > 0; i--) {\n    const j = Math.floor(Math.random() * (i + 1));\n    [a[i], a[j]] = [a[j], a[i]];\n  }\n  return a;\n}\n"
  },
  {
    "path": "src/selectors/index.js",
    "content": "import { createSelector } from \"reselect\";\nimport {\n  insetSourceFrom,\n  dateMin,\n  dateMax,\n  isLatitude,\n  isLongitude,\n  createFilterPathString,\n} from \"../common/utilities\";\nimport { isTimeRangedIn } from \"./helpers\";\nimport { ASSOCIATION_MODES, SHAPE } from \"../common/constants\";\n\n// Input selectors\nexport const getEvents = (state) => state.domain.events;\nexport const getCategories = (state) =>\n  state.domain.associations.filter(\n    (item) => item.mode === ASSOCIATION_MODES.CATEGORY\n  );\nexport const getNarratives = (state) =>\n  state.domain.associations.filter(\n    (item) => item.mode === ASSOCIATION_MODES.NARRATIVE\n  );\nexport const getActiveNarrative = (state) => state.app.associations.narrative;\nexport const getSelected = (state) => state.app.selected;\nexport const getSites = (state) => state.domain.sites;\nexport const getSources = (state) => state.domain.sources;\nexport const getRegions = (state) => state.domain.regions;\nexport const getShapes = (state) => state.domain.shapes;\nexport const getFilters = (state) =>\n  state.domain.associations.filter(\n    (item) => item.mode === ASSOCIATION_MODES.FILTER\n  );\nexport const getNotifications = (state) => state.domain.notifications;\nexport const getActiveFilters = (state) => state.app.associations.filters;\nexport const getActiveCategories = (state) => state.app.associations.categories;\nexport const getActiveShapes = (state) => state.app.shapes;\nexport const getColoringSet = (state) => state.app.associations.coloringSet;\nexport const getTimeRange = (state) => state.app.timeline.range;\nexport const getTimelineDimensions = (state) => state.app.timeline.dimensions;\nexport const selectNarrative = (state) => state.app.associations.narrative;\nexport const getFeatures = (state) => state.features;\nexport const getEventRadius = (state) => state.ui.eventRadius;\nexport const getTile = (state) => state.ui.tiles.current;\nexport const isUsingSatellite = (state) =>\n  state.ui.tiles.current === state.ui.tiles.satellite;\nexport const getMapLat = (state) => state.app.map.anchor[0];\nexport const getMapLng = (state) => state.app.map.anchor[1];\nexport const getMapZoom = (state) => state.app.map.startZoom;\n\nexport const selectSites = createSelector(\n  [getSites, getFeatures],\n  (sites, features) => {\n    if (features.USE_SITES) {\n      return sites.filter((s) => !!+s.enabled);\n    }\n    return [];\n  }\n);\n\nexport const selectSources = createSelector(\n  [getSources, getFeatures],\n  (sources, features) => {\n    if (features.USE_SOURCES) return sources;\n    return {};\n  }\n);\n\nexport const selectRegions = createSelector(\n  [getRegions, getFeatures],\n  (regions, features) => {\n    if (features.USE_REGIONS) return regions;\n    return [];\n  }\n);\n\nconst getInitialTimeRange = (state) => state.app.timeline.range.initial;\nconst getInitialDaysShown = (state) =>\n  state.app.timeline.range.initialDaysShown;\nexport const selectTimeRange = createSelector(\n  [getTimeRange, getInitialTimeRange, getInitialDaysShown],\n  (range, initialRange, initialDaysShown) => {\n    let start, end;\n    range = range.current;\n\n    if (Array.isArray(range) && range.length === 2) {\n      [start, end] = range;\n    } else if (Array.isArray(initialRange) && initialRange.length === 2) {\n      [start, end] = initialRange;\n    } else {\n      end = new Date();\n      start = new Date(end.getTime() - initialDaysShown * 24 * 60 * 60 * 1000);\n    }\n\n    return [new Date(start), new Date(end)];\n  }\n);\n\nconst getTimeRangeLimits = (state) => state.app.timeline.range.limits;\nexport const selectTimeRangeLimits = createSelector(\n  getTimeRangeLimits,\n  (limits) => {\n    return [new Date(limits.lower), new Date(limits.upper || Date.now())];\n  }\n);\n\n/**\n * Of all available events, selects those that\n * 1. fall in time range\n * 2. exist in an active filter\n * 3. exist in an active category\n */\nexport const selectEvents = createSelector(\n  [\n    getEvents,\n    getActiveFilters,\n    getActiveCategories,\n    getActiveShapes,\n    selectTimeRange,\n    getFeatures,\n  ],\n  (\n    events,\n    activeFilters,\n    activeCategories,\n    activeShapes,\n    timeRange,\n    features\n  ) => {\n    return events.reduce((acc, event) => {\n      const isMatchingFilter =\n        (event.associations &&\n          event.associations\n            .filter((a) => a.mode === ASSOCIATION_MODES.FILTER)\n            .map((association) =>\n              activeFilters.includes(createFilterPathString(association))\n            )\n            .some((s) => s)) ||\n        activeFilters.length === 0;\n      const isActiveFilter = isMatchingFilter || activeFilters.length === 0;\n      const isActiveCategory =\n        (event.associations &&\n          event.associations\n            .filter((a) => a.mode === ASSOCIATION_MODES.CATEGORY)\n            .map((association) => activeCategories.includes(association.title))\n            .some((s) => s)) ||\n        activeCategories.length === 0;\n      let isActiveTime = isTimeRangedIn(event, timeRange);\n      isActiveTime = features.GRAPH_NONLOCATED\n        ? (!event.latitude && !event.longitude) || isActiveTime\n        : isActiveTime;\n      const isActiveShape =\n        event.shape && activeShapes.includes(event.shape.id);\n      if (event.type === SHAPE) {\n        if (isActiveShape && isActiveCategory && isActiveTime) {\n          acc[event.id] = { ...event };\n        }\n      } else {\n        if (isActiveFilter && isActiveCategory && isActiveTime) {\n          acc[event.id] = { ...event };\n        }\n      }\n      return acc;\n    }, []);\n  }\n);\n\n/**\n * Of all available events, select only those that fall within the currently selected time range.\n * Since `events` is a sparse array, we need to reduce the array in order to count.\n */\nexport const selectEventCountInTimeRange = createSelector(\n  [selectEvents],\n  (events, timeRange) => events.reduce((acc) => acc + 1, 0)\n);\n\n/**\n * Of all available events, selects those that fall within the time range,\n * and if filters are being used, select them if their filters are enabled\n */\nexport const selectNarratives = createSelector(\n  [getEvents, getNarratives, getSources, getFeatures],\n  (events, narrativesMeta, sources, features) => {\n    if (Array.isArray(narrativesMeta) && narrativesMeta.length === 0) {\n      return [];\n    }\n    const narratives = {};\n    const narrativeSkeleton = (id) => ({ id, steps: [] });\n\n    /* populate narratives dict with events */\n    events.forEach((evt) => {\n      evt.associations.forEach((association) => {\n        const foundNarrative = narrativesMeta.find(\n          (narr) => narr.id === association\n        );\n        if (foundNarrative) {\n          const { id: narrId } = foundNarrative;\n          // initialise\n          if (!narratives[narrId]) {\n            narratives[narrId] = narrativeSkeleton(narrId);\n          }\n          // add evt to steps\n          // NB: insetSourceFrom is a 'curried' function to allow with maps\n          narratives[narrId].steps.push(insetSourceFrom(sources)(evt));\n        }\n      });\n    });\n    /* sort steps by time */\n    Object.keys(narratives).forEach((key) => {\n      const steps = narratives[key].steps;\n\n      steps.sort((a, b) => a.datetime - b.datetime);\n\n      const existingAssociatedNarrative = narrativesMeta.find(\n        (n) => n.id === key\n      );\n\n      if (existingAssociatedNarrative) {\n        narratives[key] = {\n          ...existingAssociatedNarrative,\n          ...narratives[key],\n        };\n      }\n    });\n    // Return narratives in original order\n    // + filter those that are undefined\n    return narrativesMeta.map((n) => narratives[n.id]).filter((d) => d);\n  }\n);\n\n/** We iterate through narrative.steps and check the idx there against the selected array and we return the idx */\nexport const selectNarrativeIdx = createSelector(\n  [getSelected, getActiveNarrative],\n  (selected, narrative) => {\n    // Only one event selected in narrative mode\n    if (narrative === null) return -1;\n\n    const selectedEvent = selected[0];\n    let selectedIdx;\n\n    narrative.steps.forEach((step, idx) => {\n      if (selectedEvent.id === step.id) {\n        selectedIdx = idx;\n      }\n    });\n    return selectedIdx;\n  }\n);\n\n/** Aggregate information about the narrative and the current step into\n *  a single object. If narrative is null, the whole object is null.\n */\nexport const selectActiveNarrative = createSelector(\n  [getActiveNarrative, selectNarrativeIdx],\n  (narrative, current) => (narrative ? { ...narrative, current } : null)\n);\n\n/**\n * Group events by location. Each location is an object:\n  {\n    events: [...],\n    label: 'Location name',\n    latitude: '47.7',\n    longitude: '32.2'\n  }\n */\nexport const selectLocations = createSelector([selectEvents], (events) => {\n  const activeLocations = {};\n  events.forEach((event) => {\n    const { latitude, longitude } = event;\n    if (!isLatitude(latitude) || !isLongitude(longitude)) return;\n\n    const location = `${event.location}$_${event.latitude}_${event.longitude}`;\n\n    if (activeLocations[location]) {\n      activeLocations[location].events.push(event);\n    } else {\n      activeLocations[location] = {\n        label: location,\n        events: [event],\n        id: event.id,\n        latitude: event.latitude,\n        longitude: event.longitude,\n      };\n    }\n  });\n\n  return Object.values(activeLocations);\n});\n\nexport const selectEventsWithProjects = createSelector(\n  [selectEvents, getFeatures, getEventRadius],\n  (events, features, eventRadius) => {\n    if (!features.GRAPH_NONLOCATED) {\n      return [events, []];\n    }\n    const projSize = 2 * eventRadius;\n    const projectIdx = features.GRAPH_NONLOCATED.projectIdx || 0;\n    const getProject = (ev) => ev.filters[projectIdx];\n    const projects = {};\n\n    // get all projects\n    events = events.reduce((acc, event) => {\n      const project =\n        event.filters.length >= 1 && !event.latitude && !event.longitude\n          ? getProject(event)\n          : null;\n\n      // add project if it doesn't exist\n      if (project !== null) {\n        if (projects.hasOwnProperty(project)) {\n          projects[project].start = dateMin(\n            projects[project].start,\n            event.datetime\n          );\n          projects[project].end = dateMax(\n            projects[project].end,\n            event.datetime\n          );\n        } else {\n          projects[project] = {\n            start: event.datetime,\n            end: event.datetime,\n            key: project,\n            category: event.category,\n          };\n        }\n      }\n      acc.push({ ...event, project });\n      return acc;\n    }, []);\n\n    const projObjs = Object.values(projects);\n    projObjs.sort((a, b) => a.start - b.start);\n\n    // active projects is a data structure with projObjs.length empty slots\n    const activeProjs = Object.keys(projects).map((_, idx) => null);\n\n    const projectsWithOffset = projObjs.reduce((acc, proj, theIdx) => {\n      // remove any project that have ended from slots\n      activeProjs.forEach((theProj, theProjIdx) => {\n        if (theProj !== null) {\n          const projInSlot = projects[theProj];\n          if (projInSlot.end < proj.start) {\n            activeProjs[theProjIdx] = null;\n          }\n        }\n      });\n      let i = 0;\n      // find the first empty slot\n      while (activeProjs[i]) i++;\n      // put proj in slot\n      activeProjs[i] = proj.key;\n\n      proj.offset = i * projSize;\n      acc[proj.key] = proj;\n      return acc;\n    }, {});\n\n    return [events, projectsWithOffset];\n  }\n);\n\nexport const selectStackedEvents = createSelector(\n  [selectEventsWithProjects],\n  (eventsWithProjects) => {\n    return eventsWithProjects[0];\n  }\n);\n\nexport const selectProjects = createSelector(\n  [selectEventsWithProjects, getFeatures],\n  (eventsWithProjects, features) => {\n    if (!features.GRAPH_NONLOCATED) {\n      return [];\n    }\n    return eventsWithProjects[1];\n  }\n);\n\n/**\n * Of all the sources, select those that are relevant to the selected events.\n */\nexport const selectSelected = createSelector(\n  [getSelected, getSources],\n  (selected, sources) => {\n    if (selected.length === 0) {\n      return [];\n    }\n    return selected.map(insetSourceFrom(sources));\n  }\n);\n\nexport const selectDimensions = createSelector(\n  [getTimelineDimensions],\n  (dimensions) => {\n    return {\n      ...dimensions,\n      trackHeight: dimensions.contentHeight - 50, // height of time labels\n    };\n  }\n);\n\nexport const selectFilterPathToIdMapping = createSelector(\n  [getFilters],\n  (filters) => {\n    return filters.reduce((acc, curr) => {\n      acc[createFilterPathString(curr)] = curr.id;\n      return acc;\n    }, {});\n  }\n);\n\nexport const selectActiveColorSets = createSelector(\n  [getColoringSet, selectFilterPathToIdMapping],\n  (set, mapping) => {\n    return set.map((set) => mapFiltersToIds(set, mapping).join(\",\"));\n  }\n);\n\nexport const selectActiveFilterIds = createSelector(\n  [getActiveFilters, selectFilterPathToIdMapping],\n  (filters, mapping) => {\n    return mapFiltersToIds(filters, mapping);\n  }\n);\n\nfunction mapFiltersToIds(arr, filterMapping) {\n  return arr.reduce((acc, path) => {\n    const id = filterMapping[path];\n    if (id) acc.push(id);\n    return acc;\n  }, []);\n}\n"
  },
  {
    "path": "src/store/index.js",
    "content": "import { createStore, applyMiddleware, compose } from \"redux\";\nimport thunk from \"redux-thunk\";\n\nimport rootReducer from \"../reducers\";\nimport { urlStateMiddleware } from \"./plugins/urlState\";\n\nconst composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;\n\nconst store = createStore(\n  rootReducer,\n  composeEnhancers(applyMiddleware(thunk), applyMiddleware(urlStateMiddleware))\n);\n\nexport default store;\n"
  },
  {
    "path": "src/store/initial.js",
    "content": "import { mergeDeepLeft } from \"ramda\";\n\nimport global, { colors } from \"../common/global\";\nimport copy from \"../common/data/copy.json\";\nimport { language } from \"../common/utilities\";\nimport { DEFAULT_TAB_ICONS } from \"../common/constants\";\nimport config from \"../../config\";\n\nconst isSmallLaptop = window.innerHeight < 800;\nconst mapInitial = {\n  anchor: [31.356397, 34.784818],\n  startZoom: 11,\n  minZoom: 2,\n  maxZoom: 16,\n  bounds: null,\n  maxBounds: [\n    [180, -180],\n    [-180, 180],\n  ],\n};\nconst space3dInitial = {};\n\nconst initial = {\n  /*\n   * The Domain or 'domain' of this state refers to the tree of data\n   *  available for render and display.\n   * Selections and filters in the 'app' subtree will operate the domain\n   *   in mapStateToProps of the Dashboard, and determine which items\n   *   in the domain will get rendered by React\n   */\n  domain: {\n    events: [],\n    categories: [],\n    associations: [],\n    sources: {},\n    sites: [],\n    shapes: [],\n    regions: [],\n    notifications: [],\n  },\n\n  /*\n   * The 'app' subtree of this state determines the data and information to be\n   *   displayed.\n   * It may refer to those the user interacts with, by selecting,\n   *   filtering and so on, which ultimately operate on the data to be displayed.\n   * Additionally, some of the 'app' flags are determined by the config file\n   *   or by the characteristics of the client, browser, etc.\n   */\n  app: {\n    debug: true,\n    errors: {\n      source: false,\n    },\n    highlighted: [],\n    selected: [],\n    source: null,\n    associations: {\n      coloringSet: [],\n      filters: [],\n      narrative: null,\n      categories: [],\n      views: {\n        events: true,\n        routes: false,\n        sites: true,\n      },\n    },\n    shapes: [],\n    language: \"en-US\",\n    cluster: {\n      radius: 30,\n      minZoom: 2,\n      maxZoom: 16,\n    },\n    timeline: {\n      dimensions: {\n        ticks: 15,\n        height: isSmallLaptop ? 170 : 250,\n        width: 0,\n        marginLeft: 20,\n        marginTop: isSmallLaptop ? 5 : 10, // the padding used for the day/month labels inside the timeline\n        marginBottom: 60,\n        contentHeight: isSmallLaptop ? 160 : 200,\n        width_controls: 100,\n      },\n      range: {\n        current: null,\n      },\n      zoomLevels: copy[language].timeline.zoomLevels || [\n        { label: \"20 years\", duration: 10512000 },\n        { label: \"2 years\", duration: 1051200 },\n        { label: \"3 months\", duration: 129600 },\n        { label: \"3 days\", duration: 4320 },\n        { label: \"12 hours\", duration: 720 },\n        { label: \"1 hour\", duration: 60 },\n      ],\n    },\n    flags: {\n      isFetchingDomain: false,\n      isFetchingSources: false,\n      isCover: true,\n      isCardstack: true,\n      isInfopopup: false,\n      isIntropopup: false,\n      isShowingSites: true,\n    },\n    cover: {\n      title: \"project title\",\n      description:\n        \"A description of the project goes here.\\n\\nThis description may contain markdown.\\n\\n# This is a large title, for example.\\n\\n## Whereas this is a slightly smaller title.\\n\\nCheck out docs/custom-covers.md in the [Timemap GitHub repo](https://github.com/forensic-architecture/timemap) for more information around how to specify custom covers.\",\n      exploreButton: \"EXPLORE\",\n    },\n    toolbar: {\n      panels: {\n        categories: {\n          default: {\n            icon: DEFAULT_TAB_ICONS.CATEGORY,\n            label: copy[language].toolbar.categories_label,\n            title: copy[language].toolbar.explore_by_category__title,\n            description:\n              copy[language].toolbar.explore_by_category__description,\n          },\n        },\n        filters: {\n          icon: DEFAULT_TAB_ICONS.FILTER,\n          label: copy[language].toolbar.filters_label,\n          title: copy[language].toolbar.explore_by_filter__title,\n          description: copy[language].toolbar.explore_by_filter__description,\n        },\n        narratives: {\n          icon: DEFAULT_TAB_ICONS.NARRATIVE,\n          label: copy[language].toolbar.narratives_label,\n          title: copy[language].toolbar.explore_by_narrative__title,\n          description: copy[language].toolbar.explore_by_narrative__description,\n        },\n        shapes: {\n          icon: DEFAULT_TAB_ICONS.SHAPE,\n          label: copy[language].toolbar.shapes_label,\n          title: copy[language].toolbar.explore_by_shape__title,\n          description: copy[language].toolbar.explore_by_shape__description,\n        },\n        download: {\n          icon: DEFAULT_TAB_ICONS.DOWNLOAD,\n          label: copy[language].toolbar.download.button,\n          title: copy[language].toolbar.download.panel.title,\n          description: copy[language].toolbar.download.panel.description,\n        },\n      },\n    },\n    loading: false,\n  },\n\n  /*\n   * The 'ui' subtree of this state refers the state of the cosmetic\n   *   elements of the application, such as color palettes of clusters\n   *   as well as dom elements to attach SVG\n   */\n  ui: {\n    tiles: {\n      current: \"openstreetmap\", // ['openstreetmap', 'streets', 'satellite']\n      default: \"openstreetmap\", // ['openstreetmap', 'streets', 'satellite']\n      satellite: \"satellite\",\n    },\n    style: {\n      categories: {\n        default: global.fallbackEventColor,\n      },\n      narratives: {\n        default: {\n          opacity: 0.9,\n          stroke: global.fallbackEventColor,\n          strokeWidth: 3,\n        },\n      },\n      regions: {\n        default: {\n          stroke: \"blue\",\n          strokeWidth: 3,\n          opacity: 0.9,\n        },\n      },\n      clusters: {\n        radial: false,\n      },\n    },\n    card: {\n      layout: {\n        template: \"basic\",\n      },\n    },\n    coloring: {\n      maxNumOfColors: 4,\n      colors: Object.values(colors),\n    },\n    dom: {\n      timeline: \"timeline\",\n      timeslider: \"timeslider\",\n      map: \"map\",\n    },\n    eventRadius: 8,\n  },\n\n  features: {\n    USE_COVER: false,\n    USE_ASSOCIATIONS: false,\n    USE_SITES: false,\n    USE_SOURCES: false,\n    USE_REGIONS: false,\n    GRAPH_NONLOCATED: false,\n    HIGHLIGHT_GROUPS: false,\n  },\n};\n\nlet appStore;\nif (config.store) {\n  appStore = mergeDeepLeft(config.store, initial);\n} else {\n  appStore = initial;\n}\n\nappStore.app.flags.isIntropopup = !!appStore.app.intro;\n\nif (\"map\" in appStore.app) {\n  appStore.app.map = mergeDeepLeft(appStore.app.map, mapInitial);\n}\n\nif (\"space3d\" in appStore.app) {\n  appStore.app.space3d = mergeDeepLeft(appStore.app.space3d, space3dInitial);\n}\n\nexport default appStore;\n"
  },
  {
    "path": "src/store/plugins/urlState/applyUrlState.js",
    "content": "import { isEmptyObject } from \"../../../common/utilities\";\nimport { SCHEMA } from \"./schema\";\nimport URLState from \"./urlState\";\n\nexport function applyUrlState(state) {\n  const urlState = new URLState().deserialize();\n  if (isEmptyObject(urlState)) return state;\n\n  const nextState = { ...state };\n\n  Object.values(SCHEMA).forEach((s) => {\n    try {\n      s.rehydrate(nextState, urlState);\n    } catch (err) {\n      console.error(err);\n    }\n  });\n\n  return nextState;\n}\n"
  },
  {
    "path": "src/store/plugins/urlState/index.js",
    "content": "export { applyUrlState } from \"./applyUrlState\";\nexport { urlStateMiddleware } from \"./middleware\";\n"
  },
  {
    "path": "src/store/plugins/urlState/middleware.js",
    "content": "import { SCHEMA } from \"./schema\";\nimport URLState from \"./urlState\";\n\nexport function urlStateMiddleware(store) {\n  return (next) => (action) => {\n    const result = next(action);\n\n    try {\n      const schemas = Object.values(SCHEMA).filter(\n        (s) => s.trigger === action.type\n      );\n\n      if (schemas.length) {\n        const urlState = new URLState();\n        const state = store.getState();\n        schemas.forEach((s) => {\n          urlState.set(s.key, s.dehydrate(state));\n        });\n        urlState.serialize();\n      }\n    } catch (err) {\n      console.error(\"error serializing url state\", err);\n    }\n\n    return result;\n  };\n}\n"
  },
  {
    "path": "src/store/plugins/urlState/schema.js",
    "content": "import {\n  TOGGLE_ASSOCIATIONS,\n  UPDATE_COLORING_SET,\n  UPDATE_SELECTED,\n  UPDATE_TIMERANGE,\n  UPDATE_MAP_VIEW,\n} from \"../../../actions\";\nimport { ASSOCIATION_MODES } from \"../../../common/constants\";\nimport { createFilterPathString } from \"../../../common/utilities\";\nimport {\n  getSelected,\n  getTimeRange,\n  selectActiveColorSets,\n  selectActiveFilterIds,\n  getMapLat,\n  getMapLng,\n  getMapZoom,\n} from \"../../../selectors\";\n\nexport const SCHEMA_TYPES = {\n  NUMBER: \"NUMBER\",\n  NUMBER_ARRAY: \"NUMBER_ARRAY\",\n  STRING: \"STRING\",\n  STRING_ARRAY: \"STRING_ARRAY\",\n  DATE: \"DATE\",\n  DATE_ARRAY: \"DATE_ARRAY\",\n};\n\nexport function isSchemaArray(schema) {\n  return [\n    SCHEMA_TYPES.DATE_ARRAY,\n    SCHEMA_TYPES.NUMBER_ARRAY,\n    SCHEMA_TYPES.STRING_ARRAY,\n  ].includes(schema.type);\n}\n\n/**\n * Schema specifies how redux state maps to the url and vice versa.\n * `trigger`: action that triggers a call to `dehydrate()`\n * `type`: type of the mapped URL property\n * `dehydrate()`: maps redux state to url state.\n * `rehydrate()`:\n *    maps url state to redux state.\n *    !for performance reasons, this function works with a mutable ref to `state`!\n */\nexport const SCHEMA = Object.freeze({\n  id: {\n    key: \"id\",\n    trigger: UPDATE_SELECTED,\n    type: SCHEMA_TYPES.STRING_ARRAY,\n    dehydrate(state) {\n      return getSelected(state).map(({ civId }) => civId);\n    },\n    // TODO: determine time range if `range` not set.\n    rehydrate(nextState, { id }) {\n      if (id?.length) {\n        nextState.app.selected = id.reduce((acc, curr) => {\n          const event = nextState.domain.events.find((e) => e.civId === curr);\n\n          if (event) {\n            acc.push(event);\n          } else {\n            console.warn(\n              `event ${curr} could not be rehydrated. reason: not present.`\n            );\n          }\n\n          return acc;\n        }, []);\n      }\n    },\n  },\n  hid: {\n    key: \"hid\",\n    trigger: null, // Read-only from URL, no action triggers update\n    type: SCHEMA_TYPES.STRING_ARRAY,\n    dehydrate() {\n      return []; // Never update URL from state\n    },\n    rehydrate(nextState, { hid }) {\n      if (hid?.length) {\n        nextState.app.highlighted = hid;\n      }\n    },\n  },\n  range: {\n    key: \"range\",\n    trigger: UPDATE_TIMERANGE,\n    type: SCHEMA_TYPES.DATE_ARRAY,\n    dehydrate(state) {\n      return getTimeRange(state);\n    },\n    rehydrate(nextState, { range }) {\n      if (range?.length === 2) {\n        const val = Array.from(range);\n        val.sort((a, b) => new Date(a) - new Date(b));\n        // HACK! diversion from upstream: we use a custom timeline state format.\n        nextState.app.timeline = {\n          ...nextState.app.timeline,\n          range: {\n            ...nextState.app.timeline.range,\n            current: val,\n          },\n        };\n      }\n    },\n  },\n  filter: {\n    key: \"filter\",\n    trigger: TOGGLE_ASSOCIATIONS,\n    type: SCHEMA_TYPES.STRING_ARRAY,\n    dehydrate(state) {\n      return selectActiveFilterIds(state);\n    },\n    // TODO: set parent filters if all children checked.\n    rehydrate(nextState, { filter }) {\n      if (filter?.length) {\n        const filters = nextState.domain.associations.filter(\n          (x) => x.mode === ASSOCIATION_MODES.FILTER\n        );\n        const filterMapping = mapFilterIdsToPaths(filters);\n        nextState.app.associations.filters = filter.map(\n          (id) => filterMapping[id]\n        );\n      }\n    },\n  },\n  color: {\n    key: \"color\",\n    trigger: UPDATE_COLORING_SET,\n    type: SCHEMA_TYPES.STRING_ARRAY,\n    dehydrate(state) {\n      return selectActiveColorSets(state);\n    },\n    // TODO: color parent if all children checked.\n    rehydrate(state, { color }) {\n      if (color?.length) {\n        const filters = state.domain.associations.filter(\n          (x) => x.mode === ASSOCIATION_MODES.FILTER\n        );\n        const filterMapping = mapFilterIdsToPaths(filters);\n        state.app.associations.coloringSet = color.map((set) =>\n          set.split(\",\").map((id) => filterMapping[id])\n        );\n      }\n    },\n  },\n  lat: {\n    key: \"lat\",\n    trigger: UPDATE_MAP_VIEW,\n    type: SCHEMA_TYPES.NUMBER,\n    dehydrate(state) {\n      return getMapLat(state);\n    },\n    rehydrate(state, { lat }) {\n      if (lat != null && state.app.map) {\n        state.app.map = {\n          ...state.app.map,\n          anchor: [lat, state.app.map.anchor[1]],\n        };\n      }\n    },\n  },\n  lng: {\n    key: \"lng\",\n    trigger: UPDATE_MAP_VIEW,\n    type: SCHEMA_TYPES.NUMBER,\n    dehydrate(state) {\n      return getMapLng(state);\n    },\n    rehydrate(state, { lng }) {\n      if (lng != null && state.app.map) {\n        state.app.map = {\n          ...state.app.map,\n          anchor: [state.app.map.anchor[0], lng],\n        };\n      }\n    },\n  },\n  zoom: {\n    key: \"zoom\",\n    trigger: UPDATE_MAP_VIEW,\n    type: SCHEMA_TYPES.NUMBER,\n    dehydrate(state) {\n      return getMapZoom(state);\n    },\n    rehydrate(state, { zoom }) {\n      if (zoom != null && state.app.map) {\n        state.app.map = {\n          ...state.app.map,\n          startZoom: zoom,\n        };\n      }\n    },\n  },\n});\n\nfunction mapFilterIdsToPaths(filters) {\n  return filters.reduce((acc, curr) => {\n    acc[curr.id] = createFilterPathString(curr);\n    return acc;\n  }, {});\n}\n"
  },
  {
    "path": "src/store/plugins/urlState/urlState.js",
    "content": "import dayjs from \"dayjs\";\nimport { isSchemaArray, SCHEMA, SCHEMA_TYPES } from \"./schema\";\n\nexport class URLState {\n  constructor() {\n    this.url = new URL(window.location);\n    this.schema = SCHEMA;\n  }\n\n  delete(key) {\n    this.url.searchParams.delete(key);\n  }\n\n  /**\n   * `key` not declared in `schema` will be ignored.\n   * `value` is encoded according to the schema.\n   * if the schema declares `isArray: true`, `value` is required be an array.\n   */\n  set(key, value) {\n    const schema = this.schema[key];\n    if (!schema) return;\n\n    this.delete(key);\n\n    // HACK! diversion from upstream: we use a custom timeline state format.\n    if (schema.type === SCHEMA_TYPES.DATE_ARRAY) {\n      value.current.forEach((val) => {\n        const encoded = this._encode(schema, val);\n        if (encoded) this.url.searchParams.append(key, encoded);\n      });\n    } else if (isSchemaArray(schema)) {\n      value.forEach((val) => {\n        const encoded = this._encode(schema, val);\n        if (encoded) this.url.searchParams.append(key, encoded);\n      });\n    } else {\n      const encoded = this._encode(schema, value);\n      if (encoded) this.url.searchParams.set(key, encoded);\n    }\n  }\n\n  serialize() {\n    window.history.replaceState(null, \"\", this.url);\n  }\n\n  /**\n   * Returns URL state as object.\n   * Values are decoded according to schema.\n   */\n  deserialize() {\n    const state = {};\n\n    this.url.searchParams.forEach((_, key) => {\n      if (state[key] != null) return;\n\n      const schema = this.schema[key];\n      // ignore unknown query parameters\n      if (!schema) return;\n\n      state[key] = isSchemaArray(schema)\n        ? this.url.searchParams\n            .getAll(key)\n            .map((val) => this._decode(schema, val))\n        : this._decode(schema, this.url.searchParams.get(key));\n    });\n\n    return state;\n  }\n\n  _decode(schema, value) {\n    switch (schema.type) {\n      case SCHEMA_TYPES.NUMBER_ARRAY:\n      case SCHEMA_TYPES.NUMBER: {\n        return +value;\n      }\n\n      case SCHEMA_TYPES.DATE:\n      case SCHEMA_TYPES.DATE_ARRAY: {\n        return new Date(value);\n      }\n\n      default: {\n        if (value === \"null\" || value === \"undefined\") return undefined;\n        return value;\n      }\n    }\n  }\n\n  _encode(schema, value) {\n    switch (schema.type) {\n      case SCHEMA_TYPES.NUMBER_ARRAY:\n      case SCHEMA_TYPES.NUMBER: {\n        return value.toString();\n      }\n\n      case SCHEMA_TYPES.DATE:\n      case SCHEMA_TYPES.DATE_ARRAY: {\n        return dayjs(value).format(\"YYYY-MM-DD\");\n      }\n\n      default: {\n        return value;\n      }\n    }\n  }\n}\n\nexport default URLState;\n"
  },
  {
    "path": "src/test/App.test.jsx",
    "content": "import { render, screen } from \"@testing-library/react\";\nimport { Provider } from \"react-redux\";\n\nimport store from \"../store\";\nimport App from \"../components/App\";\n\nit(\"renders an option to view categories\", () => {\n  render(\n    <Provider store={store}>\n      <App />\n    </Provider>\n  );\n\n  expect(screen.getAllByText(\"Filters\").length).toBeGreaterThan(0);\n});\n"
  },
  {
    "path": "test/__mocks__/fileMock.js",
    "content": "module.exports = \"test-file-stub\";"
  },
  {
    "path": "test/__mocks__/styleMock.js",
    "content": "// @see https://github.com/keyz/identity-obj-proxy\nconst identityObject = new Proxy(\n  {},\n  {\n    get(_, key) {\n      return key === \"__esModule\" ? false : key;\n    },\n  }\n);\n\nmodule.exports = identityObject;\n"
  },
  {
    "path": "test/setup.js",
    "content": "require(\"@testing-library/jest-dom\");\n\n// HACK\nglobal.fetch = () => Promise.resolve();\n"
  },
  {
    "path": "vite.config.js",
    "content": "import { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n  plugins: [react()],\n  build: {\n    outDir: \"build\"\n  },\n  server: {\n    proxy: {\n      \"/api\": {\n        target: \"https://ukraine.bellingcat.com/ukraine-server\",\n        changeOrigin: true,\n      },\n      \"/timemap\": {\n        target: \"https://bellingcat-embeds.ams3.cdn.digitaloceanspaces.com/production/ukr\",\n        changeOrigin: true,\n      }\n    }\n  },\n  test: {\n    globals: true,\n    environment: \"jsdom\",\n    setupFiles: \"./test/setup.js\",\n    passWithNoTests: true\n  },\n});\n"
  }
]