Repository: bellingcat/ukraine-timemap Branch: main Commit: f6cc37306c81 Files: 158 Total size: 372.5 KB Directory structure: gitextract_fsuxlpf2/ ├── .dockerignore ├── .eslintrc.js ├── .github/ │ ├── ISSUE_TEMPLATE.md │ └── workflows/ │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .prettierrc ├── .travis.yml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE.md ├── README.md ├── config.js ├── docs/ │ ├── configuration.md │ └── custom-covers.md ├── example.config.js ├── index.html ├── package.json ├── public/ │ ├── CNAME │ └── index.html ├── src/ │ ├── actions/ │ │ └── index.js │ ├── common/ │ │ ├── constants.js │ │ ├── data/ │ │ │ ├── copy.json │ │ │ └── es-MX.json │ │ ├── global.js │ │ └── utilities.js │ ├── components/ │ │ ├── App.jsx │ │ ├── InfoPopup.jsx │ │ ├── Layout.jsx │ │ ├── Notification.jsx │ │ ├── Portal.jsx │ │ ├── TemplateCover.jsx │ │ ├── Toolbar.jsx │ │ ├── atoms/ │ │ │ ├── Checkbox.jsx │ │ │ ├── CoeventIcon.jsx │ │ │ ├── ColoredMarkers.jsx │ │ │ ├── Content.jsx │ │ │ ├── Controls.jsx │ │ │ ├── CoverIcon.jsx │ │ │ ├── InfoIcon.jsx │ │ │ ├── Loading.jsx │ │ │ ├── Md.jsx │ │ │ ├── Media.jsx │ │ │ ├── NoSource.jsx │ │ │ ├── Popup.jsx │ │ │ ├── RefreshIcon.jsx │ │ │ ├── RouteIcon.jsx │ │ │ ├── SitesIcon.jsx │ │ │ ├── Spinner.jsx │ │ │ └── StaticPage.jsx │ │ ├── controls/ │ │ │ ├── BottomActions.jsx │ │ │ ├── Card.jsx │ │ │ ├── CardStack.jsx │ │ │ ├── CategoriesListPanel.jsx │ │ │ ├── DownloadButton.jsx │ │ │ ├── DownloadPanel.jsx │ │ │ ├── FilterListPanel.jsx │ │ │ ├── FullScreenToggle.jsx │ │ │ ├── NarrativeControls.jsx │ │ │ ├── Search.jsx │ │ │ ├── ShapesListPanel.jsx │ │ │ └── atoms/ │ │ │ ├── Button.jsx │ │ │ ├── Caret.jsx │ │ │ ├── CustomField.jsx │ │ │ ├── Media.jsx │ │ │ ├── NarrativeAdjust.jsx │ │ │ ├── NarrativeCard.jsx │ │ │ ├── NarrativeClose.jsx │ │ │ ├── PanelTree.jsx │ │ │ ├── SearchRow.jsx │ │ │ ├── TelegramEmbed.jsx │ │ │ ├── Text.jsx │ │ │ ├── Time.jsx │ │ │ ├── ToolbarButton.jsx │ │ │ └── TwitterTweet.jsx │ │ ├── space/ │ │ │ ├── Space.jsx │ │ │ └── carto/ │ │ │ ├── Map.jsx │ │ │ └── atoms/ │ │ │ ├── Clusters.jsx │ │ │ ├── DefsMarkers.jsx │ │ │ ├── Events.jsx │ │ │ ├── Narratives.jsx │ │ │ ├── Regions.jsx │ │ │ ├── SatelliteOverlayToggle.jsx │ │ │ ├── SelectedEvents.jsx │ │ │ ├── Sites.jsx │ │ │ └── __tests__/ │ │ │ └── SatelliteOverlayToggle.spec.jsx │ │ └── time/ │ │ ├── Axis.jsx │ │ ├── Categories.jsx │ │ ├── Timeline.jsx │ │ └── atoms/ │ │ ├── Clip.jsx │ │ ├── DatetimeBar.jsx │ │ ├── DatetimeDot.jsx │ │ ├── DatetimePentagon.jsx │ │ ├── DatetimeSquare.jsx │ │ ├── DatetimeStar.jsx │ │ ├── DatetimeTriangle.jsx │ │ ├── Events.jsx │ │ ├── Handles.jsx │ │ ├── Header.jsx │ │ ├── Labels.jsx │ │ ├── Markers.jsx │ │ ├── Project.jsx │ │ └── ZoomControls.jsx │ ├── index.jsx │ ├── reducers/ │ │ ├── __tests__/ │ │ │ ├── index.spec.js │ │ │ └── ui.spec.js │ │ ├── app.js │ │ ├── domain.js │ │ ├── features.js │ │ ├── index.js │ │ ├── root.js │ │ ├── ui.js │ │ └── validate/ │ │ ├── associationsSchema.js │ │ ├── eventSchema.js │ │ ├── regionSchema.js │ │ ├── shapeSchema.js │ │ ├── siteSchema.js │ │ ├── sourceSchema.js │ │ └── validators.js │ ├── scss/ │ │ ├── _burger.scss │ │ ├── _icons.scss │ │ ├── _variables.scss │ │ ├── button.scss │ │ ├── card.scss │ │ ├── cardstack.scss │ │ ├── common.scss │ │ ├── cover.scss │ │ ├── header.scss │ │ ├── infopopup.scss │ │ ├── loading.scss │ │ ├── main.scss │ │ ├── map.scss │ │ ├── mediaplayer.scss │ │ ├── narrativecard.scss │ │ ├── notification.scss │ │ ├── overlay.scss │ │ ├── popup.scss │ │ ├── satelliteoverlaytoggle.scss │ │ ├── search.scss │ │ ├── tabs.scss │ │ ├── timeline.scss │ │ ├── toolbar.scss │ │ └── video.scss │ ├── selectors/ │ │ ├── __tests__/ │ │ │ └── timeline.spec.js │ │ ├── helpers.js │ │ └── index.js │ ├── store/ │ │ ├── index.js │ │ ├── initial.js │ │ └── plugins/ │ │ └── urlState/ │ │ ├── applyUrlState.js │ │ ├── index.js │ │ ├── middleware.js │ │ ├── schema.js │ │ └── urlState.js │ └── test/ │ └── App.test.jsx ├── test/ │ ├── __mocks__/ │ │ ├── fileMock.js │ │ └── styleMock.js │ └── setup.js └── vite.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ node_modules/ build/ example.config.js ================================================ FILE: .eslintrc.js ================================================ module.exports = { root: true, plugins: [], parserOptions: { sourceType: "module", ecmaFeatures: { jsx: true, }, }, settings: { react: { version: "detect", }, }, extends: [ "eslint:recommended", "plugin:react/recommended", "plugin:react/jsx-runtime", "plugin:react-hooks/recommended", "prettier", ], env: { browser: true, es2022: true, jest: true, }, rules: { "react/prop-types": 0, } }; ================================================ FILE: .github/ISSUE_TEMPLATE.md ================================================ Environment ----------- * Your version (in package.json) or git commit hash * Your operating system and version: Steps to reproduce (for bugs only) ----------------------------- 1. 2. 3. Current Behavior ---------------- Expected Behavior ----------------- ================================================ FILE: .github/workflows/cd.yml ================================================ name: CD on: push: branches: [ develop ] # pull_request: # branches: [ develop ] jobs: build: runs-on: ubuntu-latest steps: - name: Trigger CD build uses: peter-evans/repository-dispatch@v1 with: token: ${{ secrets.CI_DISPATCH_TOKEN }} repository: forensic-architecture/configs event-type: remote-build client-payload: '{"runtime_args": "timemap", "branch": "${GITHUB_REF##*/}"}' ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: [ develop ] pull_request: branches: [ develop ] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 with: ref: ${{ github.head_ref }} - uses: actions/setup-node@v2-beta with: node-version: '12' - run: npm install - run: cp example.config.js config.js - run: npm test env: CI: true - run: npm run lint env: CI: true ================================================ FILE: .gitignore ================================================ .idea/ build/ node_modules/ dev.config.js !config/webpack*.config.js !config/getHttpsConfig.js tags tags.lock tags.temp .eslintcache src/\.DS_Store src/assets/fonts \.DS_Store tags ================================================ FILE: .prettierrc ================================================ ================================================ FILE: .travis.yml ================================================ language: node_js node_js: - "stable" cache: directories: - node_modules before_script: - cp example.config.js config.js install: - npm install script: - npm run lint - npm run build - npm run test ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to timemap Hello! Thanks for being part of the Bellingcat Tech Community 💪 We really appreciate your ideas, thoughts, and involvement. Read on for guidance on how to contribute to this project 🏆 Contributions to this project are released to the public under the project's open source license. Please note that this project is released with a [Contributor Code of Conduct](https://github.com/bellingcat/.github/blob/main/CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. ## What do I need to know to help? ### Javascript / React / Redux In order to contribute code upstream, you'll likely need to have a sense of ES6 Javascript, React, and Redux. If these terms are new to you, or not as familiar as you might like, here's a good tutorial to get you up to speed: - [Building a voting app with Redux and React](https://teropa.info/blog/2015/09/10/full-stack-redux-tutorial.html) ### Node JS and Docker Timemap doesn't actually use these technologies; but the main way of getting up and running with a data provider for timemap, [datasheet-server](https://github.com/bellingcat/datasheet-server), does, and so they're helpful to know. ## Do I need to be an experienced JS developer? Contributing can of course be about contributing code, but it can also take many other forms. A great amount of work that remains to be done to make timemap a usable community tool doesn't involve writing any code. The following are all very welcome contributions: - Writing, updating or correcting documentation - Fixing an open issue - Requesting a feature - Reporting a bug If you're new to this project, you could check the issues that are tagged ["good first issue"](https://github.com/bellingcat/ukraine-timemap/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22). These are a range of the issues that have come up in conversation for which we would welcome community contributions. These are, however, by no means exhaustive! If you see a gap or have an idea, please open up an issue to discuss it with timemap's maintainers. ## How do I make a contribution? 1. Make sure you have a [GitHub account](https://github.com/signup/free) 2. Fork the repository on GitHub. This is necessary so that you can push your changes, as you can't do this directly on our repo. 3. Get set up with a local instance of timemap and datasheet-server. The easiest way to do this is by reading [this blog post on the forensic architecture website](https://forensic-architecture.org/investigation/timemap-for-cartographic-platforms). 4. [Join the Bellingcat Discord server](https://discord.gg/bellingcat), this is our main community hub. Check out the #tool-and-sites and #tech-support channels for support with the ukraine-timemap. Also consider joining the [Forensic Architecture Discord](https://discord.gg/PjHKHJD5KX), the #timemap and #support channels are the two best channels to ask questions about setting timemap up. Once you're set up with a local copy of timemap and datasheet-server, you can start modifying code and making changes. When you're ready to submit a contribution, you can do it by making a pull request from a branch on your forked copy of timemap to this repository. You can do this with the following steps: 1. Push the changes to a remote repository. If the changes you have made address a bug, you should name it `bug/{briefdesc}`, where `{briefdesc}` is a hyphen-separated description of your change. If instead you are contributing changes as a feature request, name it `feature/{briefdesc`}. If in doubt, prefix your branch with `feature/`. 2. Submit a pull request to the `develop` branch of `forensic-architecture/timemap`. 3. Wait for the pull request to be reviewed by a maintainer. 4. Make changes to the pull request if the reviewing maintainer recommends them. 5. Celebrate your success once your pull request is merged! ## How do I validate my changes? We are still working on a set of tests. Right now, it is enough to confirm that the application runs as expected with `npm run dev`. If your changes introduce other issues, a maintainer will flag it in stage 3 of the submission process above. ## Credits This contributing guide is based on the guidelines of both the [SuperCollider contributing guide](https://raw.githubusercontent.com/supercollider/supercollider/develop/CONTRIBUTING.md), and the [nteract contributing guide](https://github.com/nteract/nteract/blob/master/CONTRIBUTING.md) (two excellent open source projects!). Thanks to [Scott Carver](https://github.com/scztt) for advice on how to put a guide together. ================================================ FILE: Dockerfile ================================================ FROM mhart/alpine-node:10.11 LABEL authors="Lachlan Kermode " # Install app dependencies COPY package.json /www/package.json RUN cd /www; yarn # Copy app source COPY . /www WORKDIR /www RUN yarn build # files available to copy at /www/build ================================================ FILE: LICENSE.md ================================================ Do No Harm License **Preamble** Most software today is developed with little to no thought of how it will be used, or the consequences for our society and planet. As software developers, we engineer the infrastructure of the 21st century. We recognise that our infrastructure has great power to shape the world and the lives of those we share it with, and we choose to consciously take responsibility for the social and environmental impacts of what we build. We envisage a world free from injustice, inequality, and the reckless destruction of lives and our planet. We reject slavery in all its forms, whether by force, indebtedness, or by algorithms that hack human vulnerabilities. We seek a world where humankind is at peace with our neighbours, nature, and ourselves. We want our work to enrich the physical, mental and spiritual wellbeing of all society. We build software to further this vision of a just world, or at the very least, to not put that vision further from reach. **Terms** *Copyright* (c) 2019 Forensic Architecture. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 4. This software must not be used by any organisation, website, product or service that: a) lobbies for, promotes, or derives a majority of income from actions that support or contribute to: * sex trafficking * human trafficking * slavery * indentured servitude * gambling * tobacco * adversely addictive behaviours * nuclear energy * warfare * weapons manufacturing * war crimes * violence (except when required to protect public safety) * burning of forests * deforestation * hate speech or discrimination based on age, gender, gender identity, race, sexuality, religion, nationality b) lobbies against, or derives a majority of income from actions that discourage or frustrate: * peace * access to the rights set out in the Universal Declaration of Human Rights and the Convention on the Rights of the Child * peaceful assembly and association (including worker associations) * a safe environment or action to curtail the use of fossil fuels or prevent climate change * democratic processes 5. All redistribution of source code or binary form, including any modifications must be under these terms. You must inform recipients that the code is governed by these conditions, and how they can obtain a copy of this license. You may not attempt to alter the conditions of who may/may not use this software. We define: **Forests** to be 0.5 or more hectares of trees that were either planted more than 50 years ago or were not planted by humans or human made equipment. **Deforestation** to be the clearing, burning or destruction of 0.5 or more hectares of forests within a 1 year period. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. **Attribution** Do No Harm License [Contributor Covenant][homepage], (pre 1.0), available at https://github.com/raisely/NoHarm [homepage]: https://github.com/raisely/NoHarm ================================================ FILE: README.md ================================================

Civilian Harm in Ukraine TimeMap

Explore it in ukraine.bellingcat.com
Download/integrate the data from here (regularly updated dataset)

Read Bellingcat's article about this project in English (UK), Русский (Россия)

TimeMap is a tool for exploration, monitoring and classification of incidents in time and space, originally forked from forensic-architecture/timemap.



![ukraine.bellingcat.com timemap preview](docs/example-timemap.png) ## Development * `npm install` to setup * adjust any local configs in [config.js](config.js) * `CONFIG=config.js npm run dev` or `npm run dev` if the file is named config.js * For more info visit the [original repo](https://github.com/forensic-architecture/timemap) ## Deployment This project is now living in github pages and the API has switched to auto-updated S3 files. Access it at https://bellingcat-embeds.ams3.cdn.digitaloceanspaces.com/production/ukr/timemap/api.json Release with `npm run deploy`. ## Contributing Please read our [Contribution Guide](./CONTRIBUTING.md) and check our [Issues Page](https://github.com/bellingcat/ukraine-timemap/issues) for desired contributions, and feel free to suggest your own. ## Configurations
Documentation of config.js * `SERVER_ROOT` - points to the API base address * `XXXX_EXT` - points to the respective JSONs of the data, for events, sources, and associations * `API_DATA` - S3 file address that can be downloaded or integrated into external apps/visualizations * `DATE_FMT` and `TIME_FMT` - how to consume the events' date/time from the API * `store.app.map` - configures the initial map view and the UX limits * `store.app.cluster` - configures how clusters/bubbles are grouped into larger clusters, larger `radius` means bigger cluster bubbles * `store.app.timeline` - configure timeline ranges, zoom level options, and default range * `store.app.intro` - the intro panel that shows on start * `store.app.cover` - configuration for the full page cover, the `description` is a list of markdown entities, can also contain html * `store.ui.colors` and `store.ui.maxNumOfColors` are applied to filters, as they are selected Easiest way to deploy the static files is through * `nvm use 16` * `npm run build` (rather: `CI=false npm run build`) * copy the files to your server, for example to `/var/www/html`
================================================ FILE: config.js ================================================ const one_day = 1440; const config = { title: "ukraine", display_title: "Civilian Harm\nin Ukraine", SERVER_ROOT: "https://bellingcat-embeds.ams3.cdn.digitaloceanspaces.com/production/ukr", EVENTS_EXT: "/timemap/events.json", SOURCES_EXT: "/timemap/sources.json", ASSOCIATIONS_EXT: "/timemap/associations.json", API_DATA: "https://bellingcat-embeds.ams3.cdn.digitaloceanspaces.com/production/ukr/timemap/api.json", // MEDIA_EXT: "/api/media", DATE_FMT: "M/D/YYYY", TIME_FMT: "HH:mm", store: { app: { debug: true, map: { // anchor: [49.02421913, 31.43836003], anchor: [48.3326259, 33.19951447], maxZoom: 18, minZoom: 4, startZoom: 6, // maxBounds: [] }, cluster: { radius: 50, minZoom: 5, maxZoom: 12 }, associations: { defaultCategory: "Weapon System", }, timeline: { dimensions: { height: 90, contentHeight: 90, }, zoomLevels: [ // { label: "Zoom to 2 weeks", duration: 14 * one_day }, // { label: "Zoom to 1 month", duration: 31 * one_day }, // { label: "Zoom to 6 months", duration: 6 * 31 * one_day }, { label: "Zoom to 1 year", duration: 12 * 31 * one_day }, { label: "Zoom to 2 years", duration: 2 * 12 * 31 * one_day }, { label: "Zoom to 5 years", duration: 5 * 12 * 31 * one_day }, ], range: { /** * Initial date range shown on map load. * Use [start, end] (strings in ISO 8601 format) for a fixed range. * Use undefined for a dynamic initial range based on the browser time. */ initial: ["2022-02-01T00:00:00.000Z", "2025-08-31T23:59:59.999Z"], /** The number of days to show when using a dynamic initial range */ initialDaysShown: 31*12, limits: { /** Required. The lower bound of the range that can be accessed on the map. (ISO 8601) */ lower: "2022-02-01T00:00:00.000Z", /** * The upper bound of the range that can be accessed on the map. * Defaults to current browser time if undefined. */ upper: "2025-08-14T23:59:59.999Z", }, }, }, intro: [ '
Image: Vyacheslav Madiyevskyy/Reuters
Image: Järva Teataja/Scanpix Baltics via Reuters
', '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 here.', '

Editor\'s note: 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.

', ], flags: { isInfopoup: false, isCover: false }, cover: { title: "About and Methodology", exploreButton: "BACK TO THE PLATFORM", description: [ "## Scope of Research", "This database, organised on Forensic Architecture's [TimeMap](https://github.com/forensic-architecture/timemap) platform and customised for this project, is focused on incidents in Ukraine that have resulted in potential civilian harm. These include: incidents where rockets or missiles struck civilian areas, where attacks have resulted in the destruction of civilian infrastructure, where the presence of civilian injuries are visible and/or the presence of immobile civilian bodies. This database began collection on February 24, 2022 and intends to be a living document that will continue to be updated as long as the conflict persists. While we are attempting to collect as many incidents as possible, we cannot possibly guarantee to collect them all nor will we be able to corroborate the locations of all the incidents we collect. Those we do not corroborate the originality or exact location of will not be shown on the map. Therefore, this map is not an exhaustive list of civilian harm in Ukraine but rather a representation of all incidents which we have been able to collect and of which we have been able to determine the exact locations. ", "## Open Source Footage", "The links in this map are all open source, meaning they are connected to an open link posted online. These sources were collected by Bellingcat researchers and placed in a database from where they are also being archived locally. After collection, our Global Authentication Project members have determined the location of each of these events (you can read more about the Global Authentication Project and its makeup below). Bellingcat staff then cross-referenced these coordinates to ensure their accuracy. The resolution of these geolocations is within 150 metres of where the incident occurred but the public coordinates viewable on the map have been slightly obscured in order to protect the identity of the creators. Because this footage is open source, the users who uploaded the content are not directly affiliated to Bellingcat or our partners. Any opinions that may be contained within the posts are therefore not those of Bellingcat or our partners. Any claims contained within the posts have also not necessarily been confirmed or verified by Bellingcat, particularly in relation to which party may have been responsible for the incidents detailed.", "## Verification Level", "The data being collected is checked for originality, basic manipulation, and location by Bellingcat investigators. This level of verification is intended to indicate where incidents took place, when and where there are reasonable visual indications of civilian harm. Our investigation plan for the collection of this material and its uses are informed by the [Berkeley Protocol on Digital Open Source Investigations](https://www.ohchr.org/en/publications/policy-and-methodological-publications/berkeley-protocol-digital-open-source). These incidents are also being collected and archived at a [forensic level](https://mnemonic.org/en/our-work) for potential evidentiary use in the future. That level of in-depth analysis and verification will take many months and our goal with this map is to transparently report on the current situation in Ukraine, as it is happening, for public interest. To be clear, these two processes will be separate.", "## Descriptions", "Each incident is accompanied with source links, the exact location determined by our Global Authentication Project and Bellingcat researchers, as well as a brief description of the incident based on what is visually present. The descriptions indicate what is clearly visible but do not attempt to make assumptions about the exact number of casualties or which party to the conflict is responsible due to those factors being difficult to fully determine from short, visual imagery alone.", "## Filters", "On the left hand side of the map, a user can toggle between different kinds of areas impacted. We are characterising the areas as residential, industrial, administrative, healthcare, school/childcare, military, commercial, religious, or undefined. Decisions on these classifications are based on visual evidence in the footage and what the area is reportedly used as. We cannot fully exclude or exhaustively search for the potential of military use in some of these areas.", "## Source Links/Embedding", "We have chosen to embed the social media links directly onto the platform. Should any be deleted by the uploader, they will still be visible on the map, but data on the post, user and footage will no longer be presented publicly. Where sensitive footage posted by individuals might allow them or their location to be identified, we have sought to preemptively take steps to anonymise these users.", "## Privacy concerns and respect for the dead ", "This footage is graphic and contains distressing scenes of war and conflict. Many of the areas represented are, at time of writing, also under attack both physically and through online attempts to discredit or harm users posting this content. For these reasons, we have chosen not to share certain posts that might indicate the direct identity of any of the persons filming. We have also filtered out posts that contain images where an immobile body is closely filmed and their identity might be ascertained out of respect for them and their close ones.", "## A Note on Bellingcat's Global Authentication Project", "The Global Authentication Project consists of a wide community of open source researchers assisting in Bellingcat research through structured tasks and feedback. Our aim is to authenticate events taking place around the world and fill in the gaps of knowledge that exist, particularly in situations where there are vast quantities of data. In creating a community for those interested in open source research, we are fostering Bellingcat's original aim of solving problems **together**, to diversify our investigations and promote the use of these skills. For this dataset, we are working with many individuals who have Ukrainian language skills and others with local contextual knowledge of the events and places seen on the map. Other participants include individuals skilled in geolocation and chronolocation, with all contributions being vetted by Bellingcat researchers. As we expand the Global Authentication Project in the coming months, more information will be available on our website and Twitter.", "## Feedback", "This map will continue to change and be updated for the duration of this conflict. We welcome feedback on our methodology, data collection and take transparency seriously. Should you have any direct feedback about the platform, please indicate it on this [form](https://forms.gle/cV2YAojBoh6h4T3XA).", ], }, toolbar: { panels: { categories: { // TRUE: { // icon: "public", // label: "Verified", // description: "todo", // }, // FALSE: { // icon: "public", // label: "Unverified", // description: "todo", // } }, }, }, spotlights: {}, }, ui: { coloring: { mode: "STATIC", maxNumOfColors: 9, defaultColor: "#dfdfdf", colors: [ "#7E57C2", "#F57C00", "#FFEB3B", "#D34F73", "#08B2E3", "#A1887F", "#90A4AE", "#E57373", "#80CBC4", ], }, card: { layout: { template: "sourced", }, }, carto: { eventRadius: 8, }, timeline: { eventRadius: 9, }, tiles: { current: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}", default: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}", satellite: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" }, }, features: { USE_CATEGORIES: false, CATEGORIES_AS_FILTERS: false, COLOR_BY_CATEGORY: false, COLOR_BY_ASSOCIATION: true, USE_ASSOCIATIONS: true, USE_FULLSCREEN: true, USE_DOWNLOAD: true, USE_SOURCES: true, USE_SPOTLIGHTS: false, USE_SHAPES: false, USE_COVER: true, USE_INTRO: false, USE_SATELLITE_OVERLAY_TOGGLE: true, USE_SEARCH: false, USE_SITES: false, ZOOM_TO_TIMEFRAME_ON_TIMELINE_CLICK: one_day, FETCH_EXTERNAL_MEDIA: false, USE_MEDIA_CACHE: false, GRAPH_NONLOCATED: false, NARRATIVE_STEP_STYLES: false, CUSTOM_EVENT_FIELDS: [], }, }, }; export default config; ================================================ FILE: docs/configuration.md ================================================ # Configuration **NOTE: WIP. These settings are currently slightly out of date.** In order to make timemap interesting, you need to configure it to read events. When loaded in a browser, timemap queries HTTP endpoint, expecting from them well-defined JSON objects. There are certain endpoints, such as `events`, that are required, while others , such as `filters`, are optional; when provided, they enhance a timemap instance with additional features and capabilities related to the additional data. The URLs for these endpoints, as well as other configurable settings in your timemap instance, are read from the `config.js` that you created in step 3 of the setup above. The example contains sensible defaults. This section covers each option in more detail: | Option | Description | Type | Nullable | | ------- | ----------- | ---- | -------- | | title | Title of the application, display in the toolbar | String | No | | SERVER_ROOT | Base URI for the server | String | No | | EVENT_EXT | Endpoint for events, which will be concatenated with SERVER_ROOT | String | No | | EVENT_DESC_ROOT | Endpoint for additional metadata for each individual event, concatenated to SERVER_ROOT | String | Yes | | CATEGORY_EXT | Endpoint for categories, concatenated with SERVER_ROOT | String | Yes | | NARRATIVE_EXT | Endpoint for narratives, concatenated with SERVER_ROOT | String | No | | FILTER_TREE_EXT | Endpoint for filters, concatenated with SERVER_ROOT | String | Yes | | SITES_EXT | Endpoint for sites, concatenated with SERVER_ROOT | String | Yes | | MAP_ANCHOR | Geographic coordinates for original map anchor | Array of numbers | No | | features.USE_ASSOCIATIONS | Enable / Disable filters | boolean | No | | features.USE_SITES | Enable / Disable sites | boolean | No | In this configuration file you'll need to replace the required endpoints with functioning ones. Additionally, you'll want to initialize your application set in `MAP_ANCHOR`, as a (lat, long) pair, which determines the specific location at which the application will center itself on start. ### Data requirements This section outlines the data requirements for each HTTP endpoint. The sum total of data that is fetched asynchronously in a timemap instance is referred to as the application `domain`. The base endpoint for the domain-- and the paths to required and optional endpoints-- are configured through a `config.js` file in timemap's root folder (explained in the next section). #### Required endpoints 1. **Events**: incidents mapped in time and space are called `events`. They must include the following fields: ```json [ { "desc":"SOME DESCRIPTION TEXT", "date":"8/23/2011", "time":"18:30", "location":"LOCATION_NAME", "lat":"17.810358", "long":"-18.2251664", "source":"", "filters": "", "category": "" } ] ``` 2. **Categories**: events must be grouped in `categories`. **All `events` must contain one (and only one) `category`.** An event's category determines how it is displayed in the both the timeline and the map. (Category styling is configurable, but by default each category has an associated color, and a separate timeline for events in it.) Categories are designed to aggregate incidents according to some kind of categorical distinction, which will differ depending on your dataset. For example, categories may correspond to population groups, actions committed by particular persons. Categories should probably not be coded according to locality or temporality, as these axes are already represented. ```json [ { "category":"Category 00", "category_label":"Category Label", "group":"category_group00", "group_label":"Events" } ] ``` #### Optional endpoints 3. **Filters**: `events` can be filterged by multiple `filters`. These will further characterize the event, and allow to select or deselect based on them. Filters are or can be distributed in a tree-like hierarchy, and each node on the tree can be a filter, including those who are not leafs. ```json { "key":"filters", "children": { "filter0": { "key": "filter0 ", "children": { "filter00": { "key": "filter00", "children": { "filter001": { "key": "filter001", "children": {} } } }, "filter01": { "key": "filter01", "children": {} } } }, "filter1": { "key": "filter1", "children": { "filter10": { "key": "filter10", "children": {} } } } } } ``` 4. **Sites**: sites are labels on the map, aiming to highlight particularly relevant locations that should not be a function of time or filters. ```json [ { "id":"1", "description":"SITE_DESCRIPTION", "site":"SITE_LABEL", "latitude":"17.810358", "longitude":"-18.2251664" } ] ``` ================================================ FILE: docs/custom-covers.md ================================================ ## Using Timemap with a Custom Cover By default, instances of Timemap use no cover. By setting the `USE_COVER` flag to true in [config.js][], however, you can use explanatory text and videos that are displayed when your instance is first loaded. The structure you need to specify in `app.cover` in the Redux store overrides in config.js is outlined below: ```js const theCover = { // The video that plays in the background of the cover. Will otherwise be plain black bgVideo: "https://url-for-background-video.mp4", // Titles sit at the top of the screen title: "My Custom Timemap Instance", subtitle: "Mapping Events in my personal open source investigation", subsubtitle: "January 2020", // The main text on the cover. This string is markdown, so you can include links and styles. description: "A brief description of what the platform shows. [Links](https://forensic-architecture.org) can be written in markdown.", // Header videos sit above the 'EXPLORE' button, and can include a basic description, as well as translations. headerVideos: [ { buttonTitle: "ABOUT", desc: "This film details the investigation's methodology and findings at a high level.", file: ""https://url-to-video.mp4, poster: "https://url-for-thumbnail.png", title: "About the Investigation", translations: [ { code: "ITA", desc: "Italian translation", paths: ["https://url-to-video.mp4"], title: "Translated Title", }, { code: "RUS", desc: "Russian translation", paths: ["https://url-to-video.mp4"], title: "Translated Title", } ] }, { buttonTitle: "HOW TO USE", desc: "This step-by-step guide explores the way that the platform arranges and presents information.", file: ""https://url-to-video.mp4, poster: "https://url-for-thumbnail.png", title: "How to Use the Platform" } ], // These videos sit at the bottom of the page, beneath the rest of the // content. The max length of the list is 4 for stylistic reasons, any later // indices will not be shown. videos: [ { buttonTitle: "VERIFICATION:", buttonSubtitle: "How we verified data", desc: "This video shows how we verified the data.", file: "https://url-to-video.mp4", poster: "https://url-for-thumbnail.png", title: "Verifying data in this investigation" }, { buttonTitle: "VERIFICATION:", buttonSubtitle: "How we verified data", desc: "This video shows how we verified the data.", file: "https://url-to-video.mp4", poster: "https://url-for-thumbnail.png", title: "Verifying data in this investigation" }, { buttonTitle: "VERIFICATION:", buttonSubtitle: "How we verified data", desc: "This video shows how we verified the data.", file: "https://url-to-video.mp4", poster: "https://url-for-thumbnail.png", title: "Verifying data in this investigation" }, { buttonTitle: "VERIFICATION:", buttonSubtitle: "How we verified data", desc: "This video shows how we verified the data.", file: "https://url-to-video.mp4", poster: "https://url-for-thumbnail.png", title: "Verifying data in this investigation" } ] } module.exports = { // ... other values in config.js store: { app: { cover: theCover, // ... } // ... } } ``` ================================================ FILE: example.config.js ================================================ const config = { title: 'example', display_title: 'example', SERVER_ROOT: 'http://localhost:4040', EVENTS_EXT: '/api/timemap_data/export_events/deeprows', ASSOCIATIONS_EXT: '/api/timemap_data/export_associations/deeprows', SOURCES_EXT: '/api/timemap_data/export_sources/deepids', SITES_EXT: '', SHAPES_EXT: '', DATE_FMT: 'MM/DD/YYYY', TIME_FMT: 'hh:mm', store: { app: { map: { anchor: [31.356397, 34.784818] } }, features: { COLOR_BY_ASSOCIATION: true, USE_ASSOCIATIONS: true, USE_SOURCES: true, USE_COVER: false, GRAPH_NONLOCATED: false, HIGHLIGHT_GROUPS: false } } } export default config; ================================================ FILE: index.html ================================================ Civilian Harm in Ukraine Timemap - Bellingcat
If you see this message wait up to 30s, otherwise please revisit on a desktop device.
================================================ FILE: package.json ================================================ { "name": "timemap", "version": "0.1.0", "description": "", "homepage": "https://bellingcat.github.io/ukraine-timemap", "private": true, "engines": { "node": "18" }, "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview", "dev:wsl": "vite --host", "test": "vitest", "eslint": "eslint src --ext jsx", "lint": "prettier --check \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"", "lint:fix": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"", "predeploy": "vite build", "deploy": "gh-pages -d build" }, "dependencies": { "@json2csv/plainjs": "^6.1.2", "d3": "^7.4.2", "dayjs": "^1.11.0", "joi": "^17.1.1", "leaflet": "^1.0.3", "marked": "^4.2.5", "object-hash": "^3.0.0", "ramda": "^0.28.0", "react": "^18.0.0", "react-device-detect": "^2.2.2", "react-dom": "^18.0.0", "react-image": "^4.0.3", "react-redux": "^8.0.5", "react-tabs": "^6.0.0", "redux": "^4.0.0", "redux-thunk": "^2.2.0", "reselect": "^4.1.7", "screenfull": "^6.0.2", "scriptjs": "^2.5.9", "supercluster": "^7.1.5", "video-react": "^0.16.0" }, "devDependencies": { "@testing-library/jest-dom": "^5.11.6", "@testing-library/react": "^13.4.0", "@vitejs/plugin-react": "^3.0.1", "eslint": "^8.31.0", "eslint-config-prettier": "^8.6.0", "eslint-plugin-react": "^7.32.0", "eslint-plugin-react-hooks": "^4.6.0", "gh-pages": "^6.0.0", "husky": "^8.0.3", "jest-date-mock": "^1.0.8", "lint-staged": "^13.1.0", "prettier": "^2.2.1", "sass": "^1.57.1", "vite": "^4.0.4", "vitest": "^0.27.1" }, "lint-staged": { "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}": [ "prettier --write" ] }, "husky": { "hooks": { "pre-commit": "lint-staged" } }, "browserslist": { "production": [ ">0.2%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] } } ================================================ FILE: public/CNAME ================================================ ukraine.bellingcat.com ================================================ FILE: public/index.html ================================================ Civilian Harm in Ukraine
If you see this message wait up to 30s, otherwise please revisit on a desktop device.
================================================ FILE: src/actions/index.js ================================================ import { urlFromEnv } from "../common/utilities"; // TODO: relegate these URLs entirely to environment variables // const CONFIG_URL = urlFromEnv('CONFIG_EXT') const EVENT_DATA_URL = urlFromEnv("EVENTS_EXT"); const ASSOCIATIONS_URL = urlFromEnv("ASSOCIATIONS_EXT"); const SOURCES_URL = urlFromEnv("SOURCES_EXT"); const SITES_URL = urlFromEnv("SITES_EXT"); const REGIONS_URL = urlFromEnv("REGIONS_EXT"); const SHAPES_URL = urlFromEnv("SHAPES_EXT"); const domainMsg = (domainType) => `Something went wrong fetching ${domainType}. Check the URL or try disabling them in the config file.`; export function fetchDomain() { const notifications = []; function handleError(message) { notifications.push({ message, type: "error", }); return []; } return (dispatch, getState) => { const features = getState().features; dispatch(toggleFetchingDomain()); // let configPromise = Promise.resolve([]) // if (features.USE_REMOTE_CONFIG) { // configPromise = fetch(CONFIG_URL) // .then(response => response.json()) // .catch(() => handleError("Couldn't find data at the config URL you specified.")) // } // NB: EVENT_DATA_URL is a list, and so results are aggregated const eventPromise = Promise.all( EVENT_DATA_URL.map((url) => fetch(url) .then((response) => response.json()) .catch(() => handleError("events")) ) ).then((results) => results.flatMap((t) => t)); let associationsPromise = Promise.resolve([]); if (features.USE_ASSOCIATIONS) { if (!ASSOCIATIONS_URL) { associationsPromise = Promise.resolve( handleError( "USE_ASSOCIATIONS is true, but you have not provided a ASSOCIATIONS_EXT" ) ); } else { associationsPromise = fetch(ASSOCIATIONS_URL) .then((response) => response.json()) .catch(() => handleError(domainMsg("associations"))); } } let sourcesPromise = Promise.resolve([]); if (features.USE_SOURCES) { if (!SOURCES_URL) { sourcesPromise = Promise.resolve( handleError( "USE_SOURCES is true, but you have not provided a SOURCES_EXT" ) ); } else { sourcesPromise = fetch(SOURCES_URL) .then((response) => response.json()) .catch(() => handleError(domainMsg("sources"))); } } let sitesPromise = Promise.resolve([]); if (features.USE_SITES) { sitesPromise = fetch(SITES_URL) .then((response) => response.json()) .catch(() => handleError(domainMsg("sites"))); } let regionsPromise = Promise.resolve([]); if (features.USE_REGIONS) { regionsPromise = fetch(REGIONS_URL) .then((response) => response.json()) .catch(() => handleError(domainMsg("regions"))); } let shapesPromise = Promise.resolve([]); if (features.USE_SHAPES) { shapesPromise = fetch(SHAPES_URL) .then((response) => response.json()) .catch(() => handleError(domainMsg("shapes"))); } return Promise.all([ eventPromise, associationsPromise, sourcesPromise, sitesPromise, regionsPromise, shapesPromise, ]) .then((response) => { const result = { events: response[0], associations: response[1], sources: response[2], sites: response[3], regions: response[4], shapes: response[5], notifications, }; if ( Object.values(result).some((resp) => resp.hasOwnProperty("error")) ) { throw new Error( "Some URLs returned negative. If you are in development, check the server is running" ); } dispatch(toggleFetchingDomain()); dispatch(setInitialCategories(result.associations)); dispatch(setInitialShapes(result.shapes)); return result; }) .catch((err) => { dispatch(fetchError(err.message)); dispatch(toggleFetchingDomain()); // TODO: handle this appropriately in React hierarchy alert(err.message); }); }; } export const FETCH_ERROR = "FETCH_ERROR"; export function fetchError(message) { return { type: FETCH_ERROR, message, }; } export const UPDATE_DOMAIN = "UPDATE_DOMAIN"; export function updateDomain(payload) { return { type: UPDATE_DOMAIN, payload, }; } export function fetchSource(source) { return (dispatch) => { if (!SOURCES_URL) { dispatch(fetchSourceError("No source extension specified.")); } else { dispatch(toggleFetchingSources()); fetch(`${SOURCES_URL}`) .then((response) => { if (!response.ok) { throw new Error( "No sources are available at the URL specified in the config specified." ); } else { return response.json(); } }) .catch((err) => { dispatch(fetchSourceError(err.message)); dispatch(toggleFetchingSources()); }); } }; } export const UPDATE_HIGHLIGHTED = "UPDATE_HIGHLIGHTED"; export function updateHighlighted(highlighted) { return { type: UPDATE_HIGHLIGHTED, highlighted: highlighted, }; } export const UPDATE_SELECTED = "UPDATE_SELECTED"; export function updateSelected(selected) { return { type: UPDATE_SELECTED, selected: selected, }; } export const UPDATE_DISTRICT = "UPDATE_DISTRICT"; export function updateDistrict(district) { return { type: UPDATE_DISTRICT, district, }; } export const CLEAR_FILTER = "CLEAR_FILTER"; export function clearFilter(filter) { return { type: CLEAR_FILTER, filter, }; } export const TOGGLE_ASSOCIATIONS = "TOGGLE_ASSOCIATIONS"; export function toggleAssociations(association, value, shouldColor) { return { type: TOGGLE_ASSOCIATIONS, association, value, shouldColor, }; } export const TOGGLE_SHAPES = "TOGGLE_SHAPES"; export function toggleShapes(shape) { return { type: TOGGLE_SHAPES, shape, }; } export const SET_LOADING = "SET_LOADING"; export function setLoading() { return { type: SET_LOADING, }; } export const SET_NOT_LOADING = "SET_NOT_LOADING"; export function setNotLoading() { return { type: SET_NOT_LOADING, }; } export const SET_INITIAL_CATEGORIES = "SET_INITIAL_CATEGORIES"; export function setInitialCategories(values) { return { type: SET_INITIAL_CATEGORIES, values, }; } export const SET_INITIAL_SHAPES = "SET_INITIAL_SHAPES"; export function setInitialShapes(values) { return { type: SET_INITIAL_SHAPES, values, }; } export const UPDATE_TIMERANGE = "UPDATE_TIMERANGE"; export function updateTimeRange(timerange) { return { type: UPDATE_TIMERANGE, timerange, }; } export const UPDATE_DIMENSIONS = "UPDATE_DIMENSIONS"; export function updateDimensions(dims) { return { type: UPDATE_DIMENSIONS, dims, }; } export const UPDATE_NARRATIVE = "UPDATE_NARRATIVE"; export function updateNarrative(narrative) { return { type: UPDATE_NARRATIVE, narrative, }; } export const UPDATE_NARRATIVE_STEP_IDX = "UPDATE_NARRATIVE_STEP_IDX"; export function updateNarrativeStepIdx(idx) { return { type: UPDATE_NARRATIVE_STEP_IDX, idx, }; } export const UPDATE_SOURCE = "UPDATE_SOURCE"; export function updateSource(source) { return { type: UPDATE_SOURCE, source, }; } export const UPDATE_COLORING_SET = "UPDATE_COLORING_SET"; export function updateColoringSet(coloringSet) { return { type: UPDATE_COLORING_SET, coloringSet, }; } export const UPDATE_TICKS = "UPDATE_TICKS"; export function updateTicks(ticks) { return { type: UPDATE_TICKS, ticks, }; } // UI export const TOGGLE_SITES = "TOGGLE_SITES"; export function toggleSites() { return { type: TOGGLE_SITES, }; } export const TOGGLE_FETCHING_DOMAIN = "TOGGLE_FETCHING_DOMAIN"; export function toggleFetchingDomain() { return { type: TOGGLE_FETCHING_DOMAIN, }; } export const TOGGLE_FETCHING_SOURCES = "TOGGLE_FETCHING_SOURCES"; export function toggleFetchingSources() { return { type: TOGGLE_FETCHING_SOURCES, }; } export const TOGGLE_LANGUAGE = "TOGGLE_LANGUAGE"; export function toggleLanguage(language) { return { type: TOGGLE_LANGUAGE, language, }; } export const CLOSE_TOOLBAR = "CLOSE_TOOLBAR"; export function closeToolbar() { return { type: CLOSE_TOOLBAR, }; } export const TOGGLE_INFOPOPUP = "TOGGLE_INFOPOPUP"; export function toggleInfoPopup() { return { type: TOGGLE_INFOPOPUP, }; } export const TOGGLE_INTROPOPUP = "TOGGLE_INTROPOPUP"; export function toggleIntroPopup() { return { type: TOGGLE_INTROPOPUP, }; } export const TOGGLE_NOTIFICATIONS = "TOGGLE_NOTIFICATIONS"; export function toggleNotifications() { return { type: TOGGLE_NOTIFICATIONS, }; } export const MARK_NOTIFICATIONS_READ = "MARK_NOTIFICATIONS_READ"; export function markNotificationsRead() { return { type: MARK_NOTIFICATIONS_READ, }; } export const TOGGLE_COVER = "TOGGLE_COVER"; export function toggleCover() { return { type: TOGGLE_COVER, }; } export const TOGGLE_TILE_OVERLAY = "TOGGLE_TILE_OVERLAY"; export function toggleTileOverlay() { return { type: TOGGLE_TILE_OVERLAY, }; } export const UPDATE_SEARCH_QUERY = "UPDATE_SEARCH_QUERY"; export function updateSearchQuery(searchQuery) { return { type: UPDATE_SEARCH_QUERY, searchQuery, }; } // ERRORS export const FETCH_SOURCE_ERROR = "FETCH_SOURCE_ERROR"; export function fetchSourceError(msg) { return { type: FETCH_SOURCE_ERROR, msg, }; } export const TOGGLE_SATELLITE_VIEW = "TOGGLE_SATELLITE_VIEW"; export function toggleSatelliteView() { return { type: TOGGLE_SATELLITE_VIEW, }; } export const REHYDRATE_STATE = "REHYDRATE_STATE"; export function rehydrateState() { return { type: REHYDRATE_STATE, }; } export const UPDATE_MAP_VIEW = "UPDATE_MAP_VIEW"; export function updateMapView(lat, lng, zoom) { return { type: UPDATE_MAP_VIEW, lat, lng, zoom, }; } ================================================ FILE: src/common/constants.js ================================================ export const ASSOCIATION_MODES = { CATEGORY: "CATEGORY", NARRATIVE: "NARRATIVE", FILTER: "FILTER", }; export const SHAPE = "SHAPE"; export const DEFAULT_TAB_ICONS = { CATEGORY: "widgets", NARRATIVE: "timeline", FILTER: "filter_list", SHAPE: "change_history", DOWNLOAD: "download", }; export const AVAILABLE_SHAPES = { STAR: "STAR", DIAMOND: "DIAMOND", PENTAGON: "PENTAGON", SQUARE: "SQUARE", DOT: "DOT", BAR: "BAR", TRIANGLE: "TRIANGLE", }; export const POLYGON_CLIP_PATH = { STAR: "polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)", DIAMOND: "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)", PENTAGON: "polygon(50% 0%, 100% 38%, 82% 100%, 18% 100%, 0% 38%)", TRIANGLE: "polygon(50% 0%, 0% 100%, 100% 100%)", }; export const DEFAULT_CHECKBOX_COLOR = "#ffffff"; ================================================ FILE: src/common/data/copy.json ================================================ { "es-MX": { "tiles": { "default": "Mapa", "satellite": "Sat" }, "loading": "Cargando...", "legend": { "view2d": { "paragraphs": [ "Seleccionando una serie de filtros verá aparecer eventos en el mapa y en la línea del tiempo.", "Cada evento estará coloreado según la persona que dio el testimonio del evento." ], "colors": [ { "class": "category_group00", "label": "Categoría Grupo 00" }, { "class": "category_group01", "label": "Categoría Grupo 01" }, { "class": "category_group02", "label": "Categoría Grupo 02" }, { "class": "category_group03", "label": "Categoría Grupo 03" }, { "class": "other", "label": "Otras categorías" } ] }, "default": { "header": "Ayudas para explorar la plataforma", "intro": [ "Cada **punto** representa un **evento en los datos** (o cada incidente). Al hacer clic en cada punto se ven los detalles del evento. Pero si le da clic en un **grupo** de puntos, verá cuantos eventos hay en ese grupo.", "Puede acercarse en el mapa *(zoom)* haciendo *scroll* con el ratón o haciendo clic en un grupo de puntos.", "Puede usar **filtros** para segmentar los datos. En el mapa sólo vemos los puntos relacionados con cada filtro seleccionado. Cuando no hay filtros seleccionados, vemos todos los puntos de la base de datos en el mapa.", "Al seleccionar más de un filtro se introducen diferentes colores para diferenciarlos. Esto permite comparar los tipos de incidentes tanto en el mapa, como en la línea de tiempo. Esto sirve con un máximo de 6 filtros-colores.", "Con el teclado puede usar las flechas de la derecha e izquierda para moverse entre eventos. También puede hacer clic y arrastrar la línea de tiempo hacia los lados para modificar el rango de tiempo." ], "notation": "Cuando un circulo combina colores significa que hay varios eventos en esa misma ubicación.", "arrows": "Usar las flechas izquierda/derecha en el teclado para moverse entre eventos cronológicamente." } }, "toolbar": { "title": "Título", "filters": "Filtros", "explore_by_filter__title": "Explorar por filtros", "explore_by_filter__description": "Al seleccionar filtros, puede ver los eventos que tienen esa categoria. Para ver todos los eventos puede quitar todas las selecciones (o seleccionarlos todos).", "panels": { "mentions": { "title": "Personas", "overview": "Seleccionar los nombres de personas mostrará eventos en los que esta persona o organización ha sido mencionada, incluyendo el propio testimonio. Entre paréntesis encontrará el número de menciones. Ej. (34)." }, "categories": { "title": "Testimonios", "overview": "Seleccionar el nombre de una persona mostrará los eventos descritos por su testimonio. Entre paréntesis encontrará el número de eventos descritos. Ej. (34)." }, "search": { "title": "Directorio de etiquetas", "placeholder": "Búsqueda" } } }, "timeline": { "zoomLevels": [ { "label": "20 años", "duration": 10512000 }, { "label": "2 años", "duration": 1051200 }, { "label": "3 meses", "duration": 129600 }, { "label": "3 días", "duration": 4320 }, { "label": "12 horas", "duration": 720 }, { "label": "1 hora", "duration": 60 } ], "labels_title": "Testimonios", "labels": [ "Testimonio Grupo 00", "Testimonio Grupo 01", "Testimonio Grupo 02", "Testimonio Grupo 03", "Otras categorias" ], "info": "%n eventos ocurridos entre", "default_categories_label": "Eventos" }, "cardstack": { "date_title": "Fecha incidente", "location_title": "Ubicación", "summary_title": "Resumen", "header": "eventos seleccionados", "unknown_location": "Ubicación desconocida", "unknown_time": "Día y hora desconocida", "timestamp": "Día y hora", "estimated": "aproximado", "location": "Ubicación", "incident_type": "Tipo de acción", "description": "Hechos", "people": "Personas en el evento", "sources": "Fuentes", "category": "Según el testimonio de", "communication": "Comunicación", "transmitter": "Transmisor", "receiver": "Receptor", "warning": "(!) HECHOS CUESTIONADOS" } }, "en-US": { "tiles": { "default": "Map", "satellite": "Sat" }, "loading": "Loading...", "legend": { "view2d": { "paragraphs": [ "Selecting a series of filters, you will be able to explore events on the map of Iguala and on the timeline.", "Each event is colored according the person that gave category of the event." ], "colors": [ { "class": "category_group00", "label": "Category Group 00" }, { "class": "category_group01", "label": "Category Group 01" }, { "class": "category_group02", "label": "Category Group 02" }, { "class": "category_group03", "label": "Category Group 03" }, { "class": "other", "label": "Other categories" } ] }, "default": { "header": "Navigating the Platform", "intro": [ "Each small **dot** represents a **datapoint**, or incident. Click on a dot to see details. Hover over a larger ‘**cluster**’ dot to see how many events it represents.", "Zoom in either with a mouse-scroll or by clicking a ‘cluster’ dot.", "Use **filters** and **categories** to segment the data. Selecting certain filters and categories will show only the datapoints that relate to them. If no filters or categories are selected, all the datapoints are displayed.", "Selecting more than one filter will introduce colour-coded datapoints, which allow you to compare types of incident across time and space. This feature works up to a maximum of six filters.", "Once you have clicked on an event, use the left and right arrows to move back and forward day by day. You can also click and drag anywhere on the timeline. Use the handles on the right to select a date range." ], "notation": "Combinations of colours within a circle indicate multiple events in a single location.", "arrows": "Use the left/right arrows on the keboard to move back and forth between events in time." } }, "toolbar": { "title": "TITLE", "panels": { "mentions": { "title": "Mentions", "overview": "Selecting the names of people/organisation will show events in which these have been mentioned in their own testimony and by others. The number in the parentheses shows how many events contain a mention of a person or organisation, e.g. (34)" }, "categories": { "title": "Testimonies", "overview": "Selecting the name of a person will show the events only according to a person’s category or category. The number in the parentheses show how many events are contained in each category, e.g. (34)." }, "search": { "title": "Directory of filters", "placeholder": "Search" } }, "narratives": "Narratives", "narratives_label": "Narratives", "explore_by_narrative__title": "Explore events by narrative", "explore_by_narrative__description": "Follow a path through the data, from one key event to the next.", "filters": "Filters", "filters_label": "Filters", "explore_by_filter__title": "Explore by filter", "explore_by_filter__description": "'Filters' refer to the types of incident. Select multiple filters to introduce colour-coding, up to a maximum of four filters.

If no filters are selected, all datapoints are displayed.", "categories": "Categories", "categories_label": "Categories", "explore_by_category__title": "Explore events by category", "explore_by_category__description": "", "shapes": "Shapes", "shapes_label": "Shapes", "explore_by_shapes__title": "Explore events by shape breakdown", "explore_by_shape__description": "Shapes map to a given type of event that appears on the timeline.

Select the shape marker to toggle this type of event on / off", "fullscreen_enter": "Fullscreen", "fullscreen_exit": "Exit Fullscreen", "download": { "button": "Download", "panel": { "title": "Download events", "description": "Export the most recent available events in different formats.", "formats": { "api": { "label": "API", "description": "An API endpoint where you can always fetch the entire dataset in JSON format with tools like curl. Useful for integrating the data in other services and visualizaitons." }, "csv": { "label": "CSV", "description": "CSV file where sources and filters are concatenated into a single column each due to data structure limitations." }, "json": { "label": "JSON", "description": "JSON file where each event is a structured object containing nested arrays of sources and filters." } } } } }, "timeline": { "labels_title": "Testimonies", "labels": [ "Testimony Group 00", "Testimony Group 01", "Testimony Group 02", "Testimony Group 03", "Other" ], "info": "Showing %n events that occurred between", "reset": "reset dates", "default_categories_label": "" }, "cardstack": { "header": "selected events", "timestamp": "Day and time", "unknown_location": "Unknown location", "estimated": "estimated", "unknown_time": "Unknown time", "location": "Localization", "incident_type": "Type of action", "description": "Summary", "filters": "Filters", "nofilters": "No known filters for this event.", "sources": "Sources", "unknown_source": "The information for this source could not be retrieved.", "category": "Category", "communication": "Communication", "transmitter": "Transmitter", "receiver": "Receiver", "warning": "(!) Highly questioned" } } } ================================================ FILE: src/common/data/es-MX.json ================================================ { "dateTime": "%x, %X", "date": "%d/%m/%Y", "time": "%-I:%M:%S %p", "periods": ["AM", "PM"], "days": [ "domingo", "lunes", "martes", "miércoles", "jueves", "viernes", "sábado" ], "shortDays": ["dom", "lun", "mar", "mié", "jue", "vie", "sáb"], "months": [ "enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre" ], "shortMonths": [ "ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sep", "oct", "nov", "dic" ] } ================================================ FILE: src/common/global.js ================================================ export const colors = { fa_red: "#eb443e", yellow: "#ffd800", black: "#000", white: "#fff", }; const exports = { fallbackEventColor: colors.fa_red, darkBackground: colors.black, primaryHighlight: colors.fa_red, secondaryHighlight: colors.white, }; export default exports; ================================================ FILE: src/common/utilities.js ================================================ import config from "../../config"; import customParseFormat from "dayjs/plugin/customParseFormat"; import dayjs from "dayjs"; import hash from "object-hash"; import { timeFormatDefaultLocale } from "d3"; import esMxData from "./data/es-MX.json"; import { ASSOCIATION_MODES, POLYGON_CLIP_PATH } from "./constants"; dayjs.extend(customParseFormat); const DATE_FMT = config.DATE_FMT ?? "MM/DD/YYYY"; const TIME_FMT = config.TIME_FMT ?? "HH:mm"; export const language = config.store.app.language || "en-US"; export function getPathLeaf(path) { const splitPath = path.split("/"); return splitPath[splitPath.length - 1]; } export function calcDatetime(date, time) { if (!time) time = "00:00"; const dt = dayjs(`${date} ${time}`, `${DATE_FMT} ${TIME_FMT}`); return dt.toDate(); } export function getCoordinatesForPercent(radius, percent) { const x = radius * Math.cos(2 * Math.PI * percent); const y = radius * Math.sin(2 * Math.PI * percent); return [x, y]; } /** * This function takes the array of percentages: [0.5, 0.5, ...] * and maps it by index to the set of colors ['#fff', '#000', ...] * If there aren't enough colors in the set, it raises an error for the user * * Return value: * ex. {'#fff': 0.5, '#000': 0.5, ...} */ export function zipColorsToPercentages(colors, percentages) { if (colors.length < percentages.length) { throw new Error("You must declare an appropriate number of filter colors"); } return percentages.reduce((map, percent, idx) => { map[colors[idx]] = percent; return map; }, {}); } /** * Compare two arrays of scalars * @param {array} arr1: array of numbers * @param {array} arr2: array of numbers */ export function areEqual(arr1, arr2) { return ( arr1.length === arr2.length && arr1.every((element, index) => { return element === arr2[index]; }) ); } /** * Return whether the variable is neither null nor undefined * @param {object} variable */ export function isNotNullNorUndefined(variable) { return typeof variable !== "undefined" && variable !== null; } /* * Taken from: https://stackoverflow.com/questions/1026069/how-do-i-make-the-first-letter-of-a-string-uppercase-in-javascript */ export function capitalize(string) { return string.charAt(0).toUpperCase() + string.slice(1); } export function trimAndEllipse(string, stringNum) { if (string.length > stringNum) { return string.substring(0, 120) + "..."; } return string; } /** * Takes the complete set of filters, and according to their paths places them at the correct node * * Returns a nested object where: * Key: filter_path_0/filter_path_1/filter_path_2... * Value: {...nested children} */ export function aggregateFilterPaths(filters) { function insertPath( children = {}, [headOfPath, ...remainder], accumulatedPath ) { const childKey = Object.keys(children).find((path) => { const pathLeaf = getPathLeaf(path); return pathLeaf === headOfPath; }); accumulatedPath.push(headOfPath); const accumulatedPlusHead = accumulatedPath.join("/"); if (!childKey) children[accumulatedPlusHead] = {}; if (remainder.length > 0) insertPath(children[accumulatedPlusHead], remainder, accumulatedPath); return children; } const allPaths = []; filters.forEach((filterItem) => allPaths.push(filterItem.filter_paths)); const aggregatedPaths = allPaths.reduce( (children, path) => insertPath(children, path, []), {} ); return aggregatedPaths; } /** * From the set of associations, grab a given filter's set of parents, * ie. all the elements in the path array before the idx where the filter is located. * If we can't find the filter by the ID, we know its a meta filter, so we look * through every association's given path attribute to find its location. * * Returns the list of parents: ex. ['Chemical', 'Tear Gas', ...] */ export function getFilterAncestors(filter) { const splitFilter = filter.split("/"); const ancestors = []; splitFilter.forEach((f, index) => { const accumulatedPath = splitFilter.slice(0, index + 1).join("/"); ancestors.push(accumulatedPath); }); // The last element here will be the leaf node aka the filter passed in ancestors.pop(); return ancestors; } /** * Grabs the second to last element in the paths array for a given existing filter. * This is the filter's most immediate ancestor. */ export function getImmediateFilterParent(filter) { const ancestors = getFilterAncestors(filter); return ancestors[ancestors.length - 1]; } /** * Grabs a given filter's siblings: the set of associations that share the same immediate filter parent. */ export function getFilterSiblings(allFilters, filterParent, filterKey) { function findSiblings(filterPathObj, ancestors) { if (ancestors.length === 0 || filterPathObj === {}) return {}; const nextAncestor = ancestors.shift(); if (Object.keys(filterPathObj).includes(nextAncestor)) { const nextObjToSearch = filterPathObj[nextAncestor]; if (ancestors.length === 0) { return nextObjToSearch; } else { return findSiblings(nextObjToSearch, ancestors); } } } const aggregatedFilters = aggregateFilterPaths(allFilters); const ancestors = getFilterAncestors(filterKey); const siblings = findSiblings(aggregatedFilters, ancestors); return Object.keys(siblings).filter((sib) => sib !== filterKey); } /** * Looks at the current coloring set (ie. a map between sets of filters and colors) and configures where to add next set */ export function addToColoringSet(coloringSet, filters) { const flattenedColoringSet = coloringSet.flatMap((f) => f); const newColoringSet = filters.filter( (k) => flattenedColoringSet.indexOf(k) === -1 ); return [...coloringSet, newColoringSet]; } /** * Looks at the current coloring set (ie. a map between sets of filters and colors) and configures new sets based off of existing filters */ export function removeFromColoringSet(coloringSet, filters) { const newColoringSets = coloringSet.map((set) => set.filter((s) => { return !filters.includes(s); }) ); return newColoringSets.filter((item) => item.length !== 0); } export function getEventCategories(event, activeCategories) { const eventCats = event.associations.filter( (a) => a.mode === ASSOCIATION_MODES.CATEGORY ); return eventCats.reduce((acc, val) => { const activeCatTitle = activeCategories.find((cat) => cat === val.title); if (activeCatTitle) acc.push(activeCatTitle); return acc; }, []); } /** * Takes a filter's path and concatenates it like so: Parent 1/Parent 2/Child */ export function createFilterPathString(filter) { return filter.filter_paths.join("/"); } /** * Inset the full source represenation from 'allSources' into an event. The * function is 'curried' to allow easy use with maps. To use for a single * source, call with two sets of parentheses: * const src = insetSourceFrom(sources)(anEvent) */ export function insetSourceFrom(allSources) { return (event) => { let sources; if (!event.sources) { sources = []; } else { sources = event.sources.map((id) => { return allSources.hasOwnProperty(id) ? allSources[id] : null; }); } return { ...event, sources, }; }; } /** * Debugging function: put in place of a mapStateToProps function to * view that source modal by default */ export function injectSource(id) { return (state) => { return { ...state, app: { ...state.app, source: state.domain.sources[id], }, }; }; } const API_ROOT = import.meta.env.MODE === "development" ? "" : config.SERVER_ROOT; export function urlFromEnv(ext) { if (config[ext]) { if (!Array.isArray(config[ext])) { return [`${API_ROOT}${config[ext]}`]; } else { return config[ext].map((suffix) => `${API_ROOT}${suffix}`); } } else { return null; } } export function toggleFlagAC(flag) { return (appState) => ({ ...appState, flags: { ...appState.flags, [flag]: !appState.flags[flag], }, }); } export function selectTypeFromPath(path) { let type; switch (true) { case /\.(png|jpg)$/.test(path): type = "Image"; break; case /\.(mp4)$/.test(path): type = "Video"; break; case /\.(md)$/.test(path): type = "Text"; break; default: type = "Unknown"; break; } return { type, path }; } export function typeForPath(path) { let type; path = path.trim(); switch (true) { case /\.((png)|(jpg)|(jpeg))$/.test(path): type = "Image"; break; case /\.(mp4)$/.test(path): type = "Video"; break; case /\.(md)$/.test(path): type = "Text"; break; case /\.(pdf)$/.test(path): type = "Document"; break; case /.+(twitter\.com).+/.test(path): type = "Tweet"; break; case /.+(t\.me).+/.test(path): type = "Telegram"; break; default: type = "Unknown"; break; } return type; } export function selectTypeFromPathWithPoster(path, poster) { return { type: typeForPath(path), path, poster }; } export function isIdentical(obj1, obj2) { return hash(obj1) === hash(obj2); } export function calcOpacity(num) { /* Events have opacity 0.5 by default, and get added to according to how many * other events there are in the same render. The idea here is that the * overlaying of events builds up a 'heat map' of the event space, where * darker areas represent more events with proportion */ const base = num >= 1 ? 0.9 : 0; return base + Math.min(0.5, 0.08 * (num - 1)); } export function calcClusterOpacity(pointCount, totalPoints) { /* Clusters represent multiple events within a specific radius. The darker the cluster, the larger the number of underlying events. We use a multiplication factor (50) here as well to ensure that the larger clusters have an appropriately darker shading. */ return Math.min(0.85, 0.08 + (pointCount / totalPoints) * 50); } export function calcClusterSize(pointCount, totalPoints) { /* The larger the cluster size, the higher the count of points that the cluster represents. Just like with opacity, we use a multiplication factor to ensure that clusters with higher point counts appear larger. */ //TO-DO: Convert maxSize into a config var const maxSize = totalPoints > 60 ? 60 : 35; return Math.min(maxSize, 10 + (pointCount / totalPoints) * 100); } export function calculateTotalClusterPoints(clusters) { return clusters.reduce((total, cl) => { if (cl && cl.properties && cl.properties.cluster) { total += cl.properties.point_count; } return total; }, 0); } export function isLatitude(lat) { return !!lat && isFinite(lat) && Math.abs(lat) <= 90; } export function isLongitude(lng) { return !!lng && isFinite(lng) && Math.abs(lng) <= 180; } export function mapClustersToLocations(clusters, locations) { return clusters.reduce((acc, cl) => { const foundLocation = locations.find( (location) => location.label === cl.properties.id ); if (foundLocation) acc.push(foundLocation); return acc; }, []); } /** * Loops through a set of either locations or events * and calculates the proportionate percentage of every given association in relation to the coloring set */ export function calculateColorPercentages(set, coloringSet) { if (coloringSet.length === 0) return [1]; const associationMap = {}; for (const [idx, value] of coloringSet.entries()) { for (const filter of value) { associationMap[filter] = idx; } } const associationCounts = new Array(coloringSet.length); associationCounts.fill(0); let totalAssociations = 0; set.forEach((item) => { let innerSet = "events" in item ? item.events : item; if (!Array.isArray(innerSet)) innerSet = [innerSet]; innerSet.forEach((val) => { val.associations.forEach((a) => { const idx = associationMap[createFilterPathString(a)]; if (!idx && idx !== 0) return; associationCounts[idx] += 1; totalAssociations += 1; }); }); }); if (totalAssociations === 0) return [1]; return associationCounts.map((count) => count / totalAssociations); } /** * Gets the idx of a given filter in relation to its position in the coloring set * * Example coloringSet = [['Chemical', 'Tear Gas'], ['Procedural', 'Destruction of property']] */ export function getFilterIdxFromColorSet(filter, coloringSet) { let filterIdx = -1; coloringSet.map((set, idx) => { const foundIdx = set.indexOf(filter); if (foundIdx !== -1) filterIdx = idx; return null; }); return filterIdx; } export const dateMin = function () { return Array.prototype.slice.call(arguments).reduce(function (a, b) { return a < b ? a : b; }); }; export const dateMax = function () { return Array.prototype.slice.call(arguments).reduce(function (a, b) { return a > b ? a : b; }); }; /** Taken from * https://stackoverflow.com/questions/22697936/binary-search-in-javascript * **/ export function binarySearch(ar, el, compareFn) { let m = 0; let n = ar.length - 1; while (m <= n) { const k = (n + m) >> 1; const cmp = compareFn(el, ar[k]); if (cmp > 0) { m = k + 1; } else if (cmp < 0) { n = k - 1; } else { return k; } } return -m - 1; } export function makeNiceDate(datetime) { if (datetime === null) return null; // see https://stackoverflow.com/questions/3552461/how-to-format-a-javascript-date const dateTimeFormat = new Intl.DateTimeFormat(language, { year: "numeric", month: "long", day: "2-digit", }); const [{ value: month }, , { value: day }, , { value: year }] = dateTimeFormat.formatToParts(datetime); return `${day} ${month}, ${year}`; } /** * Sets the default locale for d3 to format dates in each available language. */ export function setD3Locale() { const languages = { "es-MX": esMxData, }; if (language !== "es-US" && languages[language]) { timeFormatDefaultLocale(languages[language]); } } /** * Gets the set of associated styles for a given shape type from the entire set of shapes * @param list shapes - The aggregated set of shapes * @param list activeShapes - The set of active shapes in the app */ export function mapStyleByShape(shapes, activeShapes) { const styledShapes = shapes.map((s) => { const { colour, shape, id } = s; const style = { checkboxStyles: { background: activeShapes.includes(id) ? colour : "black", border: "none", clipPath: POLYGON_CLIP_PATH[shape], }, containerStyles: { background: colour, clipPath: POLYGON_CLIP_PATH[shape], }, }; s.styles = style; return s; }); return styledShapes; } export function mapCategoriesToPaths(categories, panelCategories) { const mappedCats = categories.reduce((acc, cat) => { const type = cat.filter_paths[0]; if (!(type in acc)) { acc[type] = []; } acc[type].push(cat); return acc; }, {}); const categoryMap = panelCategories.length > 1 ? mappedCats : { default: categories }; return categoryMap; } export function getCategoryIdxs(panelCategories, startingIdx) { let idxCounter = startingIdx; // If there are specified categories from the config, filter out the default value; else, leave the default value const catTypes = panelCategories.length > 1 ? panelCategories.filter((val) => val !== "default") : panelCategories; return catTypes.reduce((set, val) => { set[val] = idxCounter; idxCounter += 1; return set; }, {}); } export function getFilterIdx( narrativesExist, categoriesExist, numCategoryPanels ) { if (narrativesExist && !categoriesExist) return 1; else if (!narrativesExist && categoriesExist) return numCategoryPanels; else if (narrativesExist && categoriesExist) return numCategoryPanels + 1; else return 0; } export function downloadAsFile(filename, content) { let element = document.createElement("a"); element.setAttribute( "href", `data:application/octet-stream;charset=utf-8,${encodeURIComponent(content)}` ); element.setAttribute("download", filename); element.style.display = "none"; document.body.appendChild(element); element.click(); document.body.removeChild(element); } export const isEmptyString = (s) => s.length === 0; export const isOdd = (num) => num % 2 !== 0; export function isEmptyObject(o) { return o == null || (typeof o === "object" && !Object.keys(o).length); } ================================================ FILE: src/components/App.jsx ================================================ import "../scss/main.scss"; import { Component } from "react"; import Layout from "./Layout"; class App extends Component { render() { return ; } } export default App; ================================================ FILE: src/components/InfoPopup.jsx ================================================ import Popup from "./atoms/Popup"; import copy from "../common/data/copy.json"; const Infopopup = ({ isOpen, onClose, language, styles }) => ( ); export default Infopopup; ================================================ FILE: src/components/Layout.jsx ================================================ import { Component } from "react"; import { bindActionCreators } from "redux"; import { connect } from "react-redux"; import * as actions from "../actions"; import * as selectors from "../selectors"; import Toolbar from "./Toolbar"; import InfoPopup from "./InfoPopup"; import Notification from "./Notification"; import TemplateCover from "./TemplateCover"; import Popup from "./atoms/Popup"; import StaticPage from "./atoms/StaticPage"; import MediaOverlay from "./atoms/Media"; import LoadingOverlay from "./atoms/Loading"; import Timeline from "./time/Timeline"; import Space from "./space/Space"; import Search from "./controls/Search"; import CardStack from "./controls/CardStack"; import NarrativeControls from "./controls/NarrativeControls"; import colors from "../common/global"; import { binarySearch, insetSourceFrom } from "../common/utilities"; class Dashboard extends Component { constructor(props) { super(props); this.handleViewSource = this.handleViewSource.bind(this); this.handleHighlight = this.handleHighlight.bind(this); this.setNarrative = this.setNarrative.bind(this); this.setNarrativeFromFilters = this.setNarrativeFromFilters.bind(this); this.handleSelect = this.handleSelect.bind(this); this.getCategoryColor = this.getCategoryColor.bind(this); this.findEventIdx = this.findEventIdx.bind(this); this.onKeyDown = this.onKeyDown.bind(this); this.selectNarrativeStep = this.selectNarrativeStep.bind(this); } componentDidMount() { this.props.actions.fetchDomain().then((domain) => { this.props.actions.updateDomain({ domain, features: this.props.features, }); this.props.actions.rehydrateState(); }); // NOTE: hack to get the timeline to always show. Not entirely sure why // this is necessary. window.dispatchEvent(new Event("resize")); } handleHighlight(highlighted) { this.props.actions.updateHighlighted(highlighted || null); } handleViewSource(source) { this.props.actions.updateSource(source); } findEventIdx(theEvent) { const { events } = this.props.domain; return binarySearch(events, theEvent, (theev, otherev) => { return theev.datetime - otherev.datetime; }); } handleSelect(selected, axis) { if (selected.length <= 0) { this.props.actions.updateSelected([]); return; } const matchedEvents = []; const TIMELINE_AXIS = 0; if (axis === TIMELINE_AXIS) { matchedEvents.push(selected); // find in events const { events } = this.props.domain; const idx = this.findEventIdx(selected); // binary search can return event with different id if (events[idx].id !== selected.id) { matchedEvents.push(events[idx]); } // check events before let ptr = idx - 1; while ( ptr >= 0 && events[idx].datetime.getTime() === events[ptr].datetime.getTime() ) { if (events[ptr].id !== selected.id) { matchedEvents.push(events[ptr]); } ptr -= 1; } // check events after ptr = idx + 1; while ( ptr < events.length && events[idx].datetime.getTime() === events[ptr].datetime.getTime() ) { if (events[ptr].id !== selected.id) { matchedEvents.push(events[ptr]); } ptr += 1; } } else { // Map.. const std = { ...selected }; delete std.sources; Object.values(std).forEach((ev) => matchedEvents.push(ev)); } this.props.actions.updateSelected(matchedEvents); } getCategoryColor(category) { if (!this.props.features.USE_CATEGORIES) { return colors.fallbackEventColor; } const cat = this.props.ui.style.categories[category]; if (cat) { return cat; } else { return this.props.ui.style.categories.default; } } setNarrative(narrative) { // only handleSelect if narrative is not null and has associated events if (narrative && narrative.steps.length >= 1) { this.handleSelect([narrative.steps[0]]); } this.props.actions.updateNarrative(narrative); } setNarrativeFromFilters(withSteps) { const { app, domain } = this.props; let activeFilters = app.associations.filters; if (activeFilters.length === 0) { alert("No filters selected, cant narrativise"); return; } activeFilters = activeFilters.map((f) => ({ name: f })); const evs = domain.events.filter((ev) => { let hasOne = false; // add event if it has at least one matching filter for (let i = 0; i < activeFilters.length; i++) { if (ev.associations.includes(activeFilters[i].name)) { hasOne = true; break; } } if (hasOne) return true; return false; }); if (evs.length === 0) { alert("No associated events, cant narrativise"); return; } const name = activeFilters.map((f) => f.name).join("-"); const desc = activeFilters.map((f) => f.description).join("\n\n"); this.setNarrative({ id: name, label: name, description: desc, withLines: withSteps, steps: evs.map(insetSourceFrom(domain.sources)), }); } selectNarrativeStep(idx) { // Try to find idx if event passed rather than number if (typeof idx !== "number") { const e = idx[0] || idx; if (this.props.app.associations.narrative) { const { steps } = this.props.app.associations.narrative; // choose the first event at a given location const locationEventId = e.id; const narrativeIdxObj = steps.find((s) => s.id === locationEventId); const narrativeIdx = steps.indexOf(narrativeIdxObj); if (narrativeIdx > -1) { idx = narrativeIdx; } } } const { narrative } = this.props.app.associations; if (narrative === null) return; if (idx < narrative.steps.length && idx >= 0) { const step = narrative.steps[idx]; this.handleSelect([step]); this.props.actions.updateNarrativeStepIdx(idx); } } onKeyDown(e) { const { narrative, selected } = this.props.app; const { events } = this.props.domain; const prev = (idx) => { if (narrative === null) { this.handleSelect(events[idx - 1], 0); } else { this.selectNarrativeStep(this.props.narrativeIdx - 1); } }; const next = (idx) => { if (narrative === null) { this.handleSelect(events[idx + 1], 0); } else { this.selectNarrativeStep(this.props.narrativeIdx + 1); } }; if (selected.length > 0) { const ev = selected[selected.length - 1]; const idx = this.findEventIdx(ev); switch (e.keyCode) { case 37: // left arrow case 38: // up arrow if (idx <= 0) return; prev(idx); break; case 39: // right arrow case 40: // down arrow if (idx < 0 || idx >= this.props.domain.length - 1) return; next(idx); break; default: } } } renderIntroPopup(styles) { const { app, actions } = this.props; const localStorageKey = "rememberDismissedIntro2"; // can be incremented when new data appears on the cover let searchParams = new URLSearchParams(window.location.href.split("?")[1]); let rememberDismissedIntro = localStorage.getItem(localStorageKey) === "true"; let forceShowIntro = searchParams.get("cover") === "true"; if ( (forceShowIntro || !rememberDismissedIntro) && !searchParams.has("id") ) { return ( { actions.toggleIntroPopup(); localStorage.setItem(localStorageKey, "true"); }} content={app.intro} styles={styles} > ); } else { return null; } } render() { const { actions, app, domain, timeline, features } = this.props; const popupStyles = {}; return (
{ actions.toggleAssociations("filters", filters), onCategoryFilter: (categories) => actions.toggleAssociations("categories", categories), onShapeFilter: actions.toggleShapes, onSelectNarrative: this.setNarrative, }} /> } this.handleSelect(ev, 1), }} /> { this.handleSelect(ev, 0), onUpdateTimerange: actions.updateTimeRange, getCategoryColor: this.getCategoryColor, }} /> } null } onHighlight={this.handleHighlight} onToggleCardstack={() => actions.updateSelected([])} getCategoryColor={this.getCategoryColor} /> this.selectNarrativeStep(this.props.narrativeIdx + 1), onPrev: () => this.selectNarrativeStep(this.props.narrativeIdx - 1), onSelectNarrative: this.setNarrative, }} /> {this.renderIntroPopup(popupStyles)} {app.debug ? ( ) : null} {features.USE_SEARCH && ( )} {app.source ? ( { actions.updateSource(null); }} /> ) : null} {features.USE_COVER && ( {/* enable USE_COVER in config.js features, and customise your header */} {/* pass 'actions.toggleCover' as a prop to your custom header */} )}
); } } function mapDispatchToProps(dispatch) { return { actions: bindActionCreators(actions, dispatch), }; } export default connect( (state) => ({ ...state, timeline: { dimensions: selectors.selectDimensions(state), }, narrativeIdx: selectors.selectNarrativeIdx(state), narratives: selectors.selectNarratives(state), selected: selectors.selectSelected(state), }), mapDispatchToProps )(Dashboard); ================================================ FILE: src/components/Notification.jsx ================================================ import { Component } from "react"; export default class Notification extends Component { constructor(props) { super(); this.state = { isExtended: false, }; } toggleDetails() { this.setState({ isExtended: !this.state.isExtended }); } renderItems(items) { if (!items) return ""; return (
{items.map((item, idx) => { if (item.error) { return

{item.error.message}

; } return null; })}
); } renderNotificationContent(notification) { const { type, message, items } = notification; return (
{message}
{items !== null ? this.renderItems(items) : ""}
); } render() { if (!this.props.notifications) return null; const notificationsToRender = this.props.notifications.filter( (n) => !("isRead" in n && n.isRead) ); if (notificationsToRender.length > 0) { return (
{this.props.notifications.map((notification, idx) => { return (
this.toggleDetails()} key={idx} > {this.renderNotificationContent(notification)}
); })}
); } return
; } } ================================================ FILE: src/components/Portal.jsx ================================================ import { Component } from "react"; import ReactDOM from "react-dom"; class Portal extends Component { render() { const { children, node } = this.props; if (!node) return null; return ReactDOM.createPortal(children, node); } } export default Portal; ================================================ FILE: src/components/TemplateCover.jsx ================================================ import { Component } from "react"; import { connect } from "react-redux"; import { Player } from "video-react"; import { marked } from "marked"; import MediaOverlay from "./atoms/Media"; // import falogo from "../assets/fa-logo.png"; import bcatlogo from "../assets/bellingcat-logo.png"; const MEDIA_HIDDEN = -2; /** * Manages the presentation of props that come in from the store's app.cover. * These are documented in docs/custom-cover.md. * The component is a bit of a mess, keeping a lot of internal state and using * a couple of weird offset calculations... but it works for the time being. */ class TemplateCover extends Component { constructor(props) { super(props); this.state = { video: MEDIA_HIDDEN, featureLang: 0, }; } getVideo(index, headerEndIndex) { if (index < headerEndIndex) { return this.props.cover.headerVideos[index]; } else if (index >= 0) { return this.props.cover.videos[index - headerEndIndex]; } else { return null; } } onVideoClickHandler(index) { const buffer = this.props.cover.headerVideos ? this.props.cover.headerVideos.length : 0; return () => { this.setState({ video: index + buffer, }); }; } renderFeature() { const { featureVideo } = this.props.cover; const { featureLang } = this.state; const { translations } = featureVideo; const source = featureLang === 0 ? featureVideo : { ...translations[featureLang - 1], poster: featureVideo.poster, }; return (
{translations && translations.map((trans, idx) => { const langIdx = idx + 1; // default lang idx is 0 if (featureLang !== langIdx) { return (
this.setState({ featureLang: langIdx })} className="trans-button" > {trans.code}
); } else { return (
this.setState({ featureLang: 0 })} className="trans-button" > ENG
); } })}
); } renderHeaderVideos() { const { headerVideos } = this.props.cover; return (
{headerVideos.slice(0, 2).map((media, index) => (
this.setState({ video: index })} > {media.buttonTitle}
))}
); } renderButton(button, yellow) { return (
{button.title}
); } renderMediaOverlay() { const video = this.getVideo( this.state.video, this.props.cover.headerVideos ? this.props.cover.headerVideos.length : 0 ); return ( this.setState({ video: MEDIA_HIDDEN })} /> ); } render() { if (!this.props.cover) { return (
You haven't specified any cover props. Put them in the values that overwrite the store in app.cover
); } const { videos, footerButton } = this.props.cover; const { showing } = this.props; return (
{this.props.cover.bgVideo ? (
) : null}

{this.props.cover.subtitle ? (

{this.props.cover.subtitle}

) : null} {this.props.cover.subsubtitle ? (
{this.props.cover.subsubtitle}
) : null} {this.props.cover.featureVideo ? this.renderFeature() : null}
{this.props.cover.headerVideos ? this.renderHeaderVideos() : null} {this.props.cover.headerButton ? this.renderButton(this.props.cover.headerButton) : null}
{this.props.cover.exploreButton}
{Array.isArray(this.props.cover.description) ? ( this.props.cover.description.map((e, index) => (
)) ) : (
)} {videos ? (
{/* NOTE: only take first four videos, drop any others for style reasons */} {videos && videos.slice(0, 2).map((media, index) => (
{media.buttonTitle}
{media.buttonSubtitle}
))}
{videos.length > 2 && this.props.cover.videos.slice(2, 4).map((media, index) => (
{media.buttonTitle}
{media.buttonSubtitle}
))}
) : null} {footerButton ? (
{this.renderButton(footerButton)}
) : null}
{this.state.video !== MEDIA_HIDDEN ? this.renderMediaOverlay() : null}
); } } function mapStateToProps(state) { return { cover: state.app.cover, }; } export default connect(mapStateToProps)(TemplateCover); ================================================ FILE: src/components/Toolbar.jsx ================================================ import { Component } from "react"; import { connect } from "react-redux"; import { bindActionCreators } from "redux"; import * as actions from "../actions"; import * as selectors from "../selectors"; import config from "../../config"; import { Tabs, TabList, TabPanel } from "react-tabs"; import FilterListPanel from "./controls/FilterListPanel"; import CategoriesListPanel from "./controls/CategoriesListPanel"; import ShapesListPanel from "./controls/ShapesListPanel"; import BottomActions from "./controls/BottomActions"; import copy from "../common/data/copy.json"; import { trimAndEllipse, getImmediateFilterParent, getFilterSiblings, getFilterAncestors, addToColoringSet, removeFromColoringSet, mapCategoriesToPaths, getCategoryIdxs, getFilterIdx, } from "../common/utilities"; import { ToolbarButton } from "./controls/atoms/ToolbarButton"; import { FullscreenToggle } from "./controls/FullScreenToggle"; import DownloadPanel from "./controls/DownloadPanel"; class Toolbar extends Component { constructor(props) { super(props); this.onSelectFilter = this.onSelectFilter.bind(this); this.state = { _selected: 0, _active: false }; } selectTab(selected) { let active = true; if (this.state._selected === selected && this.state._active === true) { active = false; } this.setState({ _selected: selected, _active: active }); } onSelectFilter(key, matchingKeys) { const { filters, activeFilters, coloringSet, maxNumOfColors } = this.props; const parent = getImmediateFilterParent(key); const isTurningOff = activeFilters.includes(key); if (!isTurningOff) { const updatedColoringSet = addToColoringSet(coloringSet, matchingKeys); if (updatedColoringSet.length <= maxNumOfColors) { this.props.actions.updateColoringSet(updatedColoringSet); } } else { if (parent && activeFilters.includes(parent)) { const siblings = getFilterSiblings(filters, parent, key); let siblingsOff = true; for (const sibling of siblings) { if (activeFilters.includes(sibling)) { siblingsOff = false; break; } } if (siblingsOff) { const grandparentsOn = getFilterAncestors(key).filter((filt) => activeFilters.includes(filt) ); matchingKeys = matchingKeys.concat(grandparentsOn); } } const updatedColoringSet = removeFromColoringSet( coloringSet, matchingKeys ); this.props.actions.updateColoringSet(updatedColoringSet); } this.props.methods.onSelectFilter(matchingKeys); this.props.actions.updateSelected([]); } renderClosePanel() { return (
this.selectTab(this.state._selected)} >
); } goToNarrative(narrative) { // this.selectTab(-1); // set all unselected within this component this.props.methods.onSelectNarrative(narrative); } renderToolbarNarrativePanel() { const { panels } = this.props.toolbarCopy; return (

{panels.narratives.label}

{panels.narratives.description}

{this.props.narratives.map((narr) => { return (
); })}
); } renderToolbarCategoriesPanel() { const { categories: panelCategories } = this.props.toolbarCopy.panels; const catMap = mapCategoriesToPaths( this.props.categories, Object.keys(panelCategories) ); return (
{Object.keys(catMap).map((type) => { const children = catMap[type]; return ( ); })}
); } renderToolbarFilterPanel() { const { panels } = this.props.toolbarCopy; return ( ); } renderToolbarShapePanel() { const { panels } = this.props.toolbarCopy; if (this.props.features.USE_SHAPES) { return ( ); } } renderToolbarDownloadPanel() { const { panels } = this.props.toolbarCopy; return ( ); } renderToolbarTab(_selected, label, iconKey, key) { return ( { this.selectTab(_selected); }} /> ); } renderToolbarCategoryTabs(idxs) { const { categories: panelCategories } = this.props.toolbarCopy.panels; return (
{Object.keys(idxs).map((key) => { return this.renderToolbarTab( idxs[key], panelCategories[key].label, panelCategories[key].icon, key ); })}
); } renderToolbarPanels() { const { features, narratives } = this.props; const classes = this.state._active === true ? "toolbar-panels" : "toolbar-panels folded"; return (
{this.renderClosePanel()} {narratives && narratives.length !== 0 ? this.renderToolbarNarrativePanel() : null} {features.USE_CATEGORIES ? this.renderToolbarCategoriesPanel() : null} {features.USE_ASSOCIATIONS ? this.renderToolbarFilterPanel() : null} {features.USE_SHAPES ? this.renderToolbarShapePanel() : null} {features.USE_DOWNLOAD ? this.renderToolbarDownloadPanel() : null}
); } renderToolbarNavs() { if (this.props.narratives) { return this.props.narratives.map((nar, idx) => { const isActive = idx === this.state._selected && this.state._active === true; const classes = isActive ? "toolbar-tab active" : "toolbar-tab"; return (
{ this.selectTab(idx); }} >
{nar.label}
); }); } return null; } renderToolbarTabs() { const { features, narratives, toolbarCopy } = this.props; const narrativesExist = narratives && narratives.length !== 0; let title = copy[this.props.language].toolbar.title; if (config.display_title) title = config.display_title; const { panels } = toolbarCopy; const narrativesIdx = 0; const categoryIdxs = getCategoryIdxs( Object.keys(panels.categories), narrativesExist ? 1 : 0 ); const numCategoryPanels = Object.keys(categoryIdxs).length; const filtersIdx = getFilterIdx( narrativesExist, features.USE_CATEGORIES, numCategoryPanels || 0 ); const shapesIdx = filtersIdx + features.USE_SHAPES; const downloadIdx = shapesIdx + features.USE_DOWNLOAD; return (

{title}

{narrativesExist ? this.renderToolbarTab( narrativesIdx, panels.narratives.label, panels.narratives.icon ) : null} {features.USE_CATEGORIES ? this.renderToolbarCategoryTabs(categoryIdxs) : null} {features.USE_ASSOCIATIONS ? this.renderToolbarTab( filtersIdx, panels.filters.label, panels.filters.icon ) : null} {features.USE_SHAPES ? this.renderToolbarTab( shapesIdx, panels.shapes.label, panels.shapes.icon ) : null} {features.USE_DOWNLOAD ? this.renderToolbarTab( downloadIdx, panels.download.label, panels.download.icon ) : null} {features.USE_FULLSCREEN && ( )}
Made with{" "} TimeMap
Free software from{" "} Forensic Architecture
); } render() { const { isNarrative } = this.props; return (
null} selectedIndex={this.state._selected}> {this.renderToolbarTabs()} {this.renderToolbarPanels()}
); } } function mapStateToProps(state) { return { filters: selectors.getFilters(state), categories: selectors.getCategories(state), narratives: selectors.selectNarratives(state), shapes: selectors.getShapes(state), language: state.app.language, toolbarCopy: state.app.toolbar, activeFilters: selectors.getActiveFilters(state), activeCategories: selectors.getActiveCategories(state), activeShapes: selectors.getActiveShapes(state), viewFilters: state.app.associations.views, narrative: state.app.associations.narrative, sitesShowing: state.app.flags.isShowingSites, infoShowing: state.app.flags.isInfopopup, coloringSet: state.app.associations.coloringSet, maxNumOfColors: state.ui.coloring.maxNumOfColors, filterColors: state.ui.coloring.colors, eventRadius: state.ui.eventRadius, features: selectors.getFeatures(state), }; } function mapDispatchToProps(dispatch) { return { actions: bindActionCreators(actions, dispatch), }; } export default connect(mapStateToProps, mapDispatchToProps)(Toolbar); ================================================ FILE: src/components/atoms/Checkbox.jsx ================================================ import { DEFAULT_CHECKBOX_COLOR } from "../../common/constants"; const Checkbox = ({ label, isActive, onClickCheckbox, color, styleProps }) => { const checkboxColor = color ? color : DEFAULT_CHECKBOX_COLOR; const baseStyles = { checkboxStyles: { background: isActive ? checkboxColor : "none", border: `1px solid ${checkboxColor}`, }, }; const containerStyles = styleProps ? styleProps.containerStyles : {}; const checkboxStyles = styleProps ? styleProps.checkboxStyles : baseStyles.checkboxStyles; const generatedId = label.toLowerCase().replaceAll(" ", "-"); const onClickCheckboxWrapper = (e) => { // stop propagation in order to call method only one time e.stopPropagation(); onClickCheckbox(e); }; return (
); }; export default Checkbox; ================================================ FILE: src/components/atoms/CoeventIcon.jsx ================================================ const CoeventIcon = ({ isEnabled, toggleMapViews }) => { return ( ); }; export default CoeventIcon; ================================================ FILE: src/components/atoms/ColoredMarkers.jsx ================================================ import { getCoordinatesForPercent } from "../../common/utilities"; function ColoredMarkers({ radius, colorPercentMap, styles, className }) { let cumulativeAngleSweep = 0; const colors = Object.keys(colorPercentMap); return ( <> {colors.map((color, idx) => { const colorPercent = colorPercentMap[color]; const [startX, startY] = getCoordinatesForPercent( radius, cumulativeAngleSweep ); cumulativeAngleSweep += colorPercent; const [endX, endY] = getCoordinatesForPercent( radius, cumulativeAngleSweep ); // if the slices are less than 2, take the long arc const largeArcFlag = colors.length === 1 || colorPercent > 0.5 ? 1 : 0; // create an array and join it just for code readability const arc = [ `M ${startX} ${startY}`, // Move `A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endX} ${endY}`, // Arc "L 0 0 ", // Line `L ${startX} ${startY} Z`, // Line ].join(" "); const extraStyles = { ...styles, fill: color, }; return ( ); })} ); } export default ColoredMarkers; ================================================ FILE: src/components/atoms/Content.jsx ================================================ import { Player } from "video-react"; import { Img } from "react-image"; import Md from "./Md"; import Spinner from "../atoms/Spinner"; import NoSource from "../atoms/NoSource"; const Content = ({ media, viewIdx, translations, switchLanguage, langIdx }) => { const el = document.querySelector(".source-media-gallery"); const shiftW = el ? el.getBoundingClientRect().width : 0; function renderMedia(media) { const { path, type, poster } = media; switch (type) { case "Image": return (
} unloader={} onClick={() => window.open(path, "_blank")} />
); case "Video": return (
{translations ? translations.map((trans, idx) => langIdx !== idx + 1 ? (
switchLanguage(idx + 1)} > {trans.code}
) : (
switchLanguage(0)} > EN
) ) : null}
); case "Text": return (
} unloader={() => this.renderError()} />
); case "Document": return